From 60b9fe10e976133ebb8b31399d318e69269a81a6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 13:11:59 -0700 Subject: [PATCH] =?UTF-8?q?Profiles:=20per-profile=20Tidal=20self-auth=20(?= =?UTF-8?q?playlists)=20=E2=80=94=20with=20a=20safe=20token-save=20redirec?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second service. Each profile connects its own Tidal; its playlist reads use that account, everything else stays global. The gotcha vs Spotify: TidalClient loads AND saves tokens to one global slot (tidal_tokens), so a naive per-profile client would clobber the admin's tokens on refresh. - get_tidal_client_for_profile builds a dedicated TidalClient seeded with the profile's tokens, refreshed via the shared/global app creds, and OVERRIDES its _save_tokens to persist to the PROFILE row — never the global slot. Admin (profile 1) + unconnected profiles use the global client unchanged. Cached per profile + evicted on (dis)connect. - DB: set_profile_tidal_tokens / get_profile_tidal (encrypted); the OAuth callback now uses them + evicts the cached client. - Wired the Tidal playlist reads (list + tracks) to the per-profile client; the module import line left intact. - My Accounts: Tidal row (Connect via /auth/tidal?profile_id=, status, Disconnect). Connections API extended; disconnect made generic (//disconnect). Admin sees "managed in Settings" for every service. Tests: per-profile token refresh writes to the profile and leaves the global tidal_tokens untouched (the safety guarantee); connect status + disconnect; admin/unconnected → global client. 22 endpoint tests pass. --- database/music_database.py | 42 ++++++++ tests/test_credentials_endpoints.py | 50 +++++++++ web_server.py | 154 +++++++++++++++++++++------- webui/static/my-accounts.js | 13 ++- webui/static/style.css | 1 + 5 files changed, 220 insertions(+), 40 deletions(-) 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 "

Tidal Authentication Successful!

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/', methods=['GET']) def get_tidal_playlist_tracks(playlist_id): """Fetches full track details for a specific Tidal playlist (matches sync.py pattern).""" - 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: logger.info(f"Getting full Tidal playlist with tracks for: {playlist_id}") @@ -21263,7 +21308,7 @@ def get_tidal_playlist_tracks(playlist_id): # `get_playlist` recognizes the virtual `tidal-favorites` ID and # dispatches to the userCollectionTracks endpoint internally, so # the rest of this handler treats it identically to a real playlist. - full_playlist = tidal_client.get_playlist(playlist_id) + full_playlist = client.get_playlist(playlist_id) if not full_playlist: return jsonify({"error": "Playlist not found or unable to access. This may be due to privacy settings or Tidal API restrictions."}), 404 @@ -25371,6 +25416,20 @@ def _profile_spotify_connection(profile_id): return (False, None) +def _profile_tidal_connection(profile_id): + """(connected, account_name) for a profile's OWN Tidal. Connected = it has + stored tokens; validity is checked (and refreshed) on actual use, so this + stays a cheap no-network check for the modal.""" + if not profile_id or profile_id == 1: + return (False, None) + try: + toks = get_database().get_profile_tidal(profile_id) or {} + return (bool(toks.get('refresh_token') or toks.get('access_token')), None) + except Exception as e: + logger.debug("profile %s tidal connection check failed: %s", profile_id, e) + return (False, None) + + @app.route('/api/profiles/me/connections', methods=['GET']) def get_my_connections(): """Per-profile playlist-service connection status for the My Accounts modal. @@ -25378,36 +25437,59 @@ def get_my_connections(): try: pid = get_current_profile_id() sp_connected, sp_account = _profile_spotify_connection(pid) + td_connected, td_account = _profile_tidal_connection(pid) return jsonify({ 'success': True, 'is_admin': pid == 1, 'connections': { 'spotify': {'connected': sp_connected, 'account': sp_account}, + 'tidal': {'connected': td_connected, 'account': td_account}, }, }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 -@app.route('/api/profiles/me/connections/spotify/disconnect', methods=['POST']) -def disconnect_my_spotify(): - """Disconnect the current profile's own Spotify (clears its token cache + - stored tokens + cached client). The global/admin Spotify is untouched.""" +def _disconnect_profile_spotify(pid): + cache_path = f"config/.spotify_cache_profile_{pid}" + try: + if os.path.exists(cache_path): + os.remove(cache_path) + except Exception as e: + logger.debug("could not remove profile spotify cache: %s", e) + try: + get_database().set_profile_spotify_tokens(pid, '', '') + except Exception as e: + logger.debug("could not clear profile spotify tokens: %s", e) + metadata_registry.clear_cached_profile_spotify_client(pid) + + +def _disconnect_profile_tidal(pid): + try: + get_database().set_profile_tidal_tokens(pid, '', '') + except Exception as e: + logger.debug("could not clear profile tidal tokens: %s", e) + clear_profile_tidal_client(pid) + + +_PROFILE_DISCONNECTORS = { + 'spotify': _disconnect_profile_spotify, + 'tidal': _disconnect_profile_tidal, +} + + +@app.route('/api/profiles/me/connections//disconnect', methods=['POST']) +def disconnect_my_connection(service): + """Disconnect the current profile's OWN account for a service (clears its + per-profile tokens + cached client). The global/admin auth is untouched.""" try: pid = get_current_profile_id() + fn = _PROFILE_DISCONNECTORS.get(service) + if not fn: + return jsonify({'success': False, 'error': f'Unsupported service: {service}'}), 400 if pid == 1: - return jsonify({'success': False, 'error': 'The admin Spotify is managed in Settings'}), 400 - cache_path = f"config/.spotify_cache_profile_{pid}" - try: - if os.path.exists(cache_path): - os.remove(cache_path) - except Exception as e: - logger.debug("could not remove profile spotify cache: %s", e) - try: - get_database().set_profile_spotify_tokens(pid, '', '') - except Exception as e: - logger.debug("could not clear profile spotify tokens: %s", e) - metadata_registry.clear_cached_profile_spotify_client(pid) + return jsonify({'success': False, 'error': 'The admin account is managed in Settings'}), 400 + fn(pid) return jsonify({'success': True}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js index 6201af03..71325090 100644 --- a/webui/static/my-accounts.js +++ b/webui/static/my-accounts.js @@ -18,6 +18,11 @@ const _MA_SERVICES = [ logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', connect: (pid) => `/auth/spotify?profile_id=${pid}`, }, + { + id: 'tidal', name: 'Tidal', brand: '#00cfe8', dark: true, + logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tidal-light.png', + connect: (pid) => `/auth/tidal?profile_id=${pid}`, + }, ]; function _maEsc(s) { @@ -84,9 +89,9 @@ function _maRender(body, data) { const rows = _MA_SERVICES.map(svc => { const c = conns[svc.id] || {}; const connected = !!c.connected; - // Admin's Spotify is the app account managed in Settings — not a personal - // connection here. - const adminNote = (isAdmin && svc.id === 'spotify'); + // Admin uses the global app account (set up in Settings) for every + // service — not a personal connection here. + const adminNote = isAdmin; let action; if (adminNote) { action = `Managed in Settings (app account)`; @@ -99,7 +104,7 @@ function _maRender(body, data) { } return `
-
${_maEsc(svc.name)}
diff --git a/webui/static/style.css b/webui/static/style.css index 54829bd4..a8710691 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67722,3 +67722,4 @@ body.em-scroll-lock { overflow: hidden; } .ma-btn--connect:hover { filter: brightness(1.1); } .ma-btn--ghost { background: transparent; color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.18); } .ma-btn--ghost:hover { background: rgba(255,255,255,0.06); color: #fff; } +.ma-disc.ma-disc--dark { background: #1f2329; }