fix build a playlist functionality and update the ui

This commit is contained in:
Broque Thomas 2026-03-11 07:52:58 -07:00
parent e5450d9f89
commit 0aa8950436
5 changed files with 476 additions and 196 deletions

View file

@ -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)
}
}

View file

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

View file

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

View file

@ -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, '&lt;') + '"</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, '&quot;');
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';
}
}

View file

@ -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;
}
/* =======================