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 = ` + + `; + 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 = ` + + `; + 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 = ` + + `; + } +} + +function renderRecommendedArtistsModal(modal, artists) { + modal.innerHTML = ` + + `; + + // 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;