From 68a15071f1fc98e6df18f61a49ba8ed15bea4777 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 16 Nov 2025 15:33:08 -0800 Subject: [PATCH] discover page --- core/personalized_playlists.py | 543 +++++++++++++++++++++++++++++++++ web_server.py | 147 +++++++++ webui/index.html | 66 ++++ webui/static/script.js | 218 ++++++++++++- 4 files changed, 973 insertions(+), 1 deletion(-) create mode 100644 core/personalized_playlists.py diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py new file mode 100644 index 00000000..42306523 --- /dev/null +++ b/core/personalized_playlists.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 + +""" +Personalized Playlists Service - Creates Spotify-quality personalized playlists +from user's library and discovery pool +""" + +from typing import List, Dict, Any, Optional, Tuple +from datetime import datetime, timedelta +from collections import Counter +import random +from utils.logging_config import get_logger + +logger = get_logger("personalized_playlists") + +class PersonalizedPlaylistsService: + """Service for generating personalized playlists from library and discovery pool""" + + def __init__(self, database, spotify_client=None): + self.database = database + self.spotify_client = spotify_client + + # ======================================== + # LIBRARY-BASED PLAYLISTS + # ======================================== + + def get_recently_added(self, limit: int = 50) -> List[Dict]: + """ + Get recently added tracks from library. + + Returns tracks ordered by date_added DESC + """ + try: + with self.database._get_connection() as conn: + cursor = conn.cursor() + + 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.date_added + 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 + ORDER BY t.date_added DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting recently added tracks: {e}") + return [] + + def get_top_tracks(self, limit: int = 50) -> List[Dict]: + """ + Get user's all-time top tracks based on play count. + + Note: Requires play_count column in tracks table + """ + try: + with self.database._get_connection() as conn: + cursor = conn.cursor() + + # Check if play_count column 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 selection") + # Fallback: return random tracks + 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, + 0 as 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 + ORDER BY RANDOM() + LIMIT ? + """, (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 + ORDER BY t.play_count DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting top tracks: {e}") + return [] + + def get_forgotten_favorites(self, limit: int = 50) -> List[Dict]: + """ + Get tracks you loved but haven't played recently. + + Criteria: High play count but not played in 60+ days + """ + try: + with self.database._get_connection() as conn: + cursor = conn.cursor() + + # Check if required columns exist + cursor.execute("PRAGMA table_info(tracks)") + columns = [row['name'] for row in cursor.fetchall()] + + has_play_count = 'play_count' in columns + has_last_played = 'last_played' in columns + + if not has_play_count or not has_last_played: + logger.warning("play_count or last_played columns not found - using older tracks") + # Fallback: return older tracks by date_added + sixty_days_ago = (datetime.now() - timedelta(days=60)).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, + t.date_added + 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 < ? + ORDER BY t.date_added DESC + LIMIT ? + """, (sixty_days_ago, limit)) + else: + sixty_days_ago = (datetime.now() - timedelta(days=60)).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, + t.play_count, + t.last_played + 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 > 5 + AND (t.last_played IS NULL OR t.last_played < ?) + ORDER BY t.play_count DESC + LIMIT ? + """, (sixty_days_ago, limit)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting forgotten favorites: {e}") + return [] + + def get_decade_playlist(self, decade: int, limit: int = 100) -> List[Dict]: + """ + Get tracks from a specific decade. + + Args: + decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s) + limit: Maximum tracks to return + """ + try: + start_year = decade + end_year = decade + 9 + + with self.database._get_connection() as conn: + cursor = conn.cursor() + + # Check if release_year column exists + cursor.execute("PRAGMA table_info(tracks)") + columns = [row['name'] for row in cursor.fetchall()] + + if 'release_year' in columns: + 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.release_year + 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.release_year BETWEEN ? AND ? + ORDER BY t.popularity DESC + LIMIT ? + """, (start_year, end_year, limit)) + else: + # Try to extract year from album release_date + logger.warning("release_year column not found - using album release_date") + 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, + al.release_date + 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 al.release_date IS NOT NULL + AND CAST(SUBSTR(al.release_date, 1, 4) AS INTEGER) BETWEEN ? AND ? + ORDER BY t.popularity DESC + LIMIT ? + """, (start_year, end_year, limit)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting decade playlist for {decade}s: {e}") + return [] + + # ======================================== + # DISCOVERY POOL PLAYLISTS + # ======================================== + + def get_popular_picks(self, limit: int = 50) -> List[Dict]: + """Get high popularity tracks from discovery pool""" + 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 + WHERE popularity >= 60 + ORDER BY popularity DESC, RANDOM() + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting popular picks: {e}") + return [] + + def get_hidden_gems(self, limit: int = 50) -> List[Dict]: + """Get low popularity (underground/indie) tracks from discovery pool""" + 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 + WHERE popularity < 40 + ORDER BY RANDOM() + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting hidden gems: {e}") + return [] + + # ======================================== + # DAILY MIX (HYBRID PLAYLISTS) + # ======================================== + + def get_top_genres_from_library(self, limit: int = 5) -> List[Tuple[str, int]]: + """ + Get top genres from user's library by track count. + + Returns: List of (genre_name, track_count) tuples + """ + try: + # Get all genres from library tracks + with self.database._get_connection() as conn: + cursor = conn.cursor() + + # Try to get genres from tracks or albums + cursor.execute("PRAGMA table_info(tracks)") + columns = [row['name'] for row in cursor.fetchall()] + + if 'genres' in columns: + # Get genres directly from tracks + cursor.execute(""" + SELECT genres FROM tracks WHERE genres IS NOT NULL + """) + rows = cursor.fetchall() + + # Parse genres (assuming JSON array or comma-separated) + all_genres = [] + for row in rows: + genres_str = row['genres'] + if genres_str: + # Try JSON parse first + try: + import json + genres = json.loads(genres_str) + all_genres.extend(genres) + except: + # Fallback to comma-separated + genres = [g.strip() for g in genres_str.split(',')] + all_genres.extend(genres) + + # Count genres + genre_counts = Counter(all_genres) + return genre_counts.most_common(limit) + else: + # Fallback: use artist names as "genres" + logger.warning("No genres column - using top artists as categories") + cursor.execute(""" + SELECT ar.name, COUNT(*) as count + FROM tracks t + LEFT JOIN artists ar ON t.artist_id = ar.id + WHERE ar.name IS NOT NULL + GROUP BY ar.name + ORDER BY count DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [(row['name'], row['count']) for row in rows] + + except Exception as e: + logger.error(f"Error getting top genres: {e}") + return [] + + def create_daily_mix(self, genre_or_artist: str, mix_number: int = 1) -> Dict[str, Any]: + """ + Create a Daily Mix playlist - hybrid of library + discovery pool. + + Strategy: + - 50% tracks from user's library matching genre/artist + - 50% tracks from discovery pool matching genre/artist + + Args: + genre_or_artist: Genre name or artist name to base mix on + mix_number: Mix number (1, 2, 3, etc.) + + Returns: + Dict with playlist metadata and tracks + """ + try: + logger.info(f"Creating Daily Mix #{mix_number} for: {genre_or_artist}") + + mix_size = 50 + library_portion = mix_size // 2 # 25 tracks + discovery_portion = mix_size - library_portion # 25 tracks + + # Get tracks from library + library_tracks = self._get_library_tracks_by_category(genre_or_artist, library_portion) + + # Get tracks from discovery pool + discovery_tracks = self._get_discovery_tracks_by_category(genre_or_artist, discovery_portion) + + # Combine and shuffle + all_tracks = library_tracks + discovery_tracks + random.shuffle(all_tracks) + + return { + 'mix_number': mix_number, + 'name': f"Daily Mix {mix_number}", + 'description': f"{genre_or_artist} mix", + 'category': genre_or_artist, + 'track_count': len(all_tracks), + 'tracks': all_tracks + } + + except Exception as e: + logger.error(f"Error creating daily mix: {e}") + return { + 'mix_number': mix_number, + 'name': f"Daily Mix {mix_number}", + 'description': 'Mix', + 'category': genre_or_artist, + 'track_count': 0, + 'tracks': [] + } + + def _get_library_tracks_by_category(self, category: str, limit: int) -> List[Dict]: + """Get tracks from library matching genre or artist""" + try: + with self.database._get_connection() as conn: + cursor = conn.cursor() + + # Try genre match first, then artist match + 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 (ar.name LIKE ? OR t.genres LIKE ?) + ORDER BY RANDOM() + LIMIT ? + """, (f'%{category}%', f'%{category}%', limit)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting library tracks by category: {e}") + return [] + + def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]: + """Get tracks from discovery pool matching genre or artist""" + 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 + WHERE artist_name LIKE ? OR track_name LIKE ? + ORDER BY RANDOM() + LIMIT ? + """, (f'%{category}%', f'%{category}%', limit)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + except Exception as e: + logger.error(f"Error getting discovery tracks by category: {e}") + return [] + + def get_all_daily_mixes(self, max_mixes: int = 4) -> List[Dict]: + """ + Generate multiple Daily Mix playlists based on top genres/artists. + + Args: + max_mixes: Maximum number of mixes to generate (default: 4) + + Returns: + List of daily mix dictionaries + """ + try: + # Get top categories (genres or artists) + top_categories = self.get_top_genres_from_library(limit=max_mixes) + + if not top_categories: + logger.warning("No categories found for Daily Mixes") + return [] + + daily_mixes = [] + for i, (category, _count) in enumerate(top_categories, 1): + mix = self.create_daily_mix(category, mix_number=i) + if mix['track_count'] > 0: + daily_mixes.append(mix) + + logger.info(f"Created {len(daily_mixes)} Daily Mixes") + return daily_mixes + + except Exception as e: + logger.error(f"Error getting all daily mixes: {e}") + return [] + + +# Singleton instance +_personalized_playlists_instance = None + +def get_personalized_playlists_service(database, spotify_client=None): + """Get the global personalized playlists service instance""" + global _personalized_playlists_instance + if _personalized_playlists_instance is None: + _personalized_playlists_instance = PersonalizedPlaylistsService(database, spotify_client) + return _personalized_playlists_instance diff --git a/web_server.py b/web_server.py index be6f93bd..7c87263c 100644 --- a/web_server.py +++ b/web_server.py @@ -14860,6 +14860,153 @@ def refresh_seasonal_content(): print(f"Error refreshing seasonal content: {e}") return jsonify({"success": False, "error": str(e)}), 500 +# ======================================== +# PERSONALIZED PLAYLISTS ENDPOINTS +# ======================================== + +@app.route('/api/discover/personalized/recently-added', methods=['GET']) +def get_recently_added_playlist(): + """Get recently added tracks from library""" + try: + from core.personalized_playlists import get_personalized_playlists_service + + database = get_database() + service = get_personalized_playlists_service(database, spotify_client) + + tracks = service.get_recently_added(limit=50) + + return jsonify({ + "success": True, + "tracks": tracks + }) + + except Exception as e: + print(f"Error getting recently added playlist: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/personalized/top-tracks', methods=['GET']) +def get_top_tracks_playlist(): + """Get user's all-time top tracks""" + try: + from core.personalized_playlists import get_personalized_playlists_service + + database = get_database() + service = get_personalized_playlists_service(database, spotify_client) + + tracks = service.get_top_tracks(limit=50) + + return jsonify({ + "success": True, + "tracks": tracks + }) + + except Exception as e: + print(f"Error getting top tracks playlist: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/personalized/forgotten-favorites', methods=['GET']) +def get_forgotten_favorites_playlist(): + """Get forgotten favorites - tracks you loved but haven't played recently""" + try: + from core.personalized_playlists import get_personalized_playlists_service + + database = get_database() + service = get_personalized_playlists_service(database, spotify_client) + + tracks = service.get_forgotten_favorites(limit=50) + + return jsonify({ + "success": True, + "tracks": tracks + }) + + except Exception as e: + print(f"Error getting forgotten favorites playlist: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/personalized/decade/', methods=['GET']) +def get_decade_playlist(decade): + """Get tracks from a specific decade""" + try: + from core.personalized_playlists import get_personalized_playlists_service + + database = get_database() + service = get_personalized_playlists_service(database, spotify_client) + + tracks = service.get_decade_playlist(decade, limit=100) + + return jsonify({ + "success": True, + "decade": decade, + "tracks": tracks + }) + + except Exception as e: + print(f"Error getting decade playlist: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/personalized/popular-picks', methods=['GET']) +def get_popular_picks_playlist(): + """Get high popularity 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) + + tracks = service.get_popular_picks(limit=50) + + return jsonify({ + "success": True, + "tracks": tracks + }) + + except Exception as e: + print(f"Error getting popular picks playlist: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/personalized/hidden-gems', methods=['GET']) +def get_hidden_gems_playlist(): + """Get hidden gems (low popularity) from discovery pool""" + try: + from core.personalized_playlists import get_personalized_playlists_service + + database = get_database() + service = get_personalized_playlists_service(database, spotify_client) + + tracks = service.get_hidden_gems(limit=50) + + return jsonify({ + "success": True, + "tracks": tracks + }) + + except Exception as e: + print(f"Error getting hidden gems playlist: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/personalized/daily-mixes', methods=['GET']) +def get_daily_mixes(): + """Get all Daily Mix playlists (hybrid library + discovery)""" + try: + from core.personalized_playlists import get_personalized_playlists_service + + database = get_database() + service = get_personalized_playlists_service(database, spotify_client) + + mixes = service.get_all_daily_mixes(max_mixes=4) + + return jsonify({ + "success": True, + "mixes": mixes + }) + + except Exception as e: + print(f"Error getting daily mixes: {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""" diff --git a/webui/index.html b/webui/index.html index e2c87770..42a06e64 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1740,6 +1740,28 @@ + + + + + +
@@ -1828,6 +1850,50 @@
+ + + + + + + + + + + +
diff --git a/webui/static/script.js b/webui/static/script.js index 187840e7..ff0847ee 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -24708,6 +24708,14 @@ let discoverSeasonalAlbums = []; let discoverSeasonalTracks = []; let currentSeasonKey = null; +// Personalized playlists storage +let personalizedRecentlyAdded = []; +let personalizedTopTracks = []; +let personalizedForgottenFavorites = []; +let personalizedPopularPicks = []; +let personalizedHiddenGems = []; +let personalizedDailyMixes = []; + async function loadDiscoverPage() { console.log('Loading discover page...'); @@ -24715,9 +24723,15 @@ async function loadDiscoverPage() { await Promise.all([ loadDiscoverHero(), loadDiscoverRecentReleases(), - loadSeasonalContent(), // NEW: Seasonal discovery + loadSeasonalContent(), // Seasonal discovery + loadPersonalizedRecentlyAdded(), // NEW: Recently added from library + loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists loadDiscoverReleaseRadar(), loadDiscoverWeekly(), + loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool + loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool + loadPersonalizedTopTracks(), // NEW: Your top tracks + loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites loadMoreForYou() ]); @@ -25516,6 +25530,208 @@ async function syncSeasonalPlaylist() { await syncPlaylistToLibrary(playlistData); } +// =============================== +// PERSONALIZED PLAYLISTS +// =============================== + +async function loadPersonalizedRecentlyAdded() { + try { + const container = document.getElementById('personalized-recently-added'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/recently-added'); + 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; + } + + personalizedRecentlyAdded = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading recently added:', error); + } +} + +async function loadPersonalizedTopTracks() { + try { + const container = document.getElementById('personalized-top-tracks'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/top-tracks'); + 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; + } + + personalizedTopTracks = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading top tracks:', error); + } +} + +async function loadPersonalizedForgottenFavorites() { + try { + const container = document.getElementById('personalized-forgotten-favorites'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/forgotten-favorites'); + 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; + } + + personalizedForgottenFavorites = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading forgotten favorites:', error); + } +} + +async function loadPersonalizedPopularPicks() { + try { + const container = document.getElementById('personalized-popular-picks'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/popular-picks'); + 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; + } + + personalizedPopularPicks = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading popular picks:', error); + } +} + +async function loadPersonalizedHiddenGems() { + try { + const container = document.getElementById('personalized-hidden-gems'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/hidden-gems'); + 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; + } + + personalizedHiddenGems = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading hidden gems:', error); + } +} + +async function loadPersonalizedDailyMixes() { + try { + const container = document.getElementById('daily-mixes-grid'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/daily-mixes'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.mixes || data.mixes.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedDailyMixes = data.mixes; + + // Render Daily Mix cards + let html = ''; + data.mixes.forEach((mix, index) => { + const coverUrl = mix.tracks && mix.tracks.length > 0 ? + (mix.tracks[0].album_cover_url || '/static/placeholder-album.png') : + '/static/placeholder-album.png'; + + html += ` +
+
+ ${mix.name} +
+
+
+

${mix.name}

+

${mix.description}

+

${mix.track_count} tracks

+
+
+ `; + }); + + container.innerHTML = html; + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading daily mixes:', error); + } +} + +function renderCompactPlaylist(container, tracks) { + let html = '
'; + + tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + html += ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `; + }); + + html += '
'; + container.innerHTML = html; +} + +function openDailyMix(mixIndex) { + const mix = personalizedDailyMixes[mixIndex]; + if (!mix || !mix.tracks) return; + + // TODO: Open modal or dedicated view for Daily Mix + console.log('Opening Daily Mix:', mix.name); +} + // =============================== // DISCOVER PLAYLIST ACTIONS // ===============================