Profiles: per-profile Spotify self-auth (shared app) + My Accounts modal + read wiring
First service of the per-profile playlist-auth feature. Each profile connects its OWN Spotify account through the shared (admin's) app, getting its own token; used for that profile's playlist reads. Admin + unconnected profiles + all background workers keep using the global/admin client — fully non-regressive. - Shared-app OAuth: get_spotify_client_for_profile + the /auth/spotify init & callback now use the GLOBAL app creds (falling back from any legacy per-profile app creds) with the profile's own token cache, and show_dialog=true forces the account chooser so a user can't silently inherit the admin's Spotify session. The builder gates on the profile's own token cache existing — no cache → global. - My Accounts modal (new, all-profile-accessible via the profile bar): one-click Connect/Disconnect Spotify + connection status (account name). GET /api/profiles/me/connections + POST .../spotify/disconnect; admin's Spotify is read-only here (managed in Settings). - Wired the request-scoped reads to the per-profile client: the playlist LIST, the playlist TRACKS view, liked-songs count, and user info — so a connected user sees and opens THEIR OWN (incl. private) playlists, not the admin's. Tests: builder falls back to the global client for admin/None/unconnected (the non-regression guarantee); connections status reports unconnected; admin disconnect rejected. 124 profile/spotify/gate/integrity tests pass. Still on the global account (next step): sync/download jobs run in background workers with no profile context — stamping the requesting profile onto the job is the remaining wiring. Other services (Tidal/Deezer/Qobuz/Last.fm/ListenBrainz) follow this same pattern.
This commit is contained in:
parent
c154aa5442
commit
e8bd9c8018
8 changed files with 376 additions and 45 deletions
|
|
@ -187,18 +187,37 @@ def _build_profile_spotify_cache_key(profile_id: int, creds: Dict[str, Any]) ->
|
|||
|
||||
|
||||
def get_spotify_client_for_profile(profile_id: Optional[int] = None):
|
||||
"""Get a profile-specific Spotify client or fall back to the global one."""
|
||||
"""Get a profile-specific Spotify client or fall back to the global one.
|
||||
|
||||
Shared-app model: a profile authenticates its OWN Spotify account through
|
||||
the GLOBAL app credentials (client_id/secret) and gets its own token cache
|
||||
(``.spotify_cache_profile_<id>``). A profile that set its own app creds
|
||||
(legacy) still works. A profile that hasn't connected — no token cache —
|
||||
falls back to the global/admin client, so nothing changes for them or for
|
||||
background workers (which run as profile 1)."""
|
||||
if profile_id is None or profile_id == 1:
|
||||
return get_spotify_client()
|
||||
|
||||
try:
|
||||
creds = _profile_spotify_credentials_provider(profile_id)
|
||||
if not creds or not creds.get("client_id"):
|
||||
return get_spotify_client()
|
||||
creds = _profile_spotify_credentials_provider(profile_id) or {}
|
||||
except Exception:
|
||||
return get_spotify_client()
|
||||
|
||||
cache_key = _build_profile_spotify_cache_key(profile_id, creds)
|
||||
# Effective OAuth app creds: the profile's own (legacy) else the global app.
|
||||
client_id = creds.get("client_id") or _get_config_value("spotify.client_id", "")
|
||||
client_secret = creds.get("client_secret") or _get_config_value("spotify.client_secret", "")
|
||||
redirect_uri = (creds.get("redirect_uri")
|
||||
or _get_config_value("spotify.redirect_uri", "http://127.0.0.1:8888/callback"))
|
||||
|
||||
import os
|
||||
cache_path = f"config/.spotify_cache_profile_{profile_id}"
|
||||
# Only use a per-profile client when this profile has actually connected
|
||||
# (its own token cache exists). Otherwise use the global/admin client.
|
||||
if not client_id or not os.path.exists(cache_path):
|
||||
return get_spotify_client()
|
||||
|
||||
cache_key = _build_profile_spotify_cache_key(
|
||||
profile_id, {"client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri})
|
||||
with _client_cache_lock:
|
||||
client = _client_cache.get(cache_key)
|
||||
if client is not None and getattr(client, "sp", None) is not None:
|
||||
|
|
@ -210,11 +229,11 @@ def get_spotify_client_for_profile(profile_id: Optional[int] = None):
|
|||
import spotipy
|
||||
|
||||
auth_manager = SpotifyOAuth(
|
||||
client_id=creds["client_id"],
|
||||
client_secret=creds["client_secret"],
|
||||
redirect_uri=creds.get("redirect_uri", "http://127.0.0.1:8888/callback"),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
||||
cache_path=f"config/.spotify_cache_profile_{profile_id}",
|
||||
cache_path=cache_path,
|
||||
state=f"profile_{profile_id}",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -196,3 +196,23 @@ def test_spotify_free_composite_roundtrips_like_settings(client):
|
|||
client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify'})
|
||||
assert config_manager.get('metadata.spotify_free') is False
|
||||
assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify'
|
||||
|
||||
|
||||
# ── My Accounts: per-profile connection status (Spotify) ──────────────────────
|
||||
|
||||
def test_connections_status_unconnected(client, nonadmin_profile):
|
||||
with client.session_transaction() as sess:
|
||||
sess['profile_id'] = nonadmin_profile
|
||||
body = client.get('/api/profiles/me/connections').get_json()
|
||||
assert body['success'] and body['is_admin'] is False
|
||||
assert body['connections']['spotify']['connected'] is False
|
||||
|
||||
|
||||
def test_admin_connections_marks_admin(client):
|
||||
body = client.get('/api/profiles/me/connections').get_json()
|
||||
assert body['is_admin'] is True
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
29
tests/test_profile_spotify_resolution.py
Normal file
29
tests/test_profile_spotify_resolution.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Per-profile Spotify client resolution falls back safely (shared-app model).
|
||||
|
||||
The builder must return the GLOBAL client for admin (profile 1) and for any
|
||||
non-admin profile that hasn't connected its own Spotify (no token cache) — so
|
||||
background workers and existing users are unaffected. A per-profile client only
|
||||
appears once that profile has its own .spotify_cache_profile_<id>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.metadata import registry
|
||||
|
||||
|
||||
def test_admin_and_none_use_global_client():
|
||||
g = registry.get_spotify_client()
|
||||
assert registry.get_spotify_client_for_profile(1) is g
|
||||
assert registry.get_spotify_client_for_profile(None) is g
|
||||
|
||||
|
||||
def test_unconnected_profile_falls_back_to_global(tmp_path, monkeypatch):
|
||||
# A non-admin profile with no token cache must resolve to the global client.
|
||||
g = registry.get_spotify_client()
|
||||
# ensure no stray cache file for this id
|
||||
pid = 987654
|
||||
cache = f"config/.spotify_cache_profile_{pid}"
|
||||
assert not os.path.exists(cache)
|
||||
assert registry.get_spotify_client_for_profile(pid) is g
|
||||
|
|
@ -53,7 +53,7 @@ SPLIT_MODULES = [
|
|||
# Other JS files that exist in static/ but are NOT part of the split
|
||||
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js",
|
||||
"enrichment-manager.js", "origin-history.js", "blocklist.js",
|
||||
"watchlist-history.js", "service-switch.js"}
|
||||
"watchlist-history.js", "service-switch.js", "my-accounts.js"}
|
||||
|
||||
# Pre-existing duplicate helper functions that lived in the original monolith.
|
||||
# In a plain <script> context the last-loaded declaration wins. These are NOT
|
||||
|
|
|
|||
139
web_server.py
139
web_server.py
|
|
@ -4405,6 +4405,33 @@ def detect_soulseek_endpoint():
|
|||
|
||||
# --- Authentication Routes ---
|
||||
|
||||
def _profile_spotify_oauth(profile_id_int):
|
||||
"""Build a SpotifyOAuth for a profile's connect/callback.
|
||||
|
||||
Shared-app model (#profiles): a profile authenticates its OWN account through
|
||||
the GLOBAL app credentials and gets its own token cache. A profile that set
|
||||
its own app creds (legacy) still works. show_dialog forces Spotify's account
|
||||
chooser so a user can't silently inherit whatever Spotify session is active
|
||||
in their browser (e.g. the admin's). Returns None if no app creds exist."""
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
creds = (get_database().get_profile_spotify(profile_id_int) or {})
|
||||
cfg = config_manager.get_spotify_config()
|
||||
client_id = creds.get('client_id') or cfg.get('client_id')
|
||||
client_secret = creds.get('client_secret') or cfg.get('client_secret')
|
||||
redirect_uri = creds.get('redirect_uri') or cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback')
|
||||
if not client_id or not client_secret:
|
||||
return None
|
||||
return SpotifyOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
||||
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
|
||||
state=f'profile_{profile_id_int}',
|
||||
show_dialog=True,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/auth/spotify')
|
||||
def auth_spotify():
|
||||
"""
|
||||
|
|
@ -4414,23 +4441,12 @@ def auth_spotify():
|
|||
try:
|
||||
profile_id = request.args.get('profile_id', '')
|
||||
|
||||
# Per-profile auth: use profile's own credentials
|
||||
# Per-profile auth: the profile's own account via the shared (global) app.
|
||||
if profile_id and profile_id != '1':
|
||||
try:
|
||||
profile_id_int = int(profile_id)
|
||||
db = get_database()
|
||||
creds = db.get_profile_spotify(profile_id_int)
|
||||
if creds and creds.get('client_id'):
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
|
||||
auth_manager = SpotifyOAuth(
|
||||
client_id=creds['client_id'],
|
||||
client_secret=creds['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
||||
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
|
||||
state=f'profile_{profile_id_int}'
|
||||
)
|
||||
auth_manager = _profile_spotify_oauth(profile_id_int)
|
||||
if auth_manager:
|
||||
auth_url = auth_manager.get_authorize_url()
|
||||
logger.info(f"Per-profile Spotify auth initiated for profile {profile_id_int}")
|
||||
return redirect(auth_url)
|
||||
|
|
@ -4805,20 +4821,10 @@ def spotify_callback():
|
|||
from spotipy.oauth2 import SpotifyOAuth
|
||||
from config.settings import config_manager
|
||||
|
||||
# Per-profile callback: use profile's credentials
|
||||
# Per-profile callback: the profile's own account via the shared app.
|
||||
if profile_id_from_state and profile_id_from_state != 1:
|
||||
db = get_database()
|
||||
creds = db.get_profile_spotify(profile_id_from_state)
|
||||
if creds and creds.get('client_id'):
|
||||
redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
|
||||
auth_manager = SpotifyOAuth(
|
||||
client_id=creds['client_id'],
|
||||
client_secret=creds['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
||||
cache_path=f'config/.spotify_cache_profile_{profile_id_from_state}',
|
||||
state=f'profile_{profile_id_from_state}'
|
||||
)
|
||||
auth_manager = _profile_spotify_oauth(profile_id_from_state)
|
||||
if auth_manager:
|
||||
token_info = auth_manager.get_access_token(auth_code)
|
||||
if token_info:
|
||||
metadata_registry.clear_cached_profile_spotify_client(profile_id_from_state)
|
||||
|
|
@ -19625,7 +19631,7 @@ def get_spotify_playlists():
|
|||
|
||||
# Add virtual "Liked Songs" playlist at the END (just count, no full fetch)
|
||||
try:
|
||||
liked_songs_count = spotify_client.get_saved_tracks_count()
|
||||
liked_songs_count = client.get_saved_tracks_count()
|
||||
if liked_songs_count > 0:
|
||||
liked_songs_id = "spotify:liked-songs"
|
||||
status_info = sync_statuses.get(liked_songs_id, {})
|
||||
|
|
@ -19636,7 +19642,7 @@ def get_spotify_playlists():
|
|||
sync_status = f"Synced: {last_sync_time}"
|
||||
|
||||
# Get user info for owner name
|
||||
user_info = spotify_client.get_user_info()
|
||||
user_info = client.get_user_info()
|
||||
owner_name = user_info.get('display_name', 'You') if user_info else 'You'
|
||||
|
||||
# Add Liked Songs as LAST playlist
|
||||
|
|
@ -19661,12 +19667,15 @@ def get_spotify_playlists():
|
|||
@app.route('/api/spotify/playlist/<playlist_id>', methods=['GET'])
|
||||
def get_playlist_tracks(playlist_id):
|
||||
"""Fetches full track details for a specific playlist."""
|
||||
if not spotify_client or not spotify_client.is_authenticated():
|
||||
# Use THIS profile's own Spotify (their playlists/private content); falls
|
||||
# back to the global/admin client for admin + unconnected profiles.
|
||||
client = get_spotify_client_for_profile() or spotify_client
|
||||
if not client or not client.is_authenticated():
|
||||
return jsonify({"error": "Spotify not authenticated."}), 401
|
||||
try:
|
||||
# Handle special "Liked Songs" virtual playlist
|
||||
if playlist_id == "spotify:liked-songs":
|
||||
user_info = spotify_client.get_user_info()
|
||||
user_info = client.get_user_info()
|
||||
owner_name = user_info.get('display_name', 'You') if user_info else 'You'
|
||||
|
||||
# Fetch raw saved tracks with full album data
|
||||
|
|
@ -19679,7 +19688,7 @@ def get_playlist_tracks(playlist_id):
|
|||
return jsonify({"error": "Spotify is currently rate limited. Please try again later."}), 429
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
api_call_tracker.record_call('spotify', endpoint='current_user_saved_tracks')
|
||||
results = spotify_client.sp.current_user_saved_tracks(limit=limit, offset=offset)
|
||||
results = client.sp.current_user_saved_tracks(limit=limit, offset=offset)
|
||||
|
||||
if not results or 'items' not in results:
|
||||
break
|
||||
|
|
@ -19723,7 +19732,7 @@ def get_playlist_tracks(playlist_id):
|
|||
# Fetch raw playlist data to preserve full album objects
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
api_call_tracker.record_call('spotify', endpoint='playlist')
|
||||
playlist_data = spotify_client.sp.playlist(playlist_id)
|
||||
playlist_data = client.sp.playlist(playlist_id)
|
||||
|
||||
# Fetch all tracks with full album data
|
||||
tracks = []
|
||||
|
|
@ -19734,7 +19743,7 @@ def get_playlist_tracks(playlist_id):
|
|||
items_err = None
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
results = spotify_client._get_playlist_items_page(playlist_id, limit=100)
|
||||
results = client._get_playlist_items_page(playlist_id, limit=100)
|
||||
break
|
||||
except Exception as _e:
|
||||
items_err = _e
|
||||
|
|
@ -19798,7 +19807,7 @@ def get_playlist_tracks(playlist_id):
|
|||
|
||||
if results.get('next'):
|
||||
api_call_tracker.record_call('spotify', endpoint='playlist_tracks_page')
|
||||
results = spotify_client.sp.next(results)
|
||||
results = client.sp.next(results)
|
||||
else:
|
||||
results = None
|
||||
|
||||
|
|
@ -25344,6 +25353,66 @@ def test_profile_listenbrainz():
|
|||
|
||||
# --- Per-Profile Service Credentials API ---
|
||||
|
||||
def _profile_spotify_connection(profile_id):
|
||||
"""(connected, account_name) for a profile's OWN connected Spotify — not the
|
||||
global/admin fallback. A profile is connected only when its own token cache
|
||||
exists and authenticates."""
|
||||
if not profile_id or profile_id == 1:
|
||||
return (False, None)
|
||||
if not os.path.exists(f"config/.spotify_cache_profile_{profile_id}"):
|
||||
return (False, None)
|
||||
try:
|
||||
client = metadata_registry.get_spotify_client_for_profile(profile_id)
|
||||
if client and client.is_spotify_authenticated():
|
||||
info = client.get_user_info() or {}
|
||||
return (True, info.get('display_name') or info.get('id'))
|
||||
except Exception as e:
|
||||
logger.debug("profile %s spotify 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.
|
||||
Readable by any profile; reports only this profile's own connections."""
|
||||
try:
|
||||
pid = get_current_profile_id()
|
||||
sp_connected, sp_account = _profile_spotify_connection(pid)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'is_admin': pid == 1,
|
||||
'connections': {
|
||||
'spotify': {'connected': sp_connected, 'account': sp_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."""
|
||||
try:
|
||||
pid = get_current_profile_id()
|
||||
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': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/profiles/me/spotify', methods=['GET'])
|
||||
def get_profile_spotify_creds():
|
||||
"""Get current profile's Spotify credentials (if set)"""
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@
|
|||
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
|
||||
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
|
||||
<span id="profile-indicator-name" class="profile-indicator-name"></span>
|
||||
<button id="my-accounts-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openMyAccountsModal()" title="My Accounts">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</button>
|
||||
<button id="personal-settings-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openPersonalSettings()" title="My Settings">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
|
|
@ -8191,6 +8194,7 @@
|
|||
<script src="{{ url_for('static', filename='watchlist-history.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='blocklist.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='service-switch.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='my-accounts.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
145
webui/static/my-accounts.js
Normal file
145
webui/static/my-accounts.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* My Accounts — per-profile self-auth for playlist services.
|
||||
*
|
||||
* Each profile connects its OWN streaming accounts (their token, the app's
|
||||
* shared client). Used for that profile's playlist operations; the global/admin
|
||||
* auth keeps running the background app. Spotify is the first service; the others
|
||||
* follow the same pattern.
|
||||
*
|
||||
* Backend: GET /api/profiles/me/connections, the per-service OAuth popups, and
|
||||
* POST /api/profiles/me/connections/<service>/disconnect.
|
||||
*/
|
||||
|
||||
// Playlist services shown in My Accounts. `connect` returns the OAuth URL for a
|
||||
// given profile id (popup); services are wired in over time.
|
||||
const _MA_SERVICES = [
|
||||
{
|
||||
id: 'spotify', name: 'Spotify', brand: '#1db954',
|
||||
logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
|
||||
connect: (pid) => `/auth/spotify?profile_id=${pid}`,
|
||||
},
|
||||
];
|
||||
|
||||
function _maEsc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function _maProfileId() {
|
||||
try {
|
||||
const ctx = (typeof getCurrentProfileContext === 'function') ? getCurrentProfileContext() : null;
|
||||
return ctx ? ctx.profileId : 1;
|
||||
} catch (_e) { return 1; }
|
||||
}
|
||||
|
||||
function openMyAccountsModal() {
|
||||
let overlay = document.getElementById('my-accounts-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'my-accounts-overlay';
|
||||
overlay.className = 'modal-overlay ma-overlay hidden';
|
||||
overlay.onclick = (e) => { if (e.target === overlay) closeMyAccountsModal(); };
|
||||
overlay.innerHTML = `
|
||||
<div class="ma-modal" role="dialog" aria-modal="true" aria-label="My Accounts" tabindex="-1">
|
||||
<div class="ma-topbar">
|
||||
<div class="ma-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="ma-topbar-logo"></div>
|
||||
<div class="ma-topbar-titles">
|
||||
<h3 class="ma-topbar-title">My Accounts</h3>
|
||||
<div class="ma-topbar-sub">Connect your own streaming accounts — used for your playlists, just for you.</div>
|
||||
</div>
|
||||
<button class="ma-icon-btn" title="Close" onclick="closeMyAccountsModal()">×</button>
|
||||
</div>
|
||||
<div class="ma-body" id="ma-body"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.classList.remove('hidden');
|
||||
const modal = overlay.querySelector('.ma-modal');
|
||||
if (modal) { modal.classList.remove('ma-in'); void modal.offsetWidth; modal.classList.add('ma-in'); }
|
||||
document.addEventListener('keydown', _maOnKeydown);
|
||||
_maLoad();
|
||||
}
|
||||
|
||||
function closeMyAccountsModal() {
|
||||
const o = document.getElementById('my-accounts-overlay');
|
||||
if (o) o.classList.add('hidden');
|
||||
document.removeEventListener('keydown', _maOnKeydown);
|
||||
}
|
||||
|
||||
function _maOnKeydown(e) { if (e.key === 'Escape') closeMyAccountsModal(); }
|
||||
|
||||
async function _maLoad() {
|
||||
const body = document.getElementById('ma-body');
|
||||
if (body) body.innerHTML = '<div class="ma-empty">Loading…</div>';
|
||||
let data = null;
|
||||
try {
|
||||
data = await (await fetch('/api/profiles/me/connections')).json();
|
||||
} catch (e) { /* render disconnected */ }
|
||||
_maRender(body, data || { connections: {}, is_admin: false });
|
||||
}
|
||||
|
||||
function _maRender(body, data) {
|
||||
const conns = data.connections || {};
|
||||
const isAdmin = !!data.is_admin;
|
||||
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');
|
||||
let action;
|
||||
if (adminNote) {
|
||||
action = `<span class="ma-note">Managed in Settings (app account)</span>`;
|
||||
} else if (connected) {
|
||||
action = `
|
||||
<span class="ma-account">${_maEsc(c.account || 'Connected')}</span>
|
||||
<button class="ma-btn ma-btn--ghost" onclick="disconnectMyAccount('${svc.id}')">Disconnect</button>`;
|
||||
} else {
|
||||
action = `<button class="ma-btn ma-btn--connect" onclick="connectMyAccount('${svc.id}')">Connect</button>`;
|
||||
}
|
||||
return `
|
||||
<div class="ma-row" style="--ma-brand:${svc.brand}">
|
||||
<span class="ma-disc"><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>
|
||||
<div class="ma-row-status ${connected ? 'is-on' : ''}">${connected ? 'Connected' : (adminNote ? '' : 'Not connected')}</div>
|
||||
</div>
|
||||
<div class="ma-row-action">${action}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
body.innerHTML = rows || '<div class="ma-empty">No services available.</div>';
|
||||
}
|
||||
|
||||
let _maPollTimer = null;
|
||||
|
||||
function connectMyAccount(serviceId) {
|
||||
const svc = _MA_SERVICES.find(s => s.id === serviceId);
|
||||
if (!svc) return;
|
||||
const pid = _maProfileId();
|
||||
const popup = window.open(svc.connect(pid), 'soulsync-connect-' + serviceId,
|
||||
'width=560,height=720,menubar=no,toolbar=no');
|
||||
// Poll for the popup closing, then refresh status.
|
||||
if (_maPollTimer) clearInterval(_maPollTimer);
|
||||
_maPollTimer = setInterval(() => {
|
||||
if (!popup || popup.closed) {
|
||||
clearInterval(_maPollTimer);
|
||||
_maPollTimer = null;
|
||||
setTimeout(_maLoad, 600); // give the callback a moment to persist
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
async function disconnectMyAccount(serviceId) {
|
||||
if (!confirm(`Disconnect your ${serviceId} account from this profile?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/profiles/me/connections/${serviceId}/disconnect`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (typeof showToast === 'function') showToast('Disconnected', 'success');
|
||||
_maLoad();
|
||||
} else if (typeof showToast === 'function') {
|
||||
showToast(data.error || 'Disconnect failed', 'error');
|
||||
}
|
||||
} catch (e) { /* no-op */ }
|
||||
}
|
||||
|
|
@ -67677,3 +67677,48 @@ body.em-scroll-lock { overflow: hidden; }
|
|||
/* Service Status is admin-only — non-admins get no clickable affordance. */
|
||||
.status-section--locked { cursor: default; }
|
||||
.status-section--locked:hover { background: transparent; }
|
||||
|
||||
/* ── My Accounts (per-profile self-auth for playlist services) ── */
|
||||
.ma-overlay { display: flex; align-items: center; justify-content: center; }
|
||||
.ma-modal {
|
||||
background: linear-gradient(160deg, #181a21 0%, #121319 100%);
|
||||
border: 1px solid rgba(255,255,255,0.08); border-radius: 18px;
|
||||
width: min(560px, 94vw); max-height: 86vh; display: flex; flex-direction: column;
|
||||
overflow: hidden; box-shadow: 0 30px 80px rgba(0,0,0,0.65);
|
||||
}
|
||||
.ma-modal.ma-in { animation: ss-pop 0.22s cubic-bezier(0.2,0.9,0.3,1.2); }
|
||||
.ma-topbar {
|
||||
display: flex; align-items: center; gap: 14px; padding: 18px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06); background: rgba(var(--accent-light-rgb),0.05);
|
||||
}
|
||||
.ma-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
|
||||
.ma-topbar-logo { width: 36px; height: 36px; object-fit: contain; }
|
||||
.ma-topbar-titles { flex: 1; }
|
||||
.ma-topbar-title { margin: 0; font-size: 1.2rem; }
|
||||
.ma-topbar-sub { color: rgba(255,255,255,0.5); font-size: 0.82rem; margin-top: 2px; }
|
||||
.ma-icon-btn { background: rgba(255,255,255,0.06); border: none; color: #fff; width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 1.1rem; }
|
||||
.ma-icon-btn:hover { background: rgba(255,255,255,0.14); }
|
||||
.ma-body { padding: 16px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.ma-empty { color: rgba(255,255,255,0.4); font-style: italic; padding: 24px 0; text-align: center; }
|
||||
.ma-row {
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px;
|
||||
}
|
||||
.ma-disc {
|
||||
width: 46px; height: 46px; flex-shrink: 0; border-radius: 50%; background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ma-brand) 55%, transparent);
|
||||
}
|
||||
.ma-logo { width: 28px; height: 28px; object-fit: contain; }
|
||||
.ma-row-info { flex: 1; min-width: 0; }
|
||||
.ma-row-name { font-weight: 600; font-size: 0.98rem; }
|
||||
.ma-row-status { font-size: 0.8rem; color: rgba(255,255,255,0.45); }
|
||||
.ma-row-status.is-on { color: var(--ma-brand); }
|
||||
.ma-row-action { display: flex; align-items: center; gap: 10px; }
|
||||
.ma-account { font-size: 0.82rem; color: rgba(255,255,255,0.7); max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ma-note { font-size: 0.78rem; color: rgba(255,255,255,0.4); font-style: italic; }
|
||||
.ma-btn { border: none; border-radius: 9px; padding: 8px 18px; cursor: pointer; font-size: 0.85rem; font-weight: 600; }
|
||||
.ma-btn--connect { background: var(--ma-brand); color: #0a0a0a; }
|
||||
.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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue