From e8bd9c801844469c0ca15faeaf715d40e08b7af8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 12:21:17 -0700 Subject: [PATCH] Profiles: per-profile Spotify self-auth (shared app) + My Accounts modal + read wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/metadata/registry.py | 37 ++++-- tests/test_credentials_endpoints.py | 20 ++++ tests/test_profile_spotify_resolution.py | 29 +++++ tests/test_script_split_integrity.py | 2 +- web_server.py | 139 ++++++++++++++++------ webui/index.html | 4 + webui/static/my-accounts.js | 145 +++++++++++++++++++++++ webui/static/style.css | 45 +++++++ 8 files changed, 376 insertions(+), 45 deletions(-) create mode 100644 tests/test_profile_spotify_resolution.py create mode 100644 webui/static/my-accounts.js diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 3aab4468..a5cc48d2 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -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_``). 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}", ) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index 47d2817c..8c448e8d 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -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 diff --git a/tests/test_profile_spotify_resolution.py b/tests/test_profile_spotify_resolution.py new file mode 100644 index 00000000..ad753b5c --- /dev/null +++ b/tests/test_profile_spotify_resolution.py @@ -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_. +""" + +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 diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index e9c86e98..54c5adc5 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -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 + diff --git a/webui/static/my-accounts.js b/webui/static/my-accounts.js new file mode 100644 index 00000000..6201af03 --- /dev/null +++ b/webui/static/my-accounts.js @@ -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//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 = ` + `; + 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 = '
Loading…
'; + 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 = `Managed in Settings (app account)`; + } else if (connected) { + action = ` + + `; + } else { + action = ``; + } + return ` +
+ +
+
${_maEsc(svc.name)}
+
${connected ? 'Connected' : (adminNote ? '' : 'Not connected')}
+
+
${action}
+
`; + }).join(''); + body.innerHTML = rows || '
No services available.
'; +} + +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 */ } +} diff --git a/webui/static/style.css b/webui/static/style.css index 26f6e2e3..54829bd4 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; }