discover progress

This commit is contained in:
Broque Thomas 2025-11-16 16:27:44 -08:00
parent 68a15071f1
commit f75ffdf6cb
6 changed files with 974 additions and 9 deletions

View file

@ -323,6 +323,103 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting hidden gems: {e}")
return []
def get_discovery_shuffle(self, limit: int = 50) -> List[Dict]:
"""
Get random tracks from discovery pool - pure exploration.
Different every time you call it!
"""
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
spotify_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity
FROM discovery_pool
ORDER BY RANDOM()
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"Error getting discovery shuffle: {e}")
return []
def get_familiar_favorites(self, limit: int = 50) -> List[Dict]:
"""
Get tracks with medium play counts (3-15 plays) - your reliable go-tos.
Not overplayed, not rare - just right!
"""
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Check if play_count exists
cursor.execute("PRAGMA table_info(tracks)")
columns = [row['name'] for row in cursor.fetchall()]
if 'play_count' not in columns:
logger.warning("play_count column not found - using random older tracks")
# Fallback: tracks added 30-90 days ago
thirty_days_ago = (datetime.now() - timedelta(days=30)).isoformat()
ninety_days_ago = (datetime.now() - timedelta(days=90)).isoformat()
cursor.execute("""
SELECT
t.id,
t.spotify_track_id,
t.title as track_name,
t.duration_ms,
ar.name as artist_name,
al.title as album_name,
al.cover_url as album_cover_url,
t.popularity
FROM tracks t
LEFT JOIN artists ar ON t.artist_id = ar.id
LEFT JOIN albums al ON t.album_id = al.id
WHERE t.spotify_track_id IS NOT NULL
AND t.date_added BETWEEN ? AND ?
ORDER BY RANDOM()
LIMIT ?
""", (ninety_days_ago, thirty_days_ago, limit))
else:
cursor.execute("""
SELECT
t.id,
t.spotify_track_id,
t.title as track_name,
t.duration_ms,
ar.name as artist_name,
al.title as album_name,
al.cover_url as album_cover_url,
t.popularity,
t.play_count
FROM tracks t
LEFT JOIN artists ar ON t.artist_id = ar.id
LEFT JOIN albums al ON t.album_id = al.id
WHERE t.spotify_track_id IS NOT NULL
AND t.play_count BETWEEN 3 AND 15
ORDER BY t.play_count DESC, RANDOM()
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"Error getting familiar favorites: {e}")
return []
# ========================================
# DAILY MIX (HYBRID PLAYLISTS)
# ========================================
@ -531,6 +628,156 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting all daily mixes: {e}")
return []
# ========================================
# BUILD A PLAYLIST (CUSTOM GENERATOR)
# ========================================
def build_custom_playlist(self, seed_artist_ids: List[str], playlist_size: int = 50) -> Dict[str, Any]:
"""
Build a custom playlist from seed artists.
Process:
1. Get similar artists for each seed artist (max 25 total)
2. Get albums from those similar artists
3. Select 20 random albums
4. Build playlist from tracks in those albums (max 50 tracks)
Args:
seed_artist_ids: List of 1-5 Spotify artist IDs
playlist_size: Maximum tracks in final playlist (default: 50)
Returns:
Dict with playlist metadata and tracks
"""
try:
if not seed_artist_ids or len(seed_artist_ids) > 5:
logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}")
return {'tracks': [], 'error': 'Must provide 1-5 seed artists'}
if not self.spotify_client or not self.spotify_client.is_authenticated():
logger.error("Spotify client not available")
return {'tracks': [], 'error': 'Spotify not authenticated'}
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists")
# Step 1: Get similar artists for each seed
all_similar_artists = []
seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves
for seed_artist_id in seed_artist_ids:
try:
# Get similar artists from Spotify
similar = self.spotify_client.get_similar_artists(seed_artist_id)
if similar:
for artist in similar[:10]: # Max 10 per seed
if artist.id not in seen_artist_ids:
all_similar_artists.append(artist)
seen_artist_ids.add(artist.id)
if len(all_similar_artists) >= 25:
break
if len(all_similar_artists) >= 25:
break
except Exception as e:
logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}")
continue
logger.info(f"Found {len(all_similar_artists)} similar artists")
if not all_similar_artists:
return {'tracks': [], 'error': 'No similar artists found'}
# Limit to 25 similar artists
similar_artists_to_use = all_similar_artists[:25]
# Step 2: Get albums from similar artists
all_albums = []
for artist in similar_artists_to_use:
try:
albums = self.spotify_client.get_artist_albums(
artist.id,
album_type='album,single',
limit=10
)
if albums:
all_albums.extend(albums)
import time
time.sleep(0.3) # Rate limiting
except Exception as e:
logger.warning(f"Error getting albums for {artist.name}: {e}")
continue
logger.info(f"Found {len(all_albums)} total albums")
if not all_albums:
return {'tracks': [], 'error': 'No albums found'}
# Step 3: Select 20 random albums
random.shuffle(all_albums)
selected_albums = all_albums[:20]
logger.info(f"Selected {len(selected_albums)} random albums")
# Step 4: Build playlist from tracks in those albums
all_tracks = []
for album in selected_albums:
try:
album_data = self.spotify_client.get_album(album.id)
if album_data and 'tracks' in album_data:
tracks = album_data['tracks'].get('items', [])
for track in tracks:
if track['id']:
all_tracks.append({
'spotify_track_id': track['id'],
'track_name': track['name'],
'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]),
'album_name': album_data.get('name', 'Unknown'),
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
'duration_ms': track.get('duration_ms', 0),
'popularity': album_data.get('popularity', 0)
})
import time
time.sleep(0.3) # Rate limiting
except Exception as e:
logger.warning(f"Error getting tracks from album: {e}")
continue
logger.info(f"Collected {len(all_tracks)} total tracks")
if not all_tracks:
return {'tracks': [], 'error': 'No tracks found'}
# Shuffle and limit to playlist_size
random.shuffle(all_tracks)
final_tracks = all_tracks[:playlist_size]
logger.info(f"Built custom playlist with {len(final_tracks)} tracks")
return {
'name': 'Custom Playlist',
'description': f'Built from {len(seed_artist_ids)} seed artists',
'track_count': len(final_tracks),
'tracks': final_tracks,
'similar_artists_count': len(similar_artists_to_use),
'albums_used': len(selected_albums)
}
except Exception as e:
logger.error(f"Error building custom playlist: {e}")
import traceback
traceback.print_exc()
return {'tracks': [], 'error': str(e)}
# Singleton instance
_personalized_playlists_instance = None

View file

@ -622,10 +622,12 @@ class SeasonalDiscoveryService:
album_data = self.spotify_client.get_album(album['spotify_album_id'])
if album_data and 'tracks' in album_data:
for track in album_data['tracks'].get('items', []):
# Use track's actual artist, not album artist
track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name']
all_tracks.append({
'spotify_track_id': track['id'],
'track_name': track['name'],
'artist_name': album['artist_name'],
'artist_name': track_artist,
'album_name': album['album_name'],
'popularity': album.get('popularity', 50)
})

View file

@ -15007,6 +15007,109 @@ def get_daily_mixes():
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/personalized/discovery-shuffle', methods=['GET'])
def get_discovery_shuffle():
"""Get Discovery Shuffle playlist - random tracks from discovery pool"""
try:
from core.personalized_playlists import get_personalized_playlists_service
database = get_database()
service = get_personalized_playlists_service(database, spotify_client)
limit = int(request.args.get('limit', 50))
tracks = service.get_discovery_shuffle(limit=limit)
return jsonify({
"success": True,
"tracks": tracks
})
except Exception as e:
print(f"Error getting discovery shuffle playlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/personalized/familiar-favorites', methods=['GET'])
def get_familiar_favorites():
"""Get Familiar Favorites playlist - reliable go-to tracks"""
try:
from core.personalized_playlists import get_personalized_playlists_service
database = get_database()
service = get_personalized_playlists_service(database, spotify_client)
limit = int(request.args.get('limit', 50))
tracks = service.get_familiar_favorites(limit=limit)
return jsonify({
"success": True,
"tracks": tracks
})
except Exception as e:
print(f"Error getting familiar favorites playlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/build-playlist/search-artists', methods=['GET'])
def search_artists_for_playlist():
"""Search for artists to use as seeds for custom playlist building"""
try:
query = request.args.get('query', '').strip()
if not query:
return jsonify({"success": False, "error": "Query required"}), 400
# Search Spotify for artists
results = spotify_client.sp.search(q=query, type='artist', limit=10)
artists = []
if results and 'artists' in results and 'items' in results['artists']:
for artist in results['artists']['items']:
artists.append({
'id': artist['id'],
'name': artist['name'],
'image_url': artist['images'][0]['url'] if artist.get('images') else None
})
return jsonify({
"success": True,
"artists": artists
})
except Exception as e:
print(f"Error searching for artists: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/build-playlist/generate', methods=['POST'])
def generate_custom_playlist():
"""Generate custom playlist from seed artists"""
try:
from core.personalized_playlists import get_personalized_playlists_service
data = request.get_json()
seed_artist_ids = data.get('seed_artist_ids', [])
if not seed_artist_ids or len(seed_artist_ids) < 1 or len(seed_artist_ids) > 5:
return jsonify({
"success": False,
"error": "Please provide between 1 and 5 seed artists"
}), 400
database = get_database()
service = get_personalized_playlists_service(database, spotify_client)
playlist_size = int(data.get('playlist_size', 50))
result = service.build_custom_playlist(seed_artist_ids, playlist_size=playlist_size)
return jsonify({
"success": True,
"playlist": result
})
except Exception as e:
print(f"Error generating custom playlist: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/metadata/start', methods=['POST'])
def start_metadata_update():
"""Start the metadata update process - EXACT copy of dashboard.py logic"""

View file

@ -1727,14 +1727,30 @@
<p class="discover-section-subtitle" id="seasonal-playlist-subtitle">Curated seasonal playlist</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForSeasonalPlaylist()" title="Download missing tracks">
<span class="icon">📥</span> Download Missing
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('seasonal_playlist', 'Seasonal Mix')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button secondary" onclick="syncSeasonalPlaylist()" title="Sync to library">
<span class="icon">🔄</span> Sync
<button class="action-button primary" id="seasonal-playlist-sync-btn" onclick="startDiscoverPlaylistSync('seasonal_playlist', 'Seasonal Mix')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<!-- Sync Status Display -->
<div class="discover-sync-status" id="seasonal-playlist-sync-status" style="display: none;">
<div class="sync-status-content">
<div class="sync-status-label">
<span class="sync-icon"></span>
<span class="sync-text">Syncing Seasonal Mix...</span>
</div>
<div class="sync-status-progress">
<div class="sync-progress-text">
<span id="seasonal-playlist-sync-progress">0/0 tracks</span>
</div>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="seasonal-playlist">
<!-- Content will be populated dynamically -->
</div>
@ -1853,8 +1869,20 @@
<!-- Popular Picks Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<h2 class="discover-section-title">🔥 Popular Picks</h2>
<p class="discover-section-subtitle">Trending tracks from new discoveries</p>
<div>
<h2 class="discover-section-title">🔥 Popular Picks</h2>
<p class="discover-section-subtitle">Trending tracks from new discoveries</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('popular_picks', 'Popular Picks')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="popular-picks-sync-btn" onclick="startDiscoverPlaylistSync('popular_picks', 'Popular Picks')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-popular-picks">
<!-- Content will be populated dynamically -->
@ -1864,8 +1892,20 @@
<!-- Hidden Gems Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<h2 class="discover-section-title">🌟 Hidden Gems</h2>
<p class="discover-section-subtitle">Underground discoveries waiting for you</p>
<div>
<h2 class="discover-section-title">🌟 Hidden Gems</h2>
<p class="discover-section-subtitle">Underground discoveries waiting for you</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('hidden_gems', 'Hidden Gems')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="hidden-gems-sync-btn" onclick="startDiscoverPlaylistSync('hidden_gems', 'Hidden Gems')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-hidden-gems">
<!-- Content will be populated dynamically -->
@ -1894,6 +1934,102 @@
</div>
</div>
<!-- Discovery Shuffle Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">🔀 Discovery Shuffle</h2>
<p class="discover-section-subtitle">Random tracks from your discovery pool - different every time</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('discovery_shuffle', 'Discovery Shuffle')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="discovery-shuffle-sync-btn" onclick="startDiscoverPlaylistSync('discovery_shuffle', 'Discovery Shuffle')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-discovery-shuffle">
<!-- Content will be populated dynamically -->
</div>
</div>
<!-- Familiar Favorites Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">❤️ Familiar Favorites</h2>
<p class="discover-section-subtitle">Your reliable go-to tracks</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('familiar_favorites', 'Familiar Favorites')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="familiar-favorites-sync-btn" onclick="startDiscoverPlaylistSync('familiar_favorites', 'Familiar Favorites')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-familiar-favorites">
<!-- Content will be populated dynamically -->
</div>
</div>
<!-- Build a Playlist Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">🎨 Build a Playlist</h2>
<p class="discover-section-subtitle">Create a custom playlist from your favorite artists</p>
</div>
<div class="build-playlist-container">
<!-- Artist Search -->
<div class="build-playlist-search-section">
<input
type="text"
id="build-playlist-search"
placeholder="Search for artists (1-5 artists)..."
oninput="searchBuildPlaylistArtists()"
/>
<div id="build-playlist-search-results" class="build-playlist-search-results"></div>
</div>
<!-- Selected Artists -->
<div class="build-playlist-selected-section">
<h3>Selected Artists:</h3>
<div id="build-playlist-selected-artists" class="build-playlist-selected-artists">
<div class="build-playlist-no-selection">No artists selected. Search and select 1-5 artists.</div>
</div>
</div>
<!-- Generate Button -->
<div class="build-playlist-actions">
<button
id="build-playlist-generate-btn"
class="build-playlist-generate-btn"
onclick="generateBuildPlaylist()"
disabled
style="opacity: 0.5;"
>
Generate Playlist (50 tracks)
</button>
<div id="build-playlist-loading" class="build-playlist-loading" style="display: none;">
<div class="loading-spinner"></div>
<p>Building your playlist...</p>
</div>
</div>
<!-- Generated Playlist Results -->
<div id="build-playlist-results" class="discover-playlist-container compact">
<!-- Generated playlist will appear here -->
</div>
</div>
</div>
<!-- More Playlists Section -->
<div class="discover-section">
<div class="discover-section-header">

View file

@ -24715,6 +24715,9 @@ let personalizedForgottenFavorites = [];
let personalizedPopularPicks = [];
let personalizedHiddenGems = [];
let personalizedDailyMixes = [];
let personalizedDiscoveryShuffle = [];
let personalizedFamiliarFavorites = [];
let buildPlaylistSelectedArtists = [];
async function loadDiscoverPage() {
console.log('Loading discover page...');
@ -24732,6 +24735,8 @@ async function loadDiscoverPage() {
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
loadPersonalizedTopTracks(), // NEW: Your top tracks
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
loadFamiliarFavorites(), // NEW: Familiar Favorites
loadMoreForYou()
]);
@ -24799,6 +24804,33 @@ async function checkForActiveDiscoverSyncs() {
} catch (error) {
// Sync not active, ignore
}
// Check if Seasonal Playlist sync is active
try {
const seasonalResponse = await fetch('/api/sync/status/discover_seasonal_playlist');
if (seasonalResponse.ok) {
const data = await seasonalResponse.json();
if (data.status === 'syncing' || data.status === 'starting') {
console.log('🔄 Resuming Seasonal Playlist sync polling after page refresh');
const statusDisplay = document.getElementById('seasonal-playlist-sync-status');
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
const syncButton = document.getElementById('seasonal-playlist-sync-btn');
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
startDiscoverSyncPolling('seasonal_playlist', 'discover_seasonal_playlist');
}
}
} catch (error) {
// Sync not active, ignore
}
}
async function loadDiscoverHero() {
@ -25724,6 +25756,225 @@ function renderCompactPlaylist(container, tracks) {
container.innerHTML = html;
}
async function loadDiscoveryShuffle() {
try {
const container = document.getElementById('personalized-discovery-shuffle');
if (!container) return;
const response = await fetch('/api/discover/personalized/discovery-shuffle?limit=50');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedDiscoveryShuffle = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading discovery shuffle:', error);
}
}
async function loadFamiliarFavorites() {
try {
const container = document.getElementById('personalized-familiar-favorites');
if (!container) return;
const response = await fetch('/api/discover/personalized/familiar-favorites?limit=50');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedFamiliarFavorites = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading familiar favorites:', error);
}
}
// ===============================
// BUILD A PLAYLIST FEATURE
// ===============================
let buildPlaylistSearchTimeout = null;
async function searchBuildPlaylistArtists() {
const searchInput = document.getElementById('build-playlist-search');
const resultsContainer = document.getElementById('build-playlist-search-results');
const query = searchInput.value.trim();
if (!query) {
resultsContainer.innerHTML = '';
resultsContainer.style.display = 'none';
return;
}
// Debounce search
clearTimeout(buildPlaylistSearchTimeout);
buildPlaylistSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.artists || data.artists.length === 0) {
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found</div>';
resultsContainer.style.display = 'block';
return;
}
// Render search results
let html = '';
data.artists.forEach(artist => {
const imageUrl = artist.image_url || '/static/placeholder-album.png';
html += `
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${artist.name.replace(/'/g, "\\'")}', '${imageUrl}')">
<img src="${imageUrl}" alt="${artist.name}">
<span>${artist.name}</span>
</div>
`;
});
resultsContainer.innerHTML = html;
resultsContainer.style.display = 'block';
} catch (error) {
console.error('Error searching artists:', error);
}
}, 300);
}
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
// Check if already selected
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
alert('Artist already selected');
return;
}
// Check maximum limit
if (buildPlaylistSelectedArtists.length >= 5) {
alert('Maximum 5 artists allowed');
return;
}
// Add to selected artists
buildPlaylistSelectedArtists.push({
id: artistId,
name: artistName,
image_url: imageUrl
});
// Update UI
renderBuildPlaylistSelectedArtists();
// Clear search
document.getElementById('build-playlist-search').value = '';
document.getElementById('build-playlist-search-results').innerHTML = '';
document.getElementById('build-playlist-search-results').style.display = 'none';
}
function removeBuildPlaylistArtist(artistId) {
buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId);
renderBuildPlaylistSelectedArtists();
}
function renderBuildPlaylistSelectedArtists() {
const container = document.getElementById('build-playlist-selected-artists');
const generateBtn = document.getElementById('build-playlist-generate-btn');
if (buildPlaylistSelectedArtists.length === 0) {
container.innerHTML = '<div class="build-playlist-no-selection">No artists selected. Search and select 1-5 artists.</div>';
generateBtn.disabled = true;
generateBtn.style.opacity = '0.5';
return;
}
let html = '';
buildPlaylistSelectedArtists.forEach(artist => {
html += `
<div class="build-playlist-selected-artist">
<img src="${artist.image_url}" alt="${artist.name}">
<span>${artist.name}</span>
<button onclick="removeBuildPlaylistArtist('${artist.id}')" class="build-playlist-remove-artist">×</button>
</div>
`;
});
container.innerHTML = html;
generateBtn.disabled = false;
generateBtn.style.opacity = '1';
}
async function generateBuildPlaylist() {
if (buildPlaylistSelectedArtists.length === 0) {
alert('Please select at least 1 artist');
return;
}
const generateBtn = document.getElementById('build-playlist-generate-btn');
const resultsContainer = document.getElementById('build-playlist-results');
const loadingIndicator = document.getElementById('build-playlist-loading');
// Show loading
generateBtn.disabled = true;
generateBtn.style.opacity = '0.5';
loadingIndicator.style.display = 'block';
resultsContainer.innerHTML = '';
try {
const seedIds = buildPlaylistSelectedArtists.map(a => a.id);
const response = await fetch('/api/discover/build-playlist/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seed_artist_ids: seedIds,
playlist_size: 50
})
});
if (!response.ok) {
throw new Error('Failed to generate playlist');
}
const data = await response.json();
if (!data.success || !data.playlist || !data.playlist.tracks) {
throw new Error('Invalid playlist data');
}
// Render playlist
renderCompactPlaylist(resultsContainer, data.playlist.tracks);
// Show metadata
const metadata = data.playlist.metadata;
const metadataHtml = `
<div class="build-playlist-metadata">
<p><strong>Total Tracks:</strong> ${metadata.total_tracks}</p>
<p><strong>Similar Artists Used:</strong> ${metadata.similar_artists_count}</p>
<p><strong>Albums Sampled:</strong> ${metadata.albums_count}</p>
</div>
`;
resultsContainer.insertAdjacentHTML('beforebegin', metadataHtml);
} catch (error) {
console.error('Error generating playlist:', error);
resultsContainer.innerHTML = '<div class="error-message">Failed to generate playlist. Please try again.</div>';
} finally {
loadingIndicator.style.display = 'none';
generateBtn.disabled = false;
generateBtn.style.opacity = '1';
}
}
function openDailyMix(mixIndex) {
const mix = personalizedDailyMixes[mixIndex];
if (!mix || !mix.tracks) return;
@ -25746,6 +25997,22 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName)
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
} else if (playlistType === 'seasonal_playlist') {
tracks = discoverSeasonalTracks;
} else if (playlistType === 'popular_picks') {
tracks = personalizedPopularPicks;
} else if (playlistType === 'hidden_gems') {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'familiar_favorites') {
tracks = personalizedFamiliarFavorites;
} else if (playlistType === 'recently_added') {
tracks = personalizedRecentlyAdded;
} else if (playlistType === 'top_tracks') {
tracks = personalizedTopTracks;
} else if (playlistType === 'forgotten_favorites') {
tracks = personalizedForgottenFavorites;
}
if (!tracks || tracks.length === 0) {

View file

@ -17013,3 +17013,213 @@ body {
color: #666;
margin: 0;
}
/* ===============================
BUILD A PLAYLIST STYLES
=============================== */
.build-playlist-container {
background: #1a1a1a;
border-radius: 12px;
padding: 24px;
margin-top: 20px;
}
.build-playlist-search-section {
position: relative;
margin-bottom: 24px;
}
#build-playlist-search {
width: 100%;
padding: 12px 16px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
}
#build-playlist-search:focus {
border-color: #1db954;
}
.build-playlist-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
margin-top: 8px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.build-playlist-search-result {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid #3a3a3a;
}
.build-playlist-search-result:last-child {
border-bottom: none;
}
.build-playlist-search-result:hover {
background-color: #3a3a3a;
}
.build-playlist-search-result img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.build-playlist-search-result span {
color: #fff;
font-size: 14px;
}
.build-playlist-no-results {
padding: 16px;
text-align: center;
color: #999;
font-size: 14px;
}
.build-playlist-selected-section {
margin-bottom: 24px;
}
.build-playlist-selected-section h3 {
color: #fff;
font-size: 16px;
margin: 0 0 12px 0;
}
.build-playlist-selected-artists {
display: flex;
flex-wrap: wrap;
gap: 12px;
min-height: 60px;
}
.build-playlist-selected-artist {
display: flex;
align-items: center;
gap: 8px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
padding: 8px 12px;
}
.build-playlist-selected-artist img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.build-playlist-selected-artist span {
color: #fff;
font-size: 14px;
}
.build-playlist-remove-artist {
background: none;
border: none;
color: #ff4444;
font-size: 20px;
cursor: pointer;
padding: 0;
margin-left: 4px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.build-playlist-remove-artist:hover {
color: #ff6666;
}
.build-playlist-no-selection {
color: #999;
font-size: 14px;
padding: 16px;
text-align: center;
}
.build-playlist-actions {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.build-playlist-generate-btn {
background: #1db954;
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.build-playlist-generate-btn:not(:disabled):hover {
background: #1ed760;
transform: scale(1.02);
}
.build-playlist-generate-btn:disabled {
cursor: not-allowed;
}
.build-playlist-loading {
display: flex;
align-items: center;
gap: 12px;
}
.build-playlist-loading p {
color: #999;
font-size: 14px;
margin: 0;
}
.build-playlist-metadata {
background: #2a2a2a;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.build-playlist-metadata p {
color: #fff;
font-size: 13px;
margin: 0;
}
.build-playlist-metadata strong {
color: #1db954;
}