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}")
|
||||
return []
|
||||
|
||||
def get_discovery_shuffle(self, limit: int = 50) -> List[Dict]:
|
||||
"""
|
||||
Get random tracks from discovery pool - pure exploration.
|
||||
|
||||
Different every time you call it!
|
||||
"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity
|
||||
FROM discovery_pool
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discovery shuffle: {e}")
|
||||
return []
|
||||
|
||||
def get_familiar_favorites(self, limit: int = 50) -> List[Dict]:
|
||||
"""
|
||||
Get tracks with medium play counts (3-15 plays) - your reliable go-tos.
|
||||
|
||||
Not overplayed, not rare - just right!
|
||||
"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if play_count exists
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
columns = [row['name'] for row in cursor.fetchall()]
|
||||
|
||||
if 'play_count' not in columns:
|
||||
logger.warning("play_count column not found - using random older tracks")
|
||||
# Fallback: tracks added 30-90 days ago
|
||||
thirty_days_ago = (datetime.now() - timedelta(days=30)).isoformat()
|
||||
ninety_days_ago = (datetime.now() - timedelta(days=90)).isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
t.id,
|
||||
t.spotify_track_id,
|
||||
t.title as track_name,
|
||||
t.duration_ms,
|
||||
ar.name as artist_name,
|
||||
al.title as album_name,
|
||||
al.cover_url as album_cover_url,
|
||||
t.popularity
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||
LEFT JOIN albums al ON t.album_id = al.id
|
||||
WHERE t.spotify_track_id IS NOT NULL
|
||||
AND t.date_added BETWEEN ? AND ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", (ninety_days_ago, thirty_days_ago, limit))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
t.id,
|
||||
t.spotify_track_id,
|
||||
t.title as track_name,
|
||||
t.duration_ms,
|
||||
ar.name as artist_name,
|
||||
al.title as album_name,
|
||||
al.cover_url as album_cover_url,
|
||||
t.popularity,
|
||||
t.play_count
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||
LEFT JOIN albums al ON t.album_id = al.id
|
||||
WHERE t.spotify_track_id IS NOT NULL
|
||||
AND t.play_count BETWEEN 3 AND 15
|
||||
ORDER BY t.play_count DESC, RANDOM()
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting familiar favorites: {e}")
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# DAILY MIX (HYBRID PLAYLISTS)
|
||||
# ========================================
|
||||
|
|
@ -531,6 +628,156 @@ class PersonalizedPlaylistsService:
|
|||
logger.error(f"Error getting all daily mixes: {e}")
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# BUILD A PLAYLIST (CUSTOM GENERATOR)
|
||||
# ========================================
|
||||
|
||||
def build_custom_playlist(self, seed_artist_ids: List[str], playlist_size: int = 50) -> Dict[str, Any]:
|
||||
"""
|
||||
Build a custom playlist from seed artists.
|
||||
|
||||
Process:
|
||||
1. Get similar artists for each seed artist (max 25 total)
|
||||
2. Get albums from those similar artists
|
||||
3. Select 20 random albums
|
||||
4. Build playlist from tracks in those albums (max 50 tracks)
|
||||
|
||||
Args:
|
||||
seed_artist_ids: List of 1-5 Spotify artist IDs
|
||||
playlist_size: Maximum tracks in final playlist (default: 50)
|
||||
|
||||
Returns:
|
||||
Dict with playlist metadata and tracks
|
||||
"""
|
||||
try:
|
||||
if not seed_artist_ids or len(seed_artist_ids) > 5:
|
||||
logger.error(f"Invalid seed artists count: {len(seed_artist_ids)}")
|
||||
return {'tracks': [], 'error': 'Must provide 1-5 seed artists'}
|
||||
|
||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
||||
logger.error("Spotify client not available")
|
||||
return {'tracks': [], 'error': 'Spotify not authenticated'}
|
||||
|
||||
logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists")
|
||||
|
||||
# Step 1: Get similar artists for each seed
|
||||
all_similar_artists = []
|
||||
seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves
|
||||
|
||||
for seed_artist_id in seed_artist_ids:
|
||||
try:
|
||||
# Get similar artists from Spotify
|
||||
similar = self.spotify_client.get_similar_artists(seed_artist_id)
|
||||
|
||||
if similar:
|
||||
for artist in similar[:10]: # Max 10 per seed
|
||||
if artist.id not in seen_artist_ids:
|
||||
all_similar_artists.append(artist)
|
||||
seen_artist_ids.add(artist.id)
|
||||
|
||||
if len(all_similar_artists) >= 25:
|
||||
break
|
||||
|
||||
if len(all_similar_artists) >= 25:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(all_similar_artists)} similar artists")
|
||||
|
||||
if not all_similar_artists:
|
||||
return {'tracks': [], 'error': 'No similar artists found'}
|
||||
|
||||
# Limit to 25 similar artists
|
||||
similar_artists_to_use = all_similar_artists[:25]
|
||||
|
||||
# Step 2: Get albums from similar artists
|
||||
all_albums = []
|
||||
for artist in similar_artists_to_use:
|
||||
try:
|
||||
albums = self.spotify_client.get_artist_albums(
|
||||
artist.id,
|
||||
album_type='album,single',
|
||||
limit=10
|
||||
)
|
||||
|
||||
if albums:
|
||||
all_albums.extend(albums)
|
||||
|
||||
import time
|
||||
time.sleep(0.3) # Rate limiting
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting albums for {artist.name}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(all_albums)} total albums")
|
||||
|
||||
if not all_albums:
|
||||
return {'tracks': [], 'error': 'No albums found'}
|
||||
|
||||
# Step 3: Select 20 random albums
|
||||
random.shuffle(all_albums)
|
||||
selected_albums = all_albums[:20]
|
||||
|
||||
logger.info(f"Selected {len(selected_albums)} random albums")
|
||||
|
||||
# Step 4: Build playlist from tracks in those albums
|
||||
all_tracks = []
|
||||
for album in selected_albums:
|
||||
try:
|
||||
album_data = self.spotify_client.get_album(album.id)
|
||||
|
||||
if album_data and 'tracks' in album_data:
|
||||
tracks = album_data['tracks'].get('items', [])
|
||||
|
||||
for track in tracks:
|
||||
if track['id']:
|
||||
all_tracks.append({
|
||||
'spotify_track_id': track['id'],
|
||||
'track_name': track['name'],
|
||||
'artist_name': ', '.join([a['name'] for a in track.get('artists', [])]),
|
||||
'album_name': album_data.get('name', 'Unknown'),
|
||||
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
|
||||
'duration_ms': track.get('duration_ms', 0),
|
||||
'popularity': album_data.get('popularity', 0)
|
||||
})
|
||||
|
||||
import time
|
||||
time.sleep(0.3) # Rate limiting
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting tracks from album: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Collected {len(all_tracks)} total tracks")
|
||||
|
||||
if not all_tracks:
|
||||
return {'tracks': [], 'error': 'No tracks found'}
|
||||
|
||||
# Shuffle and limit to playlist_size
|
||||
random.shuffle(all_tracks)
|
||||
final_tracks = all_tracks[:playlist_size]
|
||||
|
||||
logger.info(f"Built custom playlist with {len(final_tracks)} tracks")
|
||||
|
||||
return {
|
||||
'name': 'Custom Playlist',
|
||||
'description': f'Built from {len(seed_artist_ids)} seed artists',
|
||||
'track_count': len(final_tracks),
|
||||
'tracks': final_tracks,
|
||||
'similar_artists_count': len(similar_artists_to_use),
|
||||
'albums_used': len(selected_albums)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error building custom playlist: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {'tracks': [], 'error': str(e)}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_personalized_playlists_instance = None
|
||||
|
|
|
|||
|
|
@ -622,10 +622,12 @@ class SeasonalDiscoveryService:
|
|||
album_data = self.spotify_client.get_album(album['spotify_album_id'])
|
||||
if album_data and 'tracks' in album_data:
|
||||
for track in album_data['tracks'].get('items', []):
|
||||
# Use track's actual artist, not album artist
|
||||
track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name']
|
||||
all_tracks.append({
|
||||
'spotify_track_id': track['id'],
|
||||
'track_name': track['name'],
|
||||
'artist_name': album['artist_name'],
|
||||
'artist_name': track_artist,
|
||||
'album_name': album['album_name'],
|
||||
'popularity': album.get('popularity', 50)
|
||||
})
|
||||
|
|
|
|||
103
web_server.py
103
web_server.py
|
|
@ -15007,6 +15007,109 @@ def get_daily_mixes():
|
|||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/personalized/discovery-shuffle', methods=['GET'])
|
||||
def get_discovery_shuffle():
|
||||
"""Get Discovery Shuffle playlist - random tracks from discovery pool"""
|
||||
try:
|
||||
from core.personalized_playlists import get_personalized_playlists_service
|
||||
|
||||
database = get_database()
|
||||
service = get_personalized_playlists_service(database, spotify_client)
|
||||
|
||||
limit = int(request.args.get('limit', 50))
|
||||
tracks = service.get_discovery_shuffle(limit=limit)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"tracks": tracks
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting discovery shuffle playlist: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/personalized/familiar-favorites', methods=['GET'])
|
||||
def get_familiar_favorites():
|
||||
"""Get Familiar Favorites playlist - reliable go-to tracks"""
|
||||
try:
|
||||
from core.personalized_playlists import get_personalized_playlists_service
|
||||
|
||||
database = get_database()
|
||||
service = get_personalized_playlists_service(database, spotify_client)
|
||||
|
||||
limit = int(request.args.get('limit', 50))
|
||||
tracks = service.get_familiar_favorites(limit=limit)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"tracks": tracks
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting familiar favorites playlist: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/build-playlist/search-artists', methods=['GET'])
|
||||
def search_artists_for_playlist():
|
||||
"""Search for artists to use as seeds for custom playlist building"""
|
||||
try:
|
||||
query = request.args.get('query', '').strip()
|
||||
if not query:
|
||||
return jsonify({"success": False, "error": "Query required"}), 400
|
||||
|
||||
# Search Spotify for artists
|
||||
results = spotify_client.sp.search(q=query, type='artist', limit=10)
|
||||
|
||||
artists = []
|
||||
if results and 'artists' in results and 'items' in results['artists']:
|
||||
for artist in results['artists']['items']:
|
||||
artists.append({
|
||||
'id': artist['id'],
|
||||
'name': artist['name'],
|
||||
'image_url': artist['images'][0]['url'] if artist.get('images') else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"artists": artists
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching for artists: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/build-playlist/generate', methods=['POST'])
|
||||
def generate_custom_playlist():
|
||||
"""Generate custom playlist from seed artists"""
|
||||
try:
|
||||
from core.personalized_playlists import get_personalized_playlists_service
|
||||
|
||||
data = request.get_json()
|
||||
seed_artist_ids = data.get('seed_artist_ids', [])
|
||||
|
||||
if not seed_artist_ids or len(seed_artist_ids) < 1 or len(seed_artist_ids) > 5:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Please provide between 1 and 5 seed artists"
|
||||
}), 400
|
||||
|
||||
database = get_database()
|
||||
service = get_personalized_playlists_service(database, spotify_client)
|
||||
|
||||
playlist_size = int(data.get('playlist_size', 50))
|
||||
result = service.build_custom_playlist(seed_artist_ids, playlist_size=playlist_size)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlist": result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating custom playlist: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/metadata/start', methods=['POST'])
|
||||
def start_metadata_update():
|
||||
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
||||
|
|
|
|||
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>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForSeasonalPlaylist()" title="Download missing tracks">
|
||||
<span class="icon">📥</span> Download Missing
|
||||
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('seasonal_playlist', 'Seasonal Mix')" title="Download missing tracks">
|
||||
<span class="button-icon">↓</span>
|
||||
<span class="button-text">Download</span>
|
||||
</button>
|
||||
<button class="action-button secondary" onclick="syncSeasonalPlaylist()" title="Sync to library">
|
||||
<span class="icon">🔄</span> Sync
|
||||
<button class="action-button primary" id="seasonal-playlist-sync-btn" onclick="startDiscoverPlaylistSync('seasonal_playlist', 'Seasonal Mix')" title="Sync to media server">
|
||||
<span class="button-icon">⟳</span>
|
||||
<span class="button-text">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sync Status Display -->
|
||||
<div class="discover-sync-status" id="seasonal-playlist-sync-status" style="display: none;">
|
||||
<div class="sync-status-content">
|
||||
<div class="sync-status-label">
|
||||
<span class="sync-icon">⟳</span>
|
||||
<span class="sync-text">Syncing Seasonal Mix...</span>
|
||||
</div>
|
||||
<div class="sync-status-progress">
|
||||
<div class="sync-progress-text">
|
||||
<span id="seasonal-playlist-sync-progress">0/0 tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-playlist-container compact" id="seasonal-playlist">
|
||||
<!-- Content will be populated dynamically -->
|
||||
</div>
|
||||
|
|
@ -1853,8 +1869,20 @@
|
|||
<!-- Popular Picks Section -->
|
||||
<div class="discover-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<h2 class="discover-section-title">🔥 Popular Picks</h2>
|
||||
<p class="discover-section-subtitle">Trending tracks from new discoveries</p>
|
||||
<div>
|
||||
<h2 class="discover-section-title">🔥 Popular Picks</h2>
|
||||
<p class="discover-section-subtitle">Trending tracks from new discoveries</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('popular_picks', 'Popular Picks')" title="Download missing tracks">
|
||||
<span class="button-icon">↓</span>
|
||||
<span class="button-text">Download</span>
|
||||
</button>
|
||||
<button class="action-button primary" id="popular-picks-sync-btn" onclick="startDiscoverPlaylistSync('popular_picks', 'Popular Picks')" title="Sync to media server">
|
||||
<span class="button-icon">⟳</span>
|
||||
<span class="button-text">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-playlist-container compact" id="personalized-popular-picks">
|
||||
<!-- Content will be populated dynamically -->
|
||||
|
|
@ -1864,8 +1892,20 @@
|
|||
<!-- Hidden Gems Section -->
|
||||
<div class="discover-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<h2 class="discover-section-title">🌟 Hidden Gems</h2>
|
||||
<p class="discover-section-subtitle">Underground discoveries waiting for you</p>
|
||||
<div>
|
||||
<h2 class="discover-section-title">🌟 Hidden Gems</h2>
|
||||
<p class="discover-section-subtitle">Underground discoveries waiting for you</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('hidden_gems', 'Hidden Gems')" title="Download missing tracks">
|
||||
<span class="button-icon">↓</span>
|
||||
<span class="button-text">Download</span>
|
||||
</button>
|
||||
<button class="action-button primary" id="hidden-gems-sync-btn" onclick="startDiscoverPlaylistSync('hidden_gems', 'Hidden Gems')" title="Sync to media server">
|
||||
<span class="button-icon">⟳</span>
|
||||
<span class="button-text">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-playlist-container compact" id="personalized-hidden-gems">
|
||||
<!-- Content will be populated dynamically -->
|
||||
|
|
@ -1894,6 +1934,102 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discovery Shuffle Section -->
|
||||
<div class="discover-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<div>
|
||||
<h2 class="discover-section-title">🔀 Discovery Shuffle</h2>
|
||||
<p class="discover-section-subtitle">Random tracks from your discovery pool - different every time</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('discovery_shuffle', 'Discovery Shuffle')" title="Download missing tracks">
|
||||
<span class="button-icon">↓</span>
|
||||
<span class="button-text">Download</span>
|
||||
</button>
|
||||
<button class="action-button primary" id="discovery-shuffle-sync-btn" onclick="startDiscoverPlaylistSync('discovery_shuffle', 'Discovery Shuffle')" title="Sync to media server">
|
||||
<span class="button-icon">⟳</span>
|
||||
<span class="button-text">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-playlist-container compact" id="personalized-discovery-shuffle">
|
||||
<!-- Content will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Familiar Favorites Section -->
|
||||
<div class="discover-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<div>
|
||||
<h2 class="discover-section-title">❤️ Familiar Favorites</h2>
|
||||
<p class="discover-section-subtitle">Your reliable go-to tracks</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('familiar_favorites', 'Familiar Favorites')" title="Download missing tracks">
|
||||
<span class="button-icon">↓</span>
|
||||
<span class="button-text">Download</span>
|
||||
</button>
|
||||
<button class="action-button primary" id="familiar-favorites-sync-btn" onclick="startDiscoverPlaylistSync('familiar_favorites', 'Familiar Favorites')" title="Sync to media server">
|
||||
<span class="button-icon">⟳</span>
|
||||
<span class="button-text">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-playlist-container compact" id="personalized-familiar-favorites">
|
||||
<!-- Content will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build a Playlist Section -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
<h2 class="discover-section-title">🎨 Build a Playlist</h2>
|
||||
<p class="discover-section-subtitle">Create a custom playlist from your favorite artists</p>
|
||||
</div>
|
||||
<div class="build-playlist-container">
|
||||
<!-- Artist Search -->
|
||||
<div class="build-playlist-search-section">
|
||||
<input
|
||||
type="text"
|
||||
id="build-playlist-search"
|
||||
placeholder="Search for artists (1-5 artists)..."
|
||||
oninput="searchBuildPlaylistArtists()"
|
||||
/>
|
||||
<div id="build-playlist-search-results" class="build-playlist-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Artists -->
|
||||
<div class="build-playlist-selected-section">
|
||||
<h3>Selected Artists:</h3>
|
||||
<div id="build-playlist-selected-artists" class="build-playlist-selected-artists">
|
||||
<div class="build-playlist-no-selection">No artists selected. Search and select 1-5 artists.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate Button -->
|
||||
<div class="build-playlist-actions">
|
||||
<button
|
||||
id="build-playlist-generate-btn"
|
||||
class="build-playlist-generate-btn"
|
||||
onclick="generateBuildPlaylist()"
|
||||
disabled
|
||||
style="opacity: 0.5;"
|
||||
>
|
||||
Generate Playlist (50 tracks)
|
||||
</button>
|
||||
<div id="build-playlist-loading" class="build-playlist-loading" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Building your playlist...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generated Playlist Results -->
|
||||
<div id="build-playlist-results" class="discover-playlist-container compact">
|
||||
<!-- Generated playlist will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- More Playlists Section -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
|
|
|
|||
|
|
@ -24715,6 +24715,9 @@ let personalizedForgottenFavorites = [];
|
|||
let personalizedPopularPicks = [];
|
||||
let personalizedHiddenGems = [];
|
||||
let personalizedDailyMixes = [];
|
||||
let personalizedDiscoveryShuffle = [];
|
||||
let personalizedFamiliarFavorites = [];
|
||||
let buildPlaylistSelectedArtists = [];
|
||||
|
||||
async function loadDiscoverPage() {
|
||||
console.log('Loading discover page...');
|
||||
|
|
@ -24732,6 +24735,8 @@ async function loadDiscoverPage() {
|
|||
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
|
||||
loadPersonalizedTopTracks(), // NEW: Your top tracks
|
||||
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
||||
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||||
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
||||
loadMoreForYou()
|
||||
]);
|
||||
|
||||
|
|
@ -24799,6 +24804,33 @@ async function checkForActiveDiscoverSyncs() {
|
|||
} catch (error) {
|
||||
// Sync not active, ignore
|
||||
}
|
||||
|
||||
// Check if Seasonal Playlist sync is active
|
||||
try {
|
||||
const seasonalResponse = await fetch('/api/sync/status/discover_seasonal_playlist');
|
||||
if (seasonalResponse.ok) {
|
||||
const data = await seasonalResponse.json();
|
||||
if (data.status === 'syncing' || data.status === 'starting') {
|
||||
console.log('🔄 Resuming Seasonal Playlist sync polling after page refresh');
|
||||
|
||||
const statusDisplay = document.getElementById('seasonal-playlist-sync-status');
|
||||
if (statusDisplay) {
|
||||
statusDisplay.style.display = 'block';
|
||||
}
|
||||
|
||||
const syncButton = document.getElementById('seasonal-playlist-sync-btn');
|
||||
if (syncButton) {
|
||||
syncButton.disabled = true;
|
||||
syncButton.style.opacity = '0.5';
|
||||
syncButton.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
startDiscoverSyncPolling('seasonal_playlist', 'discover_seasonal_playlist');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Sync not active, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscoverHero() {
|
||||
|
|
@ -25724,6 +25756,225 @@ function renderCompactPlaylist(container, tracks) {
|
|||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadDiscoveryShuffle() {
|
||||
try {
|
||||
const container = document.getElementById('personalized-discovery-shuffle');
|
||||
if (!container) return;
|
||||
|
||||
const response = await fetch('/api/discover/personalized/discovery-shuffle?limit=50');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||
container.closest('.discover-section').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
personalizedDiscoveryShuffle = data.tracks;
|
||||
renderCompactPlaylist(container, data.tracks);
|
||||
container.closest('.discover-section').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading discovery shuffle:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFamiliarFavorites() {
|
||||
try {
|
||||
const container = document.getElementById('personalized-familiar-favorites');
|
||||
if (!container) return;
|
||||
|
||||
const response = await fetch('/api/discover/personalized/familiar-favorites?limit=50');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||
container.closest('.discover-section').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
personalizedFamiliarFavorites = data.tracks;
|
||||
renderCompactPlaylist(container, data.tracks);
|
||||
container.closest('.discover-section').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading familiar favorites:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BUILD A PLAYLIST FEATURE
|
||||
// ===============================
|
||||
|
||||
let buildPlaylistSearchTimeout = null;
|
||||
|
||||
async function searchBuildPlaylistArtists() {
|
||||
const searchInput = document.getElementById('build-playlist-search');
|
||||
const resultsContainer = document.getElementById('build-playlist-search-results');
|
||||
const query = searchInput.value.trim();
|
||||
|
||||
if (!query) {
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
clearTimeout(buildPlaylistSearchTimeout);
|
||||
buildPlaylistSearchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.artists || data.artists.length === 0) {
|
||||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found</div>';
|
||||
resultsContainer.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render search results
|
||||
let html = '';
|
||||
data.artists.forEach(artist => {
|
||||
const imageUrl = artist.image_url || '/static/placeholder-album.png';
|
||||
html += `
|
||||
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${artist.name.replace(/'/g, "\\'")}', '${imageUrl}')">
|
||||
<img src="${imageUrl}" alt="${artist.name}">
|
||||
<span>${artist.name}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching artists:', error);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
|
||||
// Check if already selected
|
||||
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
|
||||
alert('Artist already selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maximum limit
|
||||
if (buildPlaylistSelectedArtists.length >= 5) {
|
||||
alert('Maximum 5 artists allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to selected artists
|
||||
buildPlaylistSelectedArtists.push({
|
||||
id: artistId,
|
||||
name: artistName,
|
||||
image_url: imageUrl
|
||||
});
|
||||
|
||||
// Update UI
|
||||
renderBuildPlaylistSelectedArtists();
|
||||
|
||||
// Clear search
|
||||
document.getElementById('build-playlist-search').value = '';
|
||||
document.getElementById('build-playlist-search-results').innerHTML = '';
|
||||
document.getElementById('build-playlist-search-results').style.display = 'none';
|
||||
}
|
||||
|
||||
function removeBuildPlaylistArtist(artistId) {
|
||||
buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId);
|
||||
renderBuildPlaylistSelectedArtists();
|
||||
}
|
||||
|
||||
function renderBuildPlaylistSelectedArtists() {
|
||||
const container = document.getElementById('build-playlist-selected-artists');
|
||||
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
||||
|
||||
if (buildPlaylistSelectedArtists.length === 0) {
|
||||
container.innerHTML = '<div class="build-playlist-no-selection">No artists selected. Search and select 1-5 artists.</div>';
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.style.opacity = '0.5';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
buildPlaylistSelectedArtists.forEach(artist => {
|
||||
html += `
|
||||
<div class="build-playlist-selected-artist">
|
||||
<img src="${artist.image_url}" alt="${artist.name}">
|
||||
<span>${artist.name}</span>
|
||||
<button onclick="removeBuildPlaylistArtist('${artist.id}')" class="build-playlist-remove-artist">×</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.style.opacity = '1';
|
||||
}
|
||||
|
||||
async function generateBuildPlaylist() {
|
||||
if (buildPlaylistSelectedArtists.length === 0) {
|
||||
alert('Please select at least 1 artist');
|
||||
return;
|
||||
}
|
||||
|
||||
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
||||
const resultsContainer = document.getElementById('build-playlist-results');
|
||||
const loadingIndicator = document.getElementById('build-playlist-loading');
|
||||
|
||||
// Show loading
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.style.opacity = '0.5';
|
||||
loadingIndicator.style.display = 'block';
|
||||
resultsContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const seedIds = buildPlaylistSelectedArtists.map(a => a.id);
|
||||
const response = await fetch('/api/discover/build-playlist/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
seed_artist_ids: seedIds,
|
||||
playlist_size: 50
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate playlist');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.playlist || !data.playlist.tracks) {
|
||||
throw new Error('Invalid playlist data');
|
||||
}
|
||||
|
||||
// Render playlist
|
||||
renderCompactPlaylist(resultsContainer, data.playlist.tracks);
|
||||
|
||||
// Show metadata
|
||||
const metadata = data.playlist.metadata;
|
||||
const metadataHtml = `
|
||||
<div class="build-playlist-metadata">
|
||||
<p><strong>Total Tracks:</strong> ${metadata.total_tracks}</p>
|
||||
<p><strong>Similar Artists Used:</strong> ${metadata.similar_artists_count}</p>
|
||||
<p><strong>Albums Sampled:</strong> ${metadata.albums_count}</p>
|
||||
</div>
|
||||
`;
|
||||
resultsContainer.insertAdjacentHTML('beforebegin', metadataHtml);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating playlist:', error);
|
||||
resultsContainer.innerHTML = '<div class="error-message">Failed to generate playlist. Please try again.</div>';
|
||||
} finally {
|
||||
loadingIndicator.style.display = 'none';
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function openDailyMix(mixIndex) {
|
||||
const mix = personalizedDailyMixes[mixIndex];
|
||||
if (!mix || !mix.tracks) return;
|
||||
|
|
@ -25746,6 +25997,22 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName)
|
|||
tracks = discoverReleaseRadarTracks;
|
||||
} else if (playlistType === 'discovery_weekly') {
|
||||
tracks = discoverWeeklyTracks;
|
||||
} else if (playlistType === 'seasonal_playlist') {
|
||||
tracks = discoverSeasonalTracks;
|
||||
} else if (playlistType === 'popular_picks') {
|
||||
tracks = personalizedPopularPicks;
|
||||
} else if (playlistType === 'hidden_gems') {
|
||||
tracks = personalizedHiddenGems;
|
||||
} else if (playlistType === 'discovery_shuffle') {
|
||||
tracks = personalizedDiscoveryShuffle;
|
||||
} else if (playlistType === 'familiar_favorites') {
|
||||
tracks = personalizedFamiliarFavorites;
|
||||
} else if (playlistType === 'recently_added') {
|
||||
tracks = personalizedRecentlyAdded;
|
||||
} else if (playlistType === 'top_tracks') {
|
||||
tracks = personalizedTopTracks;
|
||||
} else if (playlistType === 'forgotten_favorites') {
|
||||
tracks = personalizedForgottenFavorites;
|
||||
}
|
||||
|
||||
if (!tracks || tracks.length === 0) {
|
||||
|
|
|
|||
|
|
@ -17013,3 +17013,213 @@ body {
|
|||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
BUILD A PLAYLIST STYLES
|
||||
=============================== */
|
||||
|
||||
.build-playlist-container {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.build-playlist-search-section {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#build-playlist-search {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
#build-playlist-search:focus {
|
||||
border-color: #1db954;
|
||||
}
|
||||
|
||||
.build-playlist-search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.build-playlist-search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.build-playlist-search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.build-playlist-search-result:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.build-playlist-search-result img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.build-playlist-search-result span {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.build-playlist-no-results {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.build-playlist-selected-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.build-playlist-selected-section h3 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artists {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artist {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artist img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.build-playlist-selected-artist span {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.build-playlist-remove-artist {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff4444;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.build-playlist-remove-artist:hover {
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.build-playlist-no-selection {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.build-playlist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.build-playlist-generate-btn {
|
||||
background: #1db954;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.build-playlist-generate-btn:not(:disabled):hover {
|
||||
background: #1ed760;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.build-playlist-generate-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.build-playlist-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.build-playlist-loading p {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-playlist-metadata {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.build-playlist-metadata p {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-playlist-metadata strong {
|
||||
color: #1db954;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue