fix build a playlist functionality and update the ui
This commit is contained in:
parent
e5450d9f89
commit
0aa8950436
5 changed files with 476 additions and 196 deletions
|
|
@ -860,7 +860,7 @@ class PersonalizedPlaylistsService:
|
|||
4. Build playlist from tracks in those albums (max 50 tracks)
|
||||
|
||||
Args:
|
||||
seed_artist_ids: List of 1-5 Spotify artist IDs
|
||||
seed_artist_ids: List of 1-5 artist IDs (Spotify or iTunes)
|
||||
playlist_size: Maximum tracks in final playlist (default: 50)
|
||||
|
||||
Returns:
|
||||
|
|
@ -871,19 +871,17 @@ class PersonalizedPlaylistsService:
|
|||
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'}
|
||||
use_spotify = self.spotify_client and self.spotify_client.sp
|
||||
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists (source: {'spotify' if use_spotify else 'itunes'})")
|
||||
|
||||
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists")
|
||||
|
||||
# Step 1: Get similar artists for each seed from database
|
||||
# Step 1: Get similar artists for each seed
|
||||
all_similar_artists = []
|
||||
seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves
|
||||
seen_artist_ids = set(seed_artist_ids)
|
||||
|
||||
for seed_artist_id in seed_artist_ids:
|
||||
try:
|
||||
# Get similar artists from database (cached from MusicMap)
|
||||
# Try database first (cached from MusicMap/watchlist scans)
|
||||
db_results = []
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
|
|
@ -893,23 +891,32 @@ class PersonalizedPlaylistsService:
|
|||
ORDER BY similarity_rank ASC
|
||||
LIMIT 10
|
||||
""", (seed_artist_id,))
|
||||
db_results = cursor.fetchall()
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
if db_results:
|
||||
for row in db_results:
|
||||
artist_id = row['similar_artist_spotify_id']
|
||||
artist_name = row['similar_artist_name']
|
||||
|
||||
if artist_id not in seen_artist_ids:
|
||||
# Create artist-like object
|
||||
all_similar_artists.append({
|
||||
'id': artist_id,
|
||||
'name': artist_name
|
||||
})
|
||||
if artist_id and artist_id not in seen_artist_ids:
|
||||
all_similar_artists.append({'id': artist_id, 'name': artist_name})
|
||||
seen_artist_ids.add(artist_id)
|
||||
|
||||
if len(all_similar_artists) >= 25:
|
||||
break
|
||||
elif use_spotify:
|
||||
# Fallback: fetch related artists from Spotify API
|
||||
logger.info(f"No cached similar artists for {seed_artist_id}, trying Spotify related artists API")
|
||||
try:
|
||||
related = self.spotify_client.sp.artist_related_artists(seed_artist_id)
|
||||
if related and 'artists' in related:
|
||||
for artist in related['artists'][:10]:
|
||||
artist_id = artist['id']
|
||||
if artist_id not in seen_artist_ids:
|
||||
all_similar_artists.append({'id': artist_id, 'name': artist['name']})
|
||||
seen_artist_ids.add(artist_id)
|
||||
if len(all_similar_artists) >= 25:
|
||||
break
|
||||
except Exception as e2:
|
||||
logger.warning(f"Spotify related artists fallback failed for {seed_artist_id}: {e2}")
|
||||
|
||||
if len(all_similar_artists) >= 25:
|
||||
break
|
||||
|
|
@ -918,38 +925,49 @@ class PersonalizedPlaylistsService:
|
|||
logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(all_similar_artists)} similar artists from database")
|
||||
logger.info(f"Found {len(all_similar_artists)} similar artists")
|
||||
|
||||
if not all_similar_artists:
|
||||
return {'tracks': [], 'error': 'No similar artists found'}
|
||||
# Always include seed artists alongside similar artists
|
||||
# so the playlist has tracks from both the selected and discovered artists
|
||||
artists_for_albums = [{'id': sid, 'name': '', 'is_seed': True} for sid in seed_artist_ids]
|
||||
for sa in all_similar_artists[:22]: # Cap similar to leave room for seeds
|
||||
artists_for_albums.append({**sa, 'is_seed': False})
|
||||
|
||||
# Limit to 25 similar artists
|
||||
similar_artists_to_use = all_similar_artists[:25]
|
||||
|
||||
# Step 2: Get albums from similar artists
|
||||
# Step 2: Get albums from seed + 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
|
||||
if use_spotify:
|
||||
for artist in artists_for_albums:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}")
|
||||
continue
|
||||
else:
|
||||
from core.itunes_client import iTunesClient
|
||||
itunes = iTunesClient()
|
||||
for artist in artists_for_albums:
|
||||
try:
|
||||
albums = itunes.get_artist_albums(artist['id'], limit=10)
|
||||
if albums:
|
||||
all_albums.extend(albums)
|
||||
import time
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting albums for {artist.get('name', artist['id'])}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(all_albums)} total albums")
|
||||
|
||||
if not all_albums:
|
||||
return {'tracks': [], 'error': 'No albums found'}
|
||||
return {'tracks': [], 'error': 'No albums found for the selected artists'}
|
||||
|
||||
# Step 3: Select 20 random albums
|
||||
random.shuffle(all_albums)
|
||||
|
|
@ -959,40 +977,73 @@ class PersonalizedPlaylistsService:
|
|||
|
||||
# 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']:
|
||||
# Format in discovery pool format (for rendering + modal compatibility)
|
||||
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),
|
||||
# Also include Spotify format fields for modal
|
||||
'id': track['id'],
|
||||
'name': track['name'],
|
||||
'artists': [a['name'] for a in track.get('artists', [])],
|
||||
'album': {
|
||||
'name': album_data.get('name', 'Unknown'),
|
||||
'images': album_data.get('images', [])
|
||||
}
|
||||
})
|
||||
|
||||
import time
|
||||
time.sleep(0.3) # Rate limiting
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting tracks from album: {e}")
|
||||
continue
|
||||
if use_spotify:
|
||||
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),
|
||||
'id': track['id'],
|
||||
'name': track['name'],
|
||||
'artists': [a['name'] for a in track.get('artists', [])],
|
||||
'album': {
|
||||
'name': album_data.get('name', 'Unknown'),
|
||||
'images': album_data.get('images', [])
|
||||
}
|
||||
})
|
||||
import time
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting tracks from album: {e}")
|
||||
continue
|
||||
else:
|
||||
from core.itunes_client import iTunesClient
|
||||
itunes = iTunesClient()
|
||||
for album in selected_albums:
|
||||
try:
|
||||
album_data = itunes.get_album(album.id, include_tracks=True)
|
||||
if album_data and 'tracks' in album_data:
|
||||
tracks = album_data['tracks'].get('items', [])
|
||||
album_name = album_data.get('name', 'Unknown')
|
||||
album_images = album_data.get('images', [])
|
||||
album_cover = album_images[0].get('url') if album_images else None
|
||||
for track in tracks:
|
||||
track_id = track.get('id', '')
|
||||
if track_id:
|
||||
# iTunes artists are [{'name': '...'}] dicts
|
||||
track_artists = track.get('artists', [])
|
||||
artist_names = [a['name'] for a in track_artists] if isinstance(track_artists, list) and track_artists and isinstance(track_artists[0], dict) else (track_artists if isinstance(track_artists, list) else [])
|
||||
all_tracks.append({
|
||||
'spotify_track_id': track_id,
|
||||
'track_name': track.get('name', ''),
|
||||
'artist_name': ', '.join(artist_names) if artist_names else 'Unknown',
|
||||
'album_name': album_name,
|
||||
'album_cover_url': album_cover,
|
||||
'duration_ms': track.get('duration_ms', 0),
|
||||
'popularity': 0,
|
||||
'id': track_id,
|
||||
'name': track.get('name', ''),
|
||||
'artists': artist_names,
|
||||
'album': {
|
||||
'name': album_name,
|
||||
'images': album_images
|
||||
}
|
||||
})
|
||||
import time
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting tracks from album: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Collected {len(all_tracks)} total tracks")
|
||||
|
||||
|
|
@ -1012,7 +1063,7 @@ class PersonalizedPlaylistsService:
|
|||
'tracks': final_tracks,
|
||||
'metadata': {
|
||||
'total_tracks': len(final_tracks),
|
||||
'similar_artists_count': len(similar_artists_to_use),
|
||||
'similar_artists_count': len(all_similar_artists),
|
||||
'albums_count': len(selected_albums)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28498,15 +28498,37 @@ def search_artists_for_playlist():
|
|||
if hydrabase_worker and dev_mode_enabled:
|
||||
hydrabase_worker.enqueue(query, 'artists')
|
||||
|
||||
search_query = f'artist:{query}' if len(query.strip()) <= 4 else query
|
||||
results = spotify_client.sp.search(q=search_query, type='artist', limit=10)
|
||||
if results and 'artists' in results and 'items' in results['artists']:
|
||||
for artist in results['artists']['items']:
|
||||
# Try Spotify first, fall back to iTunes
|
||||
if spotify_client.sp:
|
||||
try:
|
||||
search_query = f'artist:{query}' if len(query.strip()) <= 4 else query
|
||||
results = spotify_client.sp.search(q=search_query, type='artist', limit=10)
|
||||
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
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Spotify artist search failed, falling back to iTunes: {e}")
|
||||
|
||||
if not artists:
|
||||
from core.itunes_client import iTunesClient
|
||||
itunes = iTunesClient()
|
||||
artist_objs = itunes.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
# iTunes artist search rarely returns images — grab from album art
|
||||
image = artist.image_url
|
||||
if not image:
|
||||
image = itunes._get_artist_image_from_albums(artist.id)
|
||||
artists.append({
|
||||
'id': artist['id'],
|
||||
'name': artist['name'],
|
||||
'image_url': artist['images'][0]['url'] if artist.get('images') else None
|
||||
'id': artist.id,
|
||||
'name': artist.name,
|
||||
'image_url': image
|
||||
})
|
||||
|
||||
if artists:
|
||||
# Re-rank: boost exact name matches to the top
|
||||
query_lower = query.lower().strip()
|
||||
artists.sort(key=lambda a: (0 if a['name'].lower().strip() == query_lower else 1))
|
||||
|
|
@ -28541,6 +28563,9 @@ def generate_custom_playlist():
|
|||
playlist_size = int(data.get('playlist_size', 50))
|
||||
result = service.build_custom_playlist(seed_artist_ids, playlist_size=playlist_size)
|
||||
|
||||
if result.get('error') and not result.get('tracks'):
|
||||
return jsonify({"success": False, "error": result['error']}), 400
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlist": result
|
||||
|
|
|
|||
|
|
@ -3006,36 +3006,63 @@
|
|||
<!-- Build a Playlist Section -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
<h2 class="discover-section-title">🎨 Build a Playlist</h2>
|
||||
<h2 class="discover-section-title">
|
||||
Build a Playlist
|
||||
<span class="bp-info-toggle" onclick="document.getElementById('bp-info-panel').classList.toggle('visible')" title="How it works">?</span>
|
||||
</h2>
|
||||
<p class="discover-section-subtitle">Create a custom playlist from your favorite artists</p>
|
||||
</div>
|
||||
<div class="bp-info-panel" id="bp-info-panel">
|
||||
<div class="bp-info-content">
|
||||
<p><strong>How it works:</strong></p>
|
||||
<ol>
|
||||
<li>Search and select 1-5 seed artists you like</li>
|
||||
<li>Hit Generate — the app finds similar artists, pulls their albums, and picks tracks at random</li>
|
||||
<li>You get a fresh 50-track playlist mixing your picks with new discoveries</li>
|
||||
</ol>
|
||||
<p class="bp-info-note">Tip: The more seed artists you add, the more varied the playlist will be.</p>
|
||||
</div>
|
||||
</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 class="bp-search-input-wrapper">
|
||||
<svg class="bp-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input type="text" id="build-playlist-search"
|
||||
placeholder="Search for an artist..."
|
||||
oninput="searchBuildPlaylistArtists()"
|
||||
autocomplete="off" />
|
||||
<div class="bp-search-spinner" id="bp-search-spinner" style="display: none;">
|
||||
<div class="loading-spinner" style="width: 18px; height: 18px; border-width: 2px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="bp-selected-header">
|
||||
<h3>Seed Artists</h3>
|
||||
<span class="bp-selected-counter" id="bp-selected-counter">0 / 5</span>
|
||||
</div>
|
||||
<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 class="build-playlist-no-selection">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; opacity: 0.4; margin-bottom: 8px;"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
<span>Search above to add seed artists</span>
|
||||
</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)
|
||||
onclick="generateBuildPlaylist()" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Generate Playlist
|
||||
</button>
|
||||
<div id="build-playlist-loading" class="build-playlist-loading" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Building your playlist...</p>
|
||||
<span>Finding similar artists and building your playlist...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -43707,36 +43707,53 @@ let buildPlaylistSearchTimeout = null;
|
|||
async function searchBuildPlaylistArtists() {
|
||||
const searchInput = document.getElementById('build-playlist-search');
|
||||
const resultsContainer = document.getElementById('build-playlist-search-results');
|
||||
const spinner = document.getElementById('bp-search-spinner');
|
||||
const query = searchInput.value.trim();
|
||||
|
||||
if (!query) {
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsContainer.style.display = 'none';
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
clearTimeout(buildPlaylistSearchTimeout);
|
||||
buildPlaylistSearchTimeout = setTimeout(async () => {
|
||||
if (spinner) spinner.style.display = 'flex';
|
||||
try {
|
||||
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
showToast(data.error || 'Search failed', 'error');
|
||||
return;
|
||||
}
|
||||
if (!data.success || !data.artists || data.artists.length === 0) {
|
||||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found</div>';
|
||||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found for "' + query.replace(/</g, '<') + '"</div>';
|
||||
resultsContainer.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out already-selected artists
|
||||
const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id));
|
||||
const filtered = data.artists.filter(a => !selectedIds.has(a.id));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">All results already selected</div>';
|
||||
resultsContainer.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render search results
|
||||
let html = '';
|
||||
data.artists.forEach(artist => {
|
||||
filtered.forEach(artist => {
|
||||
const imageUrl = artist.image_url || '/static/placeholder-album.png';
|
||||
const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
html += `
|
||||
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${artist.name.replace(/'/g, "\\'")}', '${imageUrl}')">
|
||||
<img src="${imageUrl}" alt="${artist.name}" loading="lazy">
|
||||
<span>${artist.name}</span>
|
||||
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${escapedName}', '${imageUrl}')">
|
||||
<img src="${imageUrl}" alt="${artist.name}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||||
<span class="bp-result-name">${artist.name}</span>
|
||||
<span class="bp-result-add">+ Add</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
|
@ -43746,31 +43763,28 @@ async function searchBuildPlaylistArtists() {
|
|||
|
||||
} catch (error) {
|
||||
console.error('Error searching artists:', error);
|
||||
} finally {
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
}
|
||||
}, 300);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
|
||||
// Check if already selected
|
||||
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
|
||||
alert('Artist already selected');
|
||||
showToast('Artist already selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maximum limit
|
||||
if (buildPlaylistSelectedArtists.length >= 5) {
|
||||
alert('Maximum 5 artists allowed');
|
||||
showToast('Maximum 5 seed artists', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to selected artists
|
||||
buildPlaylistSelectedArtists.push({
|
||||
id: artistId,
|
||||
name: artistName,
|
||||
image_url: imageUrl
|
||||
});
|
||||
|
||||
// Update UI
|
||||
renderBuildPlaylistSelectedArtists();
|
||||
|
||||
// Clear search
|
||||
|
|
@ -43787,35 +43801,42 @@ function removeBuildPlaylistArtist(artistId) {
|
|||
function renderBuildPlaylistSelectedArtists() {
|
||||
const container = document.getElementById('build-playlist-selected-artists');
|
||||
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
||||
const counter = document.getElementById('bp-selected-counter');
|
||||
const count = buildPlaylistSelectedArtists.length;
|
||||
|
||||
if (buildPlaylistSelectedArtists.length === 0) {
|
||||
container.innerHTML = '<div class="build-playlist-no-selection">No artists selected. Search and select 1-5 artists.</div>';
|
||||
if (counter) counter.textContent = `${count} / 5`;
|
||||
|
||||
if (count === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="build-playlist-no-selection">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; opacity: 0.4; margin-bottom: 8px;"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
<span>Search above to add seed artists</span>
|
||||
</div>`;
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.style.opacity = '0.5';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
buildPlaylistSelectedArtists.forEach(artist => {
|
||||
const escapedId = artist.id.replace(/'/g, "\\'");
|
||||
html += `
|
||||
<div class="build-playlist-selected-artist">
|
||||
<img src="${artist.image_url}" alt="${artist.name}" loading="lazy">
|
||||
<img src="${artist.image_url || '/static/placeholder-album.png'}" alt="${artist.name}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||||
<span>${artist.name}</span>
|
||||
<button onclick="removeBuildPlaylistArtist('${artist.id}')" class="build-playlist-remove-artist">×</button>
|
||||
<button onclick="removeBuildPlaylistArtist('${escapedId}')" class="build-playlist-remove-artist" title="Remove">×</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.style.opacity = '1';
|
||||
}
|
||||
|
||||
let buildPlaylistTracks = [];
|
||||
|
||||
async function generateBuildPlaylist() {
|
||||
if (buildPlaylistSelectedArtists.length === 0) {
|
||||
alert('Please select at least 1 artist');
|
||||
showToast('Please select at least 1 artist', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -43827,9 +43848,8 @@ async function generateBuildPlaylist() {
|
|||
const titleEl = document.getElementById('build-playlist-results-title');
|
||||
const subtitleEl = document.getElementById('build-playlist-results-subtitle');
|
||||
|
||||
// Show loading
|
||||
// Show loading, hide search area
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.style.opacity = '0.5';
|
||||
loadingIndicator.style.display = 'flex';
|
||||
resultsWrapper.style.display = 'none';
|
||||
resultsContainer.innerHTML = '';
|
||||
|
|
@ -43845,13 +43865,12 @@ async function generateBuildPlaylist() {
|
|||
})
|
||||
});
|
||||
|
||||
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');
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to generate playlist');
|
||||
}
|
||||
if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) {
|
||||
throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.');
|
||||
}
|
||||
|
||||
// Store tracks globally
|
||||
|
|
@ -43866,9 +43885,18 @@ async function generateBuildPlaylist() {
|
|||
const metadata = data.playlist.metadata;
|
||||
metadataDisplay.innerHTML = `
|
||||
<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 class="bp-meta-stat">
|
||||
<span class="bp-meta-value">${metadata.total_tracks}</span>
|
||||
<span class="bp-meta-label">Tracks</span>
|
||||
</div>
|
||||
<div class="bp-meta-stat">
|
||||
<span class="bp-meta-value">${metadata.similar_artists_count}</span>
|
||||
<span class="bp-meta-label">Similar Artists</span>
|
||||
</div>
|
||||
<div class="bp-meta-stat">
|
||||
<span class="bp-meta-value">${metadata.albums_count}</span>
|
||||
<span class="bp-meta-label">Albums Sampled</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -43880,11 +43908,11 @@ async function generateBuildPlaylist() {
|
|||
|
||||
} catch (error) {
|
||||
console.error('Error generating playlist:', error);
|
||||
resultsContainer.innerHTML = '<div class="error-message">Failed to generate playlist. Please try again.</div>';
|
||||
resultsWrapper.style.display = 'none';
|
||||
showToast(error.message || 'Failed to generate playlist', 'error');
|
||||
} finally {
|
||||
loadingIndicator.style.display = 'none';
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23956,65 +23956,150 @@ body {
|
|||
BUILD A PLAYLIST STYLES
|
||||
=============================== */
|
||||
|
||||
/* ---- Build a Playlist ---- */
|
||||
|
||||
.bp-info-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
color: rgb(var(--accent-rgb));
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
transition: background 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
.bp-info-toggle:hover {
|
||||
background: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.bp-info-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.bp-info-panel.visible {
|
||||
max-height: 260px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.15);
|
||||
}
|
||||
.bp-info-content p {
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.bp-info-content p strong {
|
||||
color: #fff;
|
||||
}
|
||||
.bp-info-content ol {
|
||||
margin: 0 0 8px 0;
|
||||
padding-left: 20px;
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.bp-info-note {
|
||||
color: #888 !important;
|
||||
font-size: 12px !important;
|
||||
font-style: italic;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.build-playlist-container {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-top: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* -- Search Input -- */
|
||||
.build-playlist-search-section {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bp-search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.bp-search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bp-search-spinner {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#build-playlist-search {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
padding: 12px 42px 12px 40px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
#build-playlist-search::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#build-playlist-search:focus {
|
||||
border-color: rgb(var(--accent-rgb));
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* -- Search Results Dropdown -- */
|
||||
.build-playlist-search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #2a2a2a;
|
||||
background: #222;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
max-height: 300px;
|
||||
border-radius: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.build-playlist-search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
transition: background-color 0.15s ease;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.build-playlist-search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.build-playlist-search-result:hover {
|
||||
background-color: #3a3a3a;
|
||||
background-color: rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
.build-playlist-search-result:hover .bp-result-add {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.build-playlist-search-result img {
|
||||
|
|
@ -24022,35 +24107,72 @@ body {
|
|||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.build-playlist-search-result span {
|
||||
.bp-result-name {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bp-result-add {
|
||||
color: rgb(var(--accent-rgb));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.build-playlist-no-results {
|
||||
padding: 16px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- Selected Artists -- */
|
||||
.build-playlist-selected-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.build-playlist-selected-section h3 {
|
||||
.bp-selected-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bp-selected-header h3 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.bp-selected-counter {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artists {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-height: 60px;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
padding: 12px;
|
||||
background: #151515;
|
||||
border: 1px dashed #333;
|
||||
border-radius: 10px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.build-playlist-selected-artists:has(.build-playlist-selected-artist) {
|
||||
border-style: solid;
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artist {
|
||||
|
|
@ -24059,74 +24181,91 @@ body {
|
|||
gap: 8px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
padding: 6px 10px 6px 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.build-playlist-selected-artist:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artist img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artist span {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.build-playlist-remove-artist {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff4444;
|
||||
font-size: 20px;
|
||||
color: #777;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: 50%;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.build-playlist-remove-artist:hover {
|
||||
color: #ff6666;
|
||||
color: #ff5555;
|
||||
background: rgba(255,85,85,0.1);
|
||||
}
|
||||
|
||||
.build-playlist-no-selection {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 16px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -- Generate Button & Loading -- */
|
||||
.build-playlist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.build-playlist-generate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgb(var(--accent-rgb));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.build-playlist-generate-btn:not(:disabled):hover {
|
||||
background: rgb(var(--accent-light-rgb));
|
||||
transform: scale(1.02);
|
||||
filter: brightness(1.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.build-playlist-generate-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
|
@ -24135,31 +24274,41 @@ body {
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.build-playlist-loading p {
|
||||
.build-playlist-loading span {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- Metadata Stats -- */
|
||||
.build-playlist-metadata {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.build-playlist-metadata p {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
.bp-meta-stat {
|
||||
background: #222;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.build-playlist-metadata strong {
|
||||
.bp-meta-value {
|
||||
display: block;
|
||||
color: rgb(var(--accent-rgb));
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bp-meta-label {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
|
|
|
|||
Loading…
Reference in a new issue