diff --git a/web_server.py b/web_server.py
index 2d848064..f5347c66 100644
--- a/web_server.py
+++ b/web_server.py
@@ -23087,6 +23087,108 @@ def get_discover_hero():
return jsonify({"success": False, "error": str(e)}), 500
+@app.route('/api/discover/similar-artists', methods=['GET'])
+def get_discover_similar_artists():
+ """Get all recommended similar artists (basic data, no enrichment for speed)"""
+ try:
+ database = get_database()
+ active_source = _get_active_discovery_source()
+
+ similar_artists = database.get_top_similar_artists(limit=200)
+
+ if not similar_artists:
+ return jsonify({"success": True, "artists": [], "source": active_source, "count": 0})
+
+ # Filter to artists with valid ID for active source
+ result_artists = []
+ for artist in similar_artists:
+ has_spotify = bool(artist.similar_artist_spotify_id)
+ has_itunes = bool(artist.similar_artist_itunes_id)
+
+ if active_source == 'spotify' and not has_spotify and not has_itunes:
+ continue
+ if active_source == 'itunes' and not has_itunes and not has_spotify:
+ continue
+
+ if active_source == 'spotify':
+ artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
+ else:
+ artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
+
+ result_artists.append({
+ "artist_id": artist_id,
+ "spotify_artist_id": artist.similar_artist_spotify_id,
+ "itunes_artist_id": artist.similar_artist_itunes_id,
+ "artist_name": artist.similar_artist_name,
+ "occurrence_count": artist.occurrence_count,
+ "similarity_rank": artist.similarity_rank,
+ "source": active_source,
+ })
+
+ print(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}")
+
+ return jsonify({
+ "success": True,
+ "artists": result_artists,
+ "source": active_source,
+ "count": len(result_artists)
+ })
+
+ except Exception as e:
+ print(f"Error getting similar artists: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+
+@app.route('/api/discover/similar-artists/enrich', methods=['POST'])
+def enrich_similar_artists():
+ """Enrich a batch of artist IDs with images/genres from Spotify or iTunes"""
+ try:
+ data = request.get_json()
+ artist_ids = data.get('artist_ids', [])
+ source = data.get('source', 'spotify')
+
+ if not artist_ids:
+ return jsonify({"success": True, "artists": {}})
+
+ enriched = {}
+
+ if source == 'spotify' and spotify_client and spotify_client.is_authenticated():
+ try:
+ batch_result = spotify_client.sp.artists(artist_ids[:50])
+ if batch_result and 'artists' in batch_result:
+ for sp_artist in batch_result['artists']:
+ if sp_artist:
+ enriched[sp_artist['id']] = {
+ "artist_name": sp_artist.get('name'),
+ "image_url": sp_artist['images'][0]['url'] if sp_artist.get('images') else None,
+ "genres": sp_artist.get('genres', [])[:3],
+ "popularity": sp_artist.get('popularity', 0)
+ }
+ except Exception as e:
+ print(f"Error enriching Spotify batch: {e}")
+ else:
+ from core.itunes_client import iTunesClient
+ itunes_client = iTunesClient()
+ for aid in artist_ids[:50]:
+ try:
+ itunes_artist = itunes_client.get_artist(aid)
+ if itunes_artist:
+ enriched[aid] = {
+ "artist_name": itunes_artist.get('name'),
+ "image_url": itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None,
+ "genres": itunes_artist.get('genres', [])[:3],
+ "popularity": 0
+ }
+ except Exception:
+ pass
+
+ return jsonify({"success": True, "artists": enriched})
+
+ except Exception as e:
+ print(f"Error enriching similar artists: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
+
+
@app.route('/api/discover/recent-releases', methods=['GET'])
def get_discover_recent_releases():
"""Get cached recent albums from watchlist and similar artists"""
diff --git a/webui/index.html b/webui/index.html
index 252f8ff0..1fb88ee4 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -2190,11 +2190,16 @@
-
-
+
+
+
+
+
diff --git a/webui/static/mobile.css b/webui/static/mobile.css
index dcfe8471..a640f5d1 100644
--- a/webui/static/mobile.css
+++ b/webui/static/mobile.css
@@ -913,6 +913,40 @@
padding: 16px;
}
+ .discover-hero-bottom-actions {
+ bottom: 12px;
+ right: 12px;
+ gap: 6px;
+ }
+
+ .discover-hero-watch-all,
+ .discover-hero-view-all {
+ padding: 6px 12px;
+ font-size: 11px;
+ }
+
+ /* Recommended Artists Modal - Mobile */
+ .recommended-modal {
+ max-width: 100vw;
+ width: 100vw;
+ max-height: 100vh;
+ border-radius: 0;
+ }
+
+ .recommended-artists-grid {
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 12px;
+ padding: 12px 16px;
+ }
+
+ .recommended-search-container {
+ padding: 0 16px;
+ }
+
+ .recommended-card-watchlist-btn {
+ opacity: 1;
+ }
+
.discover-section-header {
flex-direction: column;
gap: 12px;
diff --git a/webui/static/script.js b/webui/static/script.js
index a282e485..398ff7dd 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -33596,6 +33596,311 @@ async function watchAllHeroArtists(btn) {
}
}
+// Cache for recommended artists data so reopening is instant
+let _recommendedArtistsCache = null;
+
+async function openRecommendedArtistsModal() {
+ let modal = document.getElementById('recommended-artists-modal');
+ if (!modal) {
+ modal = document.createElement('div');
+ modal.id = 'recommended-artists-modal';
+ modal.className = 'modal-overlay';
+ document.body.appendChild(modal);
+
+ modal.addEventListener('click', function(e) {
+ if (e.target === modal) closeRecommendedArtistsModal();
+ });
+ }
+
+ // If cached, render instantly and refresh watchlist statuses
+ if (_recommendedArtistsCache) {
+ modal.style.display = 'flex';
+ renderRecommendedArtistsModal(modal, _recommendedArtistsCache);
+ checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
+ return;
+ }
+
+ // Show loading
+ modal.innerHTML = `
+
+
+
+
Loading recommended artists...
+
+
+ `;
+ modal.style.display = 'flex';
+
+ try {
+ // Phase 1: Fetch basic data (instant — no API enrichment)
+ const response = await fetch('/api/discover/similar-artists');
+ const data = await response.json();
+
+ if (!data.success || !data.artists || data.artists.length === 0) {
+ modal.querySelector('.playlist-modal-body').innerHTML = `
+
+ No recommended artists yet.
+ Run a watchlist scan to generate recommendations.
+
+ `;
+ modal.querySelector('.playlist-track-count').textContent = '0 artists';
+ return;
+ }
+
+ // Render cards immediately with fallback images
+ _recommendedArtistsCache = data.artists;
+ renderRecommendedArtistsModal(modal, data.artists);
+
+ // Phase 2: Enrich with images/genres progressively in batches of 50
+ const source = data.source || 'spotify';
+ const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id';
+ const allIds = data.artists.map(a => a[idKey]).filter(Boolean);
+
+ for (let i = 0; i < allIds.length; i += 50) {
+ const batchIds = allIds.slice(i, i + 50);
+ try {
+ const enrichResp = await fetch('/api/discover/similar-artists/enrich', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_ids: batchIds, source })
+ });
+ const enrichData = await enrichResp.json();
+ if (enrichData.success && enrichData.artists) {
+ // Update cards and cache as each batch arrives
+ for (const [aid, info] of Object.entries(enrichData.artists)) {
+ // Update the card in DOM
+ const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
+ if (card && info.image_url) {
+ const imgContainer = card.querySelector('.recommended-card-image');
+ if (imgContainer) {
+ imgContainer.innerHTML = `
`;
+ }
+ }
+ if (card && info.genres && info.genres.length > 0) {
+ const genresContainer = card.querySelector('.recommended-card-genres');
+ if (genresContainer) {
+ genresContainer.innerHTML = info.genres.map(g =>
+ `${escapeHtml(g)}`
+ ).join('');
+ } else {
+ const infoDiv = card.querySelector('.recommended-card-info');
+ if (infoDiv) {
+ const genreDiv = document.createElement('div');
+ genreDiv.className = 'recommended-card-genres';
+ genreDiv.innerHTML = info.genres.map(g =>
+ `${escapeHtml(g)}`
+ ).join('');
+ infoDiv.appendChild(genreDiv);
+ }
+ }
+ }
+
+ // Update cache
+ const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid);
+ if (cached) {
+ if (info.image_url) cached.image_url = info.image_url;
+ if (info.genres) cached.genres = info.genres;
+ if (info.artist_name) cached.artist_name = info.artist_name;
+ }
+ }
+ }
+ } catch (enrichErr) {
+ console.error('Error enriching batch:', enrichErr);
+ }
+ }
+
+ // Phase 3: Check watchlist statuses
+ checkRecommendedWatchlistStatuses(data.artists);
+
+ } catch (error) {
+ console.error('Error loading recommended artists:', error);
+ modal.querySelector('.playlist-modal-body').innerHTML = `
+ Error loading recommended artists.
+ `;
+ }
+}
+
+function renderRecommendedArtistsModal(modal, artists) {
+ modal.innerHTML = `
+
+
+
+
+
+
+
+ ${artists.map(artist => {
+ const genreTags = (artist.genres || []).slice(0, 3).map(g =>
+ `
${escapeHtml(g)}`
+ ).join('');
+ const similarText = artist.occurrence_count > 1
+ ? `Similar to ${artist.occurrence_count} in your watchlist`
+ : 'Similar to an artist in your watchlist';
+ return `
+
+
+
+ ${artist.image_url ? `
+

+ ` : `
+
🎤
+ `}
+
+
+
${escapeHtml(artist.artist_name)}
+
${similarText}
+
${genreTags}
+
+
+ `;
+ }).join('')}
+
+
+
+ `;
+
+ // Event delegation for card clicks and watchlist buttons
+ const grid = modal.querySelector('#recommended-artists-grid');
+ if (grid) {
+ grid.addEventListener('click', function(e) {
+ const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn');
+ if (watchlistBtn) {
+ e.stopPropagation();
+ toggleRecommendedWatchlist(watchlistBtn);
+ return;
+ }
+
+ const card = e.target.closest('.recommended-artist-card');
+ if (card) {
+ const artistId = card.getAttribute('data-artist-id');
+ const nameEl = card.querySelector('.recommended-card-name');
+ const artistName = nameEl ? nameEl.textContent : '';
+ viewRecommendedArtistDiscography(artistId, artistName);
+ }
+ });
+ }
+}
+
+function closeRecommendedArtistsModal() {
+ const modal = document.getElementById('recommended-artists-modal');
+ if (modal) modal.style.display = 'none';
+}
+
+function filterRecommendedArtists() {
+ const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase();
+ const cards = document.querySelectorAll('.recommended-artist-card');
+ cards.forEach(card => {
+ const name = card.getAttribute('data-artist-name') || '';
+ card.style.display = name.includes(query) ? '' : 'none';
+ });
+}
+
+async function toggleRecommendedWatchlist(btn) {
+ const artistId = btn.getAttribute('data-artist-id');
+ const artistName = btn.getAttribute('data-artist-name');
+ if (!artistId || !artistName) return;
+
+ btn.disabled = true;
+ const wasWatching = btn.classList.contains('watching');
+
+ try {
+ if (wasWatching) {
+ const resp = await fetch('/api/watchlist/remove', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artistId })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ btn.classList.remove('watching');
+ btn.textContent = 'Add to Watchlist';
+ }
+ } else {
+ const resp = await fetch('/api/watchlist/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artistId, artist_name: artistName })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ btn.classList.add('watching');
+ btn.textContent = 'Watching';
+ }
+ }
+ if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
+ } catch (error) {
+ console.error('Error toggling recommended watchlist:', error);
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+async function checkRecommendedWatchlistStatuses(artists) {
+ for (const artist of artists) {
+ try {
+ const resp = await fetch('/api/watchlist/check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artist_id: artist.artist_id })
+ });
+ const data = await resp.json();
+ if (data.success && data.is_watching) {
+ const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${artist.artist_id}"]`);
+ if (btn) {
+ btn.classList.add('watching');
+ btn.textContent = 'Watching';
+ }
+ }
+ } catch (e) {
+ // Non-critical
+ }
+ }
+}
+
+async function viewRecommendedArtistDiscography(artistId, artistName) {
+ closeRecommendedArtistsModal();
+
+ const artist = {
+ id: artistId,
+ name: artistName
+ };
+
+ // Use same navigation pattern as hero slider
+ navigateToPage('artists');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ await selectArtistForDetail(artist);
+}
+
async function checkAllHeroWatchlistStatus() {
const btn = document.getElementById('discover-hero-watch-all');
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
diff --git a/webui/static/style.css b/webui/static/style.css
index 549b5356..5aeea09f 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -20179,12 +20179,19 @@ body {
border-radius: 4px;
}
-/* Watch All Button */
-.discover-hero-watch-all {
+/* Hero Bottom Action Buttons */
+.discover-hero-bottom-actions {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.discover-hero-watch-all,
+.discover-hero-view-all {
display: flex;
align-items: center;
gap: 6px;
@@ -20200,7 +20207,8 @@ body {
transition: background 0.2s ease, border-color 0.2s ease;
}
-.discover-hero-watch-all:hover {
+.discover-hero-watch-all:hover,
+.discover-hero-view-all:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.3);
}
@@ -20221,6 +20229,168 @@ body {
cursor: wait;
}
+/* Recommended Artists Modal */
+.recommended-modal {
+ max-width: 95vw;
+ width: 95vw;
+ max-height: 95vh;
+}
+
+.recommended-modal .playlist-modal-body {
+ overflow-y: auto;
+ padding: 0;
+ min-height: 0;
+}
+
+.recommended-artists-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 20px;
+ padding: 20px 28px;
+}
+
+.recommended-artist-card {
+ position: relative;
+ border-radius: 14px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+ transition: transform 0.2s ease, border-color 0.2s ease;
+}
+
+.recommended-artist-card:hover {
+ transform: translateY(-3px);
+ border-color: rgba(var(--accent-rgb), 0.3);
+}
+
+.recommended-card-image {
+ width: 100%;
+ aspect-ratio: 1;
+ overflow: hidden;
+ background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15), rgba(var(--accent-rgb), 0.05));
+}
+
+.recommended-card-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.recommended-card-image-fallback {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 48px;
+ opacity: 0.3;
+}
+
+.recommended-card-info {
+ padding: 12px 14px;
+}
+
+.recommended-card-name {
+ display: block;
+ font-size: 14px;
+ font-weight: 700;
+ color: #fff;
+ margin-bottom: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.recommended-card-similarity {
+ display: block;
+ font-size: 11px;
+ color: rgba(var(--accent-rgb), 0.8);
+ margin-bottom: 6px;
+}
+
+.recommended-card-genres {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.recommended-card-genre {
+ padding: 2px 8px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 10px;
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.recommended-card-watchlist-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 3;
+ padding: 5px 10px;
+ border-radius: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(8px);
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.2s ease, background 0.2s ease;
+}
+
+.recommended-artist-card:hover .recommended-card-watchlist-btn {
+ opacity: 1;
+}
+
+.recommended-card-watchlist-btn.watching {
+ background: rgba(var(--accent-rgb), 0.2);
+ border-color: rgba(var(--accent-rgb), 0.5);
+ color: rgb(var(--accent-rgb));
+ opacity: 1;
+}
+
+.recommended-search-container {
+ padding: 0 28px;
+ margin-bottom: 12px;
+}
+
+.recommended-search-input {
+ width: 100%;
+ padding: 10px 16px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ color: #fff;
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.2s ease;
+}
+
+.recommended-search-input:focus {
+ border-color: rgba(var(--accent-rgb), 0.4);
+}
+
+.recommended-search-input::placeholder {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.recommended-empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 15px;
+}
+
+.recommended-loading {
+ text-align: center;
+ padding: 60px 20px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 15px;
+}
+
/* Discover Sections */
.discover-section {
margin-bottom: 50px;