view recommended artists.

This commit is contained in:
Broque Thomas 2026-03-02 09:12:35 -08:00
parent 8f2dd66aee
commit 1f73ba4f94
5 changed files with 624 additions and 8 deletions

View file

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

View file

@ -2190,11 +2190,16 @@
<!-- Slideshow Indicators -->
<div class="discover-hero-indicators" id="discover-hero-indicators"></div>
<!-- Watch All Button -->
<button class="discover-hero-watch-all" id="discover-hero-watch-all" onclick="watchAllHeroArtists(this)">
<span class="watch-all-icon">👁️</span>
<span class="watch-all-text">Watch All</span>
</button>
<!-- Hero Bottom Buttons -->
<div class="discover-hero-bottom-actions">
<button class="discover-hero-watch-all" id="discover-hero-watch-all" onclick="watchAllHeroArtists(this)">
<span class="watch-all-icon">👁️</span>
<span class="watch-all-text">Watch All</span>
</button>
<button class="discover-hero-view-all" id="discover-hero-view-all" onclick="openRecommendedArtistsModal()">
View Recommended
</button>
</div>
</div>
<!-- Recent Releases Section -->

View file

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

View file

@ -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 = `
<div class="modal-container playlist-modal recommended-modal">
<div class="playlist-modal-header">
<div class="playlist-header-content" style="width: 100%;">
<h2>Recommended Artists</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">Loading...</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">&times;</span>
</div>
<div class="playlist-modal-body">
<div class="recommended-loading">Loading recommended artists...</div>
</div>
</div>
`;
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 = `
<div class="recommended-empty-state">
No recommended artists yet.<br>
Run a watchlist scan to generate recommendations.
</div>
`;
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 = `<img src="${info.image_url}" alt="" loading="lazy"
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">`;
}
}
if (card && info.genres && info.genres.length > 0) {
const genresContainer = card.querySelector('.recommended-card-genres');
if (genresContainer) {
genresContainer.innerHTML = info.genres.map(g =>
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
).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 =>
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
).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 = `
<div class="recommended-empty-state">Error loading recommended artists.</div>
`;
}
}
function renderRecommendedArtistsModal(modal, artists) {
modal.innerHTML = `
<div class="modal-container playlist-modal recommended-modal">
<div class="playlist-modal-header">
<div class="playlist-header-content" style="width: 100%;">
<h2>Recommended Artists</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">${artists.length} artist${artists.length !== 1 ? 's' : ''}</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">&times;</span>
</div>
<div class="playlist-modal-body">
<div class="recommended-search-container">
<input type="text"
class="recommended-search-input"
id="recommended-search-input"
placeholder="Search recommended artists..."
oninput="filterRecommendedArtists()">
</div>
<div class="recommended-artists-grid" id="recommended-artists-grid">
${artists.map(artist => {
const genreTags = (artist.genres || []).slice(0, 3).map(g =>
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
).join('');
const similarText = artist.occurrence_count > 1
? `Similar to ${artist.occurrence_count} in your watchlist`
: 'Similar to an artist in your watchlist';
return `
<div class="recommended-artist-card"
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
data-artist-id="${artist.artist_id}">
<button class="recommended-card-watchlist-btn"
data-artist-id="${artist.artist_id}"
data-artist-name="${escapeHtml(artist.artist_name)}">
Add to Watchlist
</button>
<div class="recommended-card-image">
${artist.image_url ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.artist_name)}"
loading="lazy"
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">
` : `
<div class="recommended-card-image-fallback">🎤</div>
`}
</div>
<div class="recommended-card-info">
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
<span class="recommended-card-similarity">${similarText}</span>
<div class="recommended-card-genres">${genreTags}</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
`;
// 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;

View file

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