discover progress
This commit is contained in:
parent
68a15071f1
commit
f75ffdf6cb
6 changed files with 974 additions and 9 deletions
|
|
@ -323,6 +323,103 @@ class PersonalizedPlaylistsService:
|
||||||
logger.error(f"Error getting hidden gems: {e}")
|
logger.error(f"Error getting hidden gems: {e}")
|
||||||
return []
|
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)
|
# DAILY MIX (HYBRID PLAYLISTS)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
@ -531,6 +628,156 @@ class PersonalizedPlaylistsService:
|
||||||
logger.error(f"Error getting all daily mixes: {e}")
|
logger.error(f"Error getting all daily mixes: {e}")
|
||||||
return []
|
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
|
# Singleton instance
|
||||||
_personalized_playlists_instance = None
|
_personalized_playlists_instance = None
|
||||||
|
|
|
||||||
|
|
@ -622,10 +622,12 @@ class SeasonalDiscoveryService:
|
||||||
album_data = self.spotify_client.get_album(album['spotify_album_id'])
|
album_data = self.spotify_client.get_album(album['spotify_album_id'])
|
||||||
if album_data and 'tracks' in album_data:
|
if album_data and 'tracks' in album_data:
|
||||||
for track in album_data['tracks'].get('items', []):
|
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({
|
all_tracks.append({
|
||||||
'spotify_track_id': track['id'],
|
'spotify_track_id': track['id'],
|
||||||
'track_name': track['name'],
|
'track_name': track['name'],
|
||||||
'artist_name': album['artist_name'],
|
'artist_name': track_artist,
|
||||||
'album_name': album['album_name'],
|
'album_name': album['album_name'],
|
||||||
'popularity': album.get('popularity', 50)
|
'popularity': album.get('popularity', 50)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
103
web_server.py
103
web_server.py
|
|
@ -15007,6 +15007,109 @@ def get_daily_mixes():
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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'])
|
@app.route('/api/metadata/start', methods=['POST'])
|
||||||
def start_metadata_update():
|
def start_metadata_update():
|
||||||
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
||||||
|
|
|
||||||
152
webui/index.html
152
webui/index.html
|
|
@ -1727,14 +1727,30 @@
|
||||||
<p class="discover-section-subtitle" id="seasonal-playlist-subtitle">Curated seasonal playlist</p>
|
<p class="discover-section-subtitle" id="seasonal-playlist-subtitle">Curated seasonal playlist</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-section-actions">
|
<div class="discover-section-actions">
|
||||||
<button class="action-button secondary" onclick="openDownloadModalForSeasonalPlaylist()" title="Download missing tracks">
|
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('seasonal_playlist', 'Seasonal Mix')" title="Download missing tracks">
|
||||||
<span class="icon">📥</span> Download Missing
|
<span class="button-icon">↓</span>
|
||||||
|
<span class="button-text">Download</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-button secondary" onclick="syncSeasonalPlaylist()" title="Sync to library">
|
<button class="action-button primary" id="seasonal-playlist-sync-btn" onclick="startDiscoverPlaylistSync('seasonal_playlist', 'Seasonal Mix')" title="Sync to media server">
|
||||||
<span class="icon">🔄</span> Sync
|
<span class="button-icon">⟳</span>
|
||||||
|
<span class="button-text">Sync</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="discover-playlist-container compact" id="seasonal-playlist">
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Content will be populated dynamically -->
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1853,8 +1869,20 @@
|
||||||
<!-- Popular Picks Section -->
|
<!-- Popular Picks Section -->
|
||||||
<div class="discover-section" style="display: none;">
|
<div class="discover-section" style="display: none;">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<h2 class="discover-section-title">🔥 Popular Picks</h2>
|
<div>
|
||||||
<p class="discover-section-subtitle">Trending tracks from new discoveries</p>
|
<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>
|
||||||
<div class="discover-playlist-container compact" id="personalized-popular-picks">
|
<div class="discover-playlist-container compact" id="personalized-popular-picks">
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Content will be populated dynamically -->
|
||||||
|
|
@ -1864,8 +1892,20 @@
|
||||||
<!-- Hidden Gems Section -->
|
<!-- Hidden Gems Section -->
|
||||||
<div class="discover-section" style="display: none;">
|
<div class="discover-section" style="display: none;">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<h2 class="discover-section-title">🌟 Hidden Gems</h2>
|
<div>
|
||||||
<p class="discover-section-subtitle">Underground discoveries waiting for you</p>
|
<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>
|
||||||
<div class="discover-playlist-container compact" id="personalized-hidden-gems">
|
<div class="discover-playlist-container compact" id="personalized-hidden-gems">
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Content will be populated dynamically -->
|
||||||
|
|
@ -1894,6 +1934,102 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- More Playlists Section -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
|
|
|
||||||
|
|
@ -24715,6 +24715,9 @@ let personalizedForgottenFavorites = [];
|
||||||
let personalizedPopularPicks = [];
|
let personalizedPopularPicks = [];
|
||||||
let personalizedHiddenGems = [];
|
let personalizedHiddenGems = [];
|
||||||
let personalizedDailyMixes = [];
|
let personalizedDailyMixes = [];
|
||||||
|
let personalizedDiscoveryShuffle = [];
|
||||||
|
let personalizedFamiliarFavorites = [];
|
||||||
|
let buildPlaylistSelectedArtists = [];
|
||||||
|
|
||||||
async function loadDiscoverPage() {
|
async function loadDiscoverPage() {
|
||||||
console.log('Loading discover page...');
|
console.log('Loading discover page...');
|
||||||
|
|
@ -24732,6 +24735,8 @@ async function loadDiscoverPage() {
|
||||||
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
|
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
|
||||||
loadPersonalizedTopTracks(), // NEW: Your top tracks
|
loadPersonalizedTopTracks(), // NEW: Your top tracks
|
||||||
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
||||||
|
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||||||
|
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
||||||
loadMoreForYou()
|
loadMoreForYou()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -24799,6 +24804,33 @@ async function checkForActiveDiscoverSyncs() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Sync not active, ignore
|
// 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() {
|
async function loadDiscoverHero() {
|
||||||
|
|
@ -25724,6 +25756,225 @@ function renderCompactPlaylist(container, tracks) {
|
||||||
container.innerHTML = html;
|
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) {
|
function openDailyMix(mixIndex) {
|
||||||
const mix = personalizedDailyMixes[mixIndex];
|
const mix = personalizedDailyMixes[mixIndex];
|
||||||
if (!mix || !mix.tracks) return;
|
if (!mix || !mix.tracks) return;
|
||||||
|
|
@ -25746,6 +25997,22 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName)
|
||||||
tracks = discoverReleaseRadarTracks;
|
tracks = discoverReleaseRadarTracks;
|
||||||
} else if (playlistType === 'discovery_weekly') {
|
} else if (playlistType === 'discovery_weekly') {
|
||||||
tracks = discoverWeeklyTracks;
|
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) {
|
if (!tracks || tracks.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -17013,3 +17013,213 @@ body {
|
||||||
color: #666;
|
color: #666;
|
||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue