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)
This commit is contained in:
Broque Thomas 2026-04-13 13:39:36 -07:00
parent d210c2311f
commit ff5684bded
4 changed files with 242 additions and 35 deletions

View file

@ -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}")

View file

@ -3284,6 +3284,9 @@
<button class="ya-header-btn ya-refresh-btn" id="your-artists-refresh-btn" onclick="refreshYourArtists()" title="Refresh from services">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="ya-header-btn ya-settings-btn" onclick="openYourArtistsSourcesModal()" title="Configure sources">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>
<button class="ya-header-btn ya-viewall-btn" onclick="openYourArtistsModal()">
<span>View All</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>

View file

@ -56554,6 +56554,116 @@ async function refreshYourArtists() {
}
}
async function openYourArtistsSourcesModal() {
const existing = document.getElementById('ya-sources-modal-overlay');
if (existing) existing.remove();
// Fetch current config + connected services
let enabled = ['spotify', 'tidal', 'lastfm', 'deezer'];
let connected = [];
try {
const resp = await fetch('/api/discover/your-artists/sources');
if (resp.ok) {
const data = await resp.json();
if (data.enabled) enabled = data.enabled;
if (data.connected) connected = data.connected;
}
} catch (e) {}
const sourceInfo = [
{ id: 'spotify', label: 'Spotify', icon: '🎵' },
{ id: 'tidal', label: 'Tidal', icon: '🌊' },
{ id: 'lastfm', label: 'Last.fm', icon: '📻' },
{ id: 'deezer', label: 'Deezer', icon: '🎶' },
];
const state = {};
sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); });
const overlay = document.createElement('div');
overlay.id = 'ya-sources-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const rows = sourceInfo.map(s => {
const isConnected = connected.includes(s.id);
const isOn = state[s.id];
return `
<div class="ya-source-row${isConnected ? '' : ' disconnected'}" data-source="${s.id}" onclick="_yaSourceRowClick('${s.id}')">
<div class="ya-source-row-left">
<span style="font-size:18px">${s.icon}</span>
<div>
<div class="ya-source-name">${s.label}</div>
<div class="ya-source-status">${isConnected ? 'Connected' : 'Not connected'}</div>
</div>
</div>
<button class="ya-source-toggle${isOn ? ' on' : ''}" id="ya-toggle-${s.id}" onclick="event.stopPropagation();_yaSourceToggle('${s.id}')"></button>
</div>`;
}).join('');
overlay.innerHTML = `
<div class="ya-sources-modal">
<h2>Your Artists Sources</h2>
<p class="ya-sources-desc">Choose which connected services contribute artists to this section.</p>
<div class="ya-sources-list">${rows}</div>
<div class="ya-sources-footer">
<button class="ya-sources-cancel-btn" onclick="document.getElementById('ya-sources-modal-overlay').remove()">Cancel</button>
<button class="ya-sources-save-btn" onclick="_yaSourcesSave()">Save</button>
</div>
</div>
`;
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();

View file

@ -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;