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}")
|
logger.error(f"Error setting Spotify tokens for profile {profile_id}: {e}")
|
||||||
return False
|
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,
|
def set_profile_server_library(self, profile_id: int, server_type: str,
|
||||||
library_id: str = None, user_id: str = None) -> bool:
|
library_id: str = None, user_id: str = None) -> bool:
|
||||||
"""Save media server library/user selection for a profile."""
|
"""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):
|
def test_disconnect_admin_spotify_rejected(client):
|
||||||
# Admin's Spotify is the app account (Settings) — not disconnectable here.
|
# Admin's Spotify is the app account (Settings) — not disconnectable here.
|
||||||
assert client.post('/api/profiles/me/connections/spotify/disconnect').status_code == 400
|
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()
|
profile_id = get_current_profile_id()
|
||||||
return metadata_registry.get_spotify_client_for_profile(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 for profile permission validation
|
||||||
VALID_PAGE_IDS = {
|
VALID_PAGE_IDS = {
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
|
@ -4985,20 +5039,9 @@ def tidal_callback():
|
||||||
if profile_id_for_tidal:
|
if profile_id_for_tidal:
|
||||||
try:
|
try:
|
||||||
profile_id_int = int(profile_id_for_tidal)
|
profile_id_int = int(profile_id_for_tidal)
|
||||||
db = get_database()
|
get_database().set_profile_tidal_tokens(
|
||||||
# Store Tidal tokens on the profile
|
profile_id_int, temp_tidal_client.access_token, temp_tidal_client.refresh_token)
|
||||||
from config.settings import config_manager as _cm
|
clear_profile_tidal_client(profile_id_int) # rebuild with fresh tokens
|
||||||
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()
|
|
||||||
add_activity_item("", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now")
|
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>"
|
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:
|
except Exception as profile_err:
|
||||||
|
|
@ -21163,11 +21206,12 @@ def tidal_disconnect():
|
||||||
@app.route('/api/tidal/playlists', methods=['GET'])
|
@app.route('/api/tidal/playlists', methods=['GET'])
|
||||||
def get_tidal_playlists():
|
def get_tidal_playlists():
|
||||||
"""Fetches all user playlists from Tidal with full track data (like sync.py)."""
|
"""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
|
return jsonify({"error": "Tidal not authenticated."}), 401
|
||||||
try:
|
try:
|
||||||
# Use same method as sync.py - this already includes all track data
|
# 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 = []
|
playlist_data = []
|
||||||
for p in playlists:
|
for p in playlists:
|
||||||
|
|
@ -21212,8 +21256,8 @@ def get_tidal_playlists():
|
||||||
COLLECTION_PLAYLIST_NAME,
|
COLLECTION_PLAYLIST_NAME,
|
||||||
COLLECTION_PLAYLIST_DESCRIPTION,
|
COLLECTION_PLAYLIST_DESCRIPTION,
|
||||||
)
|
)
|
||||||
collection_count = tidal_client.get_collection_tracks_count()
|
collection_count = client.get_collection_tracks_count()
|
||||||
needs_reconnect = tidal_client.collection_needs_reconnect()
|
needs_reconnect = client.collection_needs_reconnect()
|
||||||
|
|
||||||
if needs_reconnect:
|
if needs_reconnect:
|
||||||
playlist_data.append({
|
playlist_data.append({
|
||||||
|
|
@ -21254,7 +21298,8 @@ def get_tidal_playlists():
|
||||||
@app.route('/api/tidal/playlist/<playlist_id>', methods=['GET'])
|
@app.route('/api/tidal/playlist/<playlist_id>', methods=['GET'])
|
||||||
def get_tidal_playlist_tracks(playlist_id):
|
def get_tidal_playlist_tracks(playlist_id):
|
||||||
"""Fetches full track details for a specific Tidal playlist (matches sync.py pattern)."""
|
"""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
|
return jsonify({"error": "Tidal not authenticated."}), 401
|
||||||
try:
|
try:
|
||||||
logger.info(f"Getting full Tidal playlist with tracks for: {playlist_id}")
|
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
|
# `get_playlist` recognizes the virtual `tidal-favorites` ID and
|
||||||
# dispatches to the userCollectionTracks endpoint internally, so
|
# dispatches to the userCollectionTracks endpoint internally, so
|
||||||
# the rest of this handler treats it identically to a real playlist.
|
# 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:
|
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
|
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)
|
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'])
|
@app.route('/api/profiles/me/connections', methods=['GET'])
|
||||||
def get_my_connections():
|
def get_my_connections():
|
||||||
"""Per-profile playlist-service connection status for the My Accounts modal.
|
"""Per-profile playlist-service connection status for the My Accounts modal.
|
||||||
|
|
@ -25378,36 +25437,59 @@ def get_my_connections():
|
||||||
try:
|
try:
|
||||||
pid = get_current_profile_id()
|
pid = get_current_profile_id()
|
||||||
sp_connected, sp_account = _profile_spotify_connection(pid)
|
sp_connected, sp_account = _profile_spotify_connection(pid)
|
||||||
|
td_connected, td_account = _profile_tidal_connection(pid)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'is_admin': pid == 1,
|
'is_admin': pid == 1,
|
||||||
'connections': {
|
'connections': {
|
||||||
'spotify': {'connected': sp_connected, 'account': sp_account},
|
'spotify': {'connected': sp_connected, 'account': sp_account},
|
||||||
|
'tidal': {'connected': td_connected, 'account': td_account},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/profiles/me/connections/spotify/disconnect', methods=['POST'])
|
def _disconnect_profile_spotify(pid):
|
||||||
def disconnect_my_spotify():
|
cache_path = f"config/.spotify_cache_profile_{pid}"
|
||||||
"""Disconnect the current profile's own Spotify (clears its token cache +
|
try:
|
||||||
stored tokens + cached client). The global/admin Spotify is untouched."""
|
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:
|
try:
|
||||||
pid = get_current_profile_id()
|
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:
|
if pid == 1:
|
||||||
return jsonify({'success': False, 'error': 'The admin Spotify is managed in Settings'}), 400
|
return jsonify({'success': False, 'error': 'The admin account is managed in Settings'}), 400
|
||||||
cache_path = f"config/.spotify_cache_profile_{pid}"
|
fn(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': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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',
|
logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
|
||||||
connect: (pid) => `/auth/spotify?profile_id=${pid}`,
|
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) {
|
function _maEsc(s) {
|
||||||
|
|
@ -84,9 +89,9 @@ function _maRender(body, data) {
|
||||||
const rows = _MA_SERVICES.map(svc => {
|
const rows = _MA_SERVICES.map(svc => {
|
||||||
const c = conns[svc.id] || {};
|
const c = conns[svc.id] || {};
|
||||||
const connected = !!c.connected;
|
const connected = !!c.connected;
|
||||||
// Admin's Spotify is the app account managed in Settings — not a personal
|
// Admin uses the global app account (set up in Settings) for every
|
||||||
// connection here.
|
// service — not a personal connection here.
|
||||||
const adminNote = (isAdmin && svc.id === 'spotify');
|
const adminNote = isAdmin;
|
||||||
let action;
|
let action;
|
||||||
if (adminNote) {
|
if (adminNote) {
|
||||||
action = `<span class="ma-note">Managed in Settings (app account)</span>`;
|
action = `<span class="ma-note">Managed in Settings (app account)</span>`;
|
||||||
|
|
@ -99,7 +104,7 @@ function _maRender(body, data) {
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<div class="ma-row" style="--ma-brand:${svc.brand}">
|
<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>
|
onerror="this.style.display='none'"></span>
|
||||||
<div class="ma-row-info">
|
<div class="ma-row-info">
|
||||||
<div class="ma-row-name">${_maEsc(svc.name)}</div>
|
<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--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 { 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-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