From ff5684bdede4d72436785d91917ddbf9155edaf8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:39:36 -0700 Subject: [PATCH] Add configurable sources for Your Artists section on Discover page Gear button next to View All opens a sources modal letting users pick which connected services (Spotify, Tidal, Last.fm, Deezer) contribute artists to the Your Artists carousel. Setting saved via standard /api/settings endpoint under discover.your_artists_sources. - GET /api/discover/your-artists/sources returns enabled config + which services are currently connected - _fetch_and_match_liked_artists skips sources not in the enabled list - Disconnected services shown dimmed and non-interactive in modal - Saving with nothing selected blocked with error toast - Remove z-index from .sidebar-header (fixes artist map overlap) - Add padding-bottom to #automations-list-view (search bar overlap fix) --- web_server.py | 114 +++++++++++++++++++++++++++++------------ webui/index.html | 3 ++ webui/static/script.js | 110 +++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 50 +++++++++++++++++- 4 files changed, 242 insertions(+), 35 deletions(-) diff --git a/web_server.py b/web_server.py index 3679f0a9..4e49c4dd 100644 --- a/web_server.py +++ b/web_server.py @@ -5364,7 +5364,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'lidarr_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'lidarr_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -42376,6 +42376,39 @@ def refresh_your_artists(): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/discover/your-artists/sources', methods=['GET']) +def get_your_artists_sources(): + """Return current source config + which services are connected.""" + try: + enabled_raw = config_manager.get('discover.your_artists_sources', 'spotify,tidal,lastfm,deezer') + enabled = [s.strip() for s in enabled_raw.split(',') if s.strip()] + + connected = [] + # Spotify + if spotify_client and spotify_client.is_spotify_authenticated(): + connected.append('spotify') + # Tidal + try: + if tidal_client and hasattr(tidal_client, '_ensure_valid_token') and tidal_client._ensure_valid_token(): + connected.append('tidal') + except Exception: + pass + # Last.fm + if config_manager.get('lastfm.api_key', '') and config_manager.get('lastfm.session_key', ''): + connected.append('lastfm') + # Deezer + try: + deezer_cl = _get_deezer_client() + if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated(): + connected.append('deezer') + except Exception: + pass + + return jsonify({"success": True, "enabled": enabled, "connected": connected}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + _your_artists_refresh_lock = threading.Lock() _your_artists_refreshing = False @@ -42408,9 +42441,14 @@ def _fetch_and_match_liked_artists(profile_id: int): database = get_database() fetched = 0 + enabled_raw = config_manager.get('discover.your_artists_sources', 'spotify,tidal,lastfm,deezer') + enabled_sources = {s.strip() for s in enabled_raw.split(',') if s.strip()} + # 1. Fetch from Spotify (followed artists) try: - if spotify_client and spotify_client.is_spotify_authenticated(): + if 'spotify' not in enabled_sources: + print("[Your Artists] Spotify skipped (disabled in sources config)") + elif spotify_client and spotify_client.is_spotify_authenticated(): print("[Your Artists] Fetching followed artists from Spotify...") artists = spotify_client.get_followed_artists() for a in artists: @@ -42427,7 +42465,9 @@ def _fetch_and_match_liked_artists(profile_id: int): # 2. Fetch from Tidal (favorite artists) try: - if tidal_client and hasattr(tidal_client, 'get_favorite_artists'): + if 'tidal' not in enabled_sources: + print("[Your Artists] Tidal skipped (disabled in sources config)") + elif tidal_client and hasattr(tidal_client, 'get_favorite_artists'): tidal_auth = tidal_client._ensure_valid_token() if hasattr(tidal_client, '_ensure_valid_token') else False if tidal_auth: print("[Your Artists] Fetching favorite artists from Tidal...") @@ -42444,42 +42484,48 @@ def _fetch_and_match_liked_artists(profile_id: int): # 3. Fetch from Last.fm (top artists) try: - lastfm_key = config_manager.get('lastfm.api_key', '') - lastfm_secret = config_manager.get('lastfm.api_secret', '') - lastfm_session = config_manager.get('lastfm.session_key', '') - print(f"[Your Artists] Last.fm credentials: key={'yes' if lastfm_key else 'NO'}, secret={'yes' if lastfm_secret else 'NO'}, session={'yes' if lastfm_session else 'NO'}") - if lastfm_key and lastfm_secret and lastfm_session: - from core.lastfm_client import LastFMClient - lfm = LastFMClient(api_key=lastfm_key, api_secret=lastfm_secret, session_key=lastfm_session) - username = lfm.get_authenticated_username() - print(f"[Your Artists] Last.fm username resolved: {username or 'NONE'}") - if username: - print(f"[Your Artists] Fetching top artists from Last.fm ({username})...") - artists = lfm.get_user_top_artists(username, period='overall', limit=200) - for a in artists: - database.upsert_liked_artist( - artist_name=a['name'], source_service='lastfm', - image_url=a.get('image_url'), profile_id=profile_id - ) - fetched += len(artists) - print(f"[Your Artists] Fetched {len(artists)} from Last.fm") + if 'lastfm' not in enabled_sources: + print("[Your Artists] Last.fm skipped (disabled in sources config)") + else: + lastfm_key = config_manager.get('lastfm.api_key', '') + lastfm_secret = config_manager.get('lastfm.api_secret', '') + lastfm_session = config_manager.get('lastfm.session_key', '') + print(f"[Your Artists] Last.fm credentials: key={'yes' if lastfm_key else 'NO'}, secret={'yes' if lastfm_secret else 'NO'}, session={'yes' if lastfm_session else 'NO'}") + if lastfm_key and lastfm_secret and lastfm_session: + from core.lastfm_client import LastFMClient + lfm = LastFMClient(api_key=lastfm_key, api_secret=lastfm_secret, session_key=lastfm_session) + username = lfm.get_authenticated_username() + print(f"[Your Artists] Last.fm username resolved: {username or 'NONE'}") + if username: + print(f"[Your Artists] Fetching top artists from Last.fm ({username})...") + artists = lfm.get_user_top_artists(username, period='overall', limit=200) + for a in artists: + database.upsert_liked_artist( + artist_name=a['name'], source_service='lastfm', + image_url=a.get('image_url'), profile_id=profile_id + ) + fetched += len(artists) + print(f"[Your Artists] Fetched {len(artists)} from Last.fm") except Exception as e: logger.error(f"[Your Artists] Last.fm fetch error: {e}") # 4. Fetch from Deezer (favorite artists — requires OAuth) try: - deezer_cl = _get_deezer_client() - if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated(): - print("[Your Artists] Fetching favorite artists from Deezer...") - artists = deezer_cl.get_user_favorite_artists(limit=200) - for a in artists: - database.upsert_liked_artist( - artist_name=a['name'], source_service='deezer', - source_id=a.get('deezer_id'), source_id_type='deezer', - image_url=a.get('image_url'), profile_id=profile_id - ) - fetched += len(artists) - print(f"[Your Artists] Fetched {len(artists)} from Deezer") + if 'deezer' not in enabled_sources: + print("[Your Artists] Deezer skipped (disabled in sources config)") + else: + deezer_cl = _get_deezer_client() + if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated(): + print("[Your Artists] Fetching favorite artists from Deezer...") + artists = deezer_cl.get_user_favorite_artists(limit=200) + for a in artists: + database.upsert_liked_artist( + artist_name=a['name'], source_service='deezer', + source_id=a.get('deezer_id'), source_id_type='deezer', + image_url=a.get('image_url'), profile_id=profile_id + ) + fetched += len(artists) + print(f"[Your Artists] Fetched {len(artists)} from Deezer") except Exception as e: logger.error(f"[Your Artists] Deezer fetch error: {e}") diff --git a/webui/index.html b/webui/index.html index 45d7729a..a07ef59b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3284,6 +3284,9 @@ + + `; + }).join(''); + + overlay.innerHTML = ` +
+

Your Artists Sources

+

Choose which connected services contribute artists to this section.

+
${rows}
+ +
+ `; + document.body.appendChild(overlay); + window._yaSourcesState = state; +} + +function _yaSourceRowClick(id) { + // Don't allow toggling disconnected services + const row = document.querySelector(`.ya-source-row[data-source="${id}"]`); + if (row && row.classList.contains('disconnected')) return; + _yaSourceToggle(id); +} + +function _yaSourceToggle(id) { + // Don't allow toggling disconnected services + const row = document.querySelector(`.ya-source-row[data-source="${id}"]`); + if (row && row.classList.contains('disconnected')) return; + window._yaSourcesState[id] = !window._yaSourcesState[id]; + const btn = document.getElementById(`ya-toggle-${id}`); + if (btn) btn.classList.toggle('on', window._yaSourcesState[id]); +} + +async function _yaSourcesSave() { + const enabledArr = Object.entries(window._yaSourcesState) + .filter(([, v]) => v).map(([k]) => k); + if (enabledArr.length === 0) { + showToast('Select at least one source', 'error'); + return; + } + const enabled = enabledArr.join(','); + try { + const resp = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ discover: { your_artists_sources: enabled } }) + }); + if (resp.ok) { + document.getElementById('ya-sources-modal-overlay')?.remove(); + showToast('Sources saved — refresh to apply', 'success'); + // Update subtitle immediately + const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', lastfm: 'Last.fm', deezer: 'Deezer' }; + const subtitle = document.getElementById('your-artists-subtitle'); + if (subtitle) { + const names = enabledArr.map(s => sourceNames[s] || s).join(' and '); + subtitle.textContent = `Artists you follow on ${names}`; + } + } else { + showToast('Failed to save sources', 'error'); + } + } catch (e) { + showToast('Failed to save sources', 'error'); + } +} + async function openYourArtistsModal() { const existing = document.getElementById('your-artists-modal-overlay'); if (existing) existing.remove(); diff --git a/webui/static/style.css b/webui/static/style.css index 578d43fc..560f111e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -269,7 +269,6 @@ body { gap: 8px; position: sticky; top: 0; - z-index: 10; overflow: hidden; flex-shrink: 0; @@ -30228,6 +30227,9 @@ div.artist-hero-badge { .ya-refresh-btn { padding: 7px 10px; } .ya-refresh-btn:hover svg { transform: rotate(45deg); } .ya-refresh-btn svg { transition: transform 0.3s; } +.ya-settings-btn { padding: 7px 10px; } +.ya-settings-btn:hover svg { transform: rotate(60deg); } +.ya-settings-btn svg { transition: transform 0.3s; } .ya-viewall-btn { background: rgba(var(--accent-rgb),0.1); border-color: rgba(var(--accent-rgb),0.2); color: rgba(var(--accent-rgb),0.9); @@ -30239,6 +30241,52 @@ div.artist-hero-badge { .ya-viewall-btn svg { transition: transform 0.2s; } .ya-viewall-btn:hover svg { transform: translateX(3px); } +/* Your Artists Sources Modal */ +.ya-sources-modal { + background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); border-radius: 16px; + width: 420px; max-width: 95vw; + box-shadow: 0 24px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.06); + padding: 28px; +} +.ya-sources-modal h2 { font-size: 16px; font-weight: 700; color: #fff; margin: 0 0 4px; } +.ya-sources-modal .ya-sources-desc { font-size: 12px; color: rgba(255,255,255,0.45); margin: 0 0 22px; } +.ya-sources-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; } +.ya-source-row { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; border-radius: 10px; + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); + cursor: pointer; transition: background 0.15s; +} +.ya-source-row:hover { background: rgba(255,255,255,0.07); } +.ya-source-row.disconnected { opacity: 0.45; cursor: default; } +.ya-source-row-left { display: flex; align-items: center; gap: 10px; } +.ya-source-name { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.85); } +.ya-source-status { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 1px; } +.ya-source-toggle { + width: 36px; height: 20px; border-radius: 10px; border: none; cursor: pointer; + background: rgba(255,255,255,0.12); position: relative; transition: background 0.2s; flex-shrink: 0; +} +.ya-source-toggle.on { background: var(--accent, #7c6af7); } +.ya-source-toggle::after { + content: ''; position: absolute; top: 3px; left: 3px; + width: 14px; height: 14px; border-radius: 50%; background: #fff; + transition: transform 0.2s; +} +.ya-source-toggle.on::after { transform: translateX(16px); } +.ya-sources-footer { display: flex; justify-content: flex-end; gap: 10px; } +.ya-sources-save-btn { + padding: 9px 20px; border-radius: 10px; font-size: 13px; font-weight: 600; + background: var(--accent, #7c6af7); color: #fff; border: none; cursor: pointer; + transition: opacity 0.2s; +} +.ya-sources-save-btn:hover { opacity: 0.85; } +.ya-sources-cancel-btn { + padding: 9px 16px; border-radius: 10px; font-size: 13px; font-weight: 600; + background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6); + border: 1px solid rgba(255,255,255,0.08); cursor: pointer; transition: background 0.15s; +} +.ya-sources-cancel-btn:hover { background: rgba(255,255,255,0.1); } + /* Your Artists Modal */ .ya-modal { background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); border-radius: 16px;