diff --git a/database/music_database.py b/database/music_database.py index e12700a0..7b352a34 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4747,6 +4747,48 @@ class MusicDatabase: logger.error(f"Error setting Spotify tokens for profile {profile_id}: {e}") return False + def set_profile_tidal_tokens(self, profile_id: int, access_token: str, refresh_token: str) -> bool: + """Save Tidal OAuth tokens for a profile (encrypted). Used by the + per-profile Tidal client's token refresh — keeps a profile's refresh from + ever touching the global tidal_tokens slot.""" + 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 tidal_access_token = ?, tidal_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 Tidal tokens for profile {profile_id}: {e}") + return False + + def get_profile_tidal(self, profile_id: int) -> Dict[str, Any]: + """Get decrypted Tidal tokens for a profile ({} if none).""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT tidal_access_token, tidal_refresh_token FROM profiles WHERE id = ?", + (profile_id,)) + row = cursor.fetchone() + if not row or not row[0]: + return {} + return { + 'access_token': config_manager._decrypt_value(row[0]) if row[0] else '', + 'refresh_token': config_manager._decrypt_value(row[1]) if row[1] else '', + } + except Exception as e: + logger.error(f"Error getting Tidal tokens for profile {profile_id}: {e}") + return {} + 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.""" diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 8c448e8d..286579a3 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -216,3 +216,53 @@ def test_admin_connections_marks_admin(client): def test_disconnect_admin_spotify_rejected(client): # Admin's Spotify is the app account (Settings) — not disconnectable here. assert client.post('/api/profiles/me/connections/spotify/disconnect').status_code == 400 + + +# ── Tidal: per-profile connect status + the token-save-redirect safety ──────── + +def test_tidal_connection_status_and_disconnect(client, nonadmin_profile): + db = web_server.get_database() + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + # unconnected + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is False + # seed tokens → connected + db.set_profile_tidal_tokens(nonadmin_profile, 'acc-tok', 'ref-tok') + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is True + # disconnect → cleared + assert client.post('/api/profiles/me/connections/tidal/disconnect').get_json()['success'] + assert db.get_profile_tidal(nonadmin_profile) == {} + assert client.get('/api/profiles/me/connections').get_json()['connections']['tidal']['connected'] is False + + +def test_disconnect_unsupported_service_400(client, nonadmin_profile): + with client.session_transaction() as sess: + sess['profile_id'] = nonadmin_profile + assert client.post('/api/profiles/me/connections/deezer/disconnect').status_code == 400 + + +def test_tidal_token_refresh_redirects_to_profile_not_global(client, nonadmin_profile): + # THE safety guarantee: a per-profile Tidal client's token save must write to + # the PROFILE, never the global tidal_tokens slot the app runs on. + from config.settings import config_manager + db = web_server.get_database() + config_manager.set('tidal_tokens', {'access_token': 'ADMIN-ACC', 'refresh_token': 'ADMIN-REF'}) + db.set_profile_tidal_tokens(nonadmin_profile, 'p-acc', 'p-ref') + web_server.clear_profile_tidal_client(nonadmin_profile) + + c = web_server.get_tidal_client_for_profile(nonadmin_profile) + assert c is not web_server.tidal_client # a dedicated per-profile client + # simulate a refresh writing new tokens + c.access_token = 'p-acc-NEW' + c.refresh_token = 'p-ref-NEW' + c._save_tokens() + + assert db.get_profile_tidal(nonadmin_profile) == {'access_token': 'p-acc-NEW', 'refresh_token': 'p-ref-NEW'} + # global slot untouched + assert config_manager.get('tidal_tokens') == {'access_token': 'ADMIN-ACC', 'refresh_token': 'ADMIN-REF'} + + +def test_tidal_admin_and_unconnected_use_global_client(client): + assert web_server.get_tidal_client_for_profile(1) is web_server.tidal_client + assert web_server.get_tidal_client_for_profile(None) is web_server.tidal_client + assert web_server.get_tidal_client_for_profile(987654) is web_server.tidal_client diff --git a/web_server.py b/web_server.py index 1d347422..9f5e1cd0 100644 --- a/web_server.py +++ b/web_server.py @@ -572,6 +572,60 @@ def get_spotify_client_for_profile(profile_id=None): profile_id = get_current_profile_id() return metadata_registry.get_spotify_client_for_profile(profile_id) + +_profile_tidal_clients = {} +_profile_tidal_lock = threading.Lock() + + +def get_tidal_client_for_profile(profile_id=None): + """Get the Tidal client for a profile's OWN playlists, or the global one. + + A profile that has connected its own Tidal account gets a dedicated client + seeded with its tokens (refreshed via the shared/global app creds). Crucially + its token refresh is redirected to the profile row, so a per-profile refresh + never overwrites the global tidal_tokens the app runs on. Admin (profile 1) + and unconnected profiles use the global client unchanged.""" + if profile_id is None: + profile_id = get_current_profile_id() + if not profile_id or profile_id == 1: + return tidal_client + try: + toks = get_database().get_profile_tidal(profile_id) or {} + except Exception: + return tidal_client + if not toks.get('access_token') and not toks.get('refresh_token'): + return tidal_client + with _profile_tidal_lock: + cached = _profile_tidal_clients.get(profile_id) + if cached is not None: + return cached + try: + c = TidalClient() + c.access_token = toks.get('access_token') or None + c.refresh_token = toks.get('refresh_token') or None + c.token_expires_at = 0 # force a refresh check on first use + if c.access_token: + c.session.headers['Authorization'] = f'Bearer {c.access_token}' + # Redirect token persistence to the PROFILE, never the global slot. + _pid = profile_id + def _save_to_profile(_c=c, _p=_pid): + try: + get_database().set_profile_tidal_tokens(_p, _c.access_token, _c.refresh_token) + except Exception as e: + logger.debug("per-profile Tidal token save failed: %s", e) + c._save_tokens = _save_to_profile + _profile_tidal_clients[profile_id] = c + return c + except Exception as e: + logger.error("per-profile Tidal client build failed for %s: %s", profile_id, e) + return tidal_client + + +def clear_profile_tidal_client(profile_id): + """Evict a profile's cached Tidal client (after (dis)connect).""" + with _profile_tidal_lock: + _profile_tidal_clients.pop(profile_id, None) + # Valid page IDs for profile permission validation VALID_PAGE_IDS = { 'dashboard', @@ -4985,20 +5039,9 @@ def tidal_callback(): 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() + get_database().set_profile_tidal_tokens( + profile_id_int, temp_tidal_client.access_token, temp_tidal_client.refresh_token) + clear_profile_tidal_client(profile_id_int) # rebuild with fresh tokens add_activity_item("", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now") return "
Your personal Tidal account is now connected. You can close this window.
" except Exception as profile_err: @@ -21163,11 +21206,12 @@ def tidal_disconnect(): @app.route('/api/tidal/playlists', methods=['GET']) def get_tidal_playlists(): """Fetches all user playlists from Tidal with full track data (like sync.py).""" - if not tidal_client or not tidal_client.is_authenticated(): + client = get_tidal_client_for_profile() or tidal_client + if not client or not client.is_authenticated(): return jsonify({"error": "Tidal not authenticated."}), 401 try: # Use same method as sync.py - this already includes all track data - playlists = tidal_client.get_user_playlists_metadata_only() + playlists = client.get_user_playlists_metadata_only() playlist_data = [] for p in playlists: @@ -21212,8 +21256,8 @@ def get_tidal_playlists(): COLLECTION_PLAYLIST_NAME, COLLECTION_PLAYLIST_DESCRIPTION, ) - collection_count = tidal_client.get_collection_tracks_count() - needs_reconnect = tidal_client.collection_needs_reconnect() + collection_count = client.get_collection_tracks_count() + needs_reconnect = client.collection_needs_reconnect() if needs_reconnect: playlist_data.append({ @@ -21254,7 +21298,8 @@ def get_tidal_playlists(): @app.route('/api/tidal/playlist/