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:
parent
7eaed1d533
commit
60b9fe10e9
5 changed files with 220 additions and 40 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
154
web_server.py
154
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 "<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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue