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:
parent
d210c2311f
commit
ff5684bded
4 changed files with 242 additions and 35 deletions
114
web_server.py
114
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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue