Profiles: per-profile Tidal self-auth (playlists) — with a safe token-save redirect

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 (/<service>/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.
This commit is contained in:
BoulderBadgeDad 2026-06-10 13:11:59 -07:00
parent 7eaed1d533
commit 60b9fe10e9
5 changed files with 220 additions and 40 deletions

View file

@ -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."""

View file

@ -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

View file

@ -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 "<h1>Tidal Authentication Successful!</h1><p>Your personal Tidal account is now connected. You can close this window.</p>"
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/<playlist_id>', 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/<service>/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

View file

@ -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 = `<span class="ma-note">Managed in Settings (app account)</span>`;
@ -99,7 +104,7 @@ function _maRender(body, data) {
}
return `
<div class="ma-row" style="--ma-brand:${svc.brand}">
<span class="ma-disc"><img class="ma-logo" src="${svc.logo}" alt=""
<span class="ma-disc${svc.dark ? ' ma-disc--dark' : ''}"><img class="ma-logo" src="${svc.logo}" alt=""
onerror="this.style.display='none'"></span>
<div class="ma-row-info">
<div class="ma-row-name">${_maEsc(svc.name)}</div>

View file

@ -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; }