diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 81ab2527..5f223aad 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -73,7 +73,7 @@ class PersonalizedPlaylistsService: def get_decade_playlist(self, decade: int, limit: int = 100) -> List[Dict]: """ - Get tracks from a specific decade. + Get tracks from a specific decade from discovery pool with diversity filtering. Args: decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s) @@ -86,56 +86,59 @@ class PersonalizedPlaylistsService: 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)) + # Query discovery_pool - get 10x more for diversity filtering + cursor.execute(""" + SELECT + spotify_track_id, + track_name, + artist_name, + album_name, + album_cover_url, + duration_ms, + popularity, + release_date + FROM discovery_pool + WHERE release_date IS NOT NULL + AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ? + ORDER BY RANDOM() + LIMIT ? + """, (start_year, end_year, limit * 10)) rows = cursor.fetchall() - return [dict(row) for row in rows] + all_tracks = [dict(row) for row in rows] + + if not all_tracks: + logger.warning(f"No tracks found for {decade}s") + return [] + + # Apply diversity constraint: max 3 tracks per album, max 4 per artist + # Shuffle first for randomness + import random + random.shuffle(all_tracks) + + tracks_by_album = {} + tracks_by_artist = {} + diverse_tracks = [] + + for track in all_tracks: + album = track['album_name'] + artist = track['artist_name'] + + # Count current tracks for this album/artist + album_count = tracks_by_album.get(album, 0) + artist_count = tracks_by_artist.get(artist, 0) + + # Apply more lenient limits: max 3 per album, max 4 per artist + if album_count < 3 and artist_count < 4: + diverse_tracks.append(track) + tracks_by_album[album] = album_count + 1 + tracks_by_artist[artist] = artist_count + 1 + + if len(diverse_tracks) >= limit: + break + + logger.info(f"Found {len(diverse_tracks)} tracks from {decade}s in discovery pool (with diversity filtering)") + return diverse_tracks[:limit] except Exception as e: logger.error(f"Error getting decade playlist for {decade}s: {e}") diff --git a/web_server.py b/web_server.py index a669ff38..4e4257e8 100644 --- a/web_server.py +++ b/web_server.py @@ -14538,12 +14538,18 @@ def get_discover_hero(): try: database = get_database() - # Get top similar artists (by occurrence count) - similar_artists = database.get_top_similar_artists(limit=10) + # Get top similar artists (by occurrence count) - get 20 for variety + similar_artists = database.get_top_similar_artists(limit=20) if not similar_artists: return jsonify({"success": True, "artists": []}) + # Shuffle for variety and take top 10 + import random + shuffled = list(similar_artists) + random.shuffle(shuffled) + similar_artists = shuffled[:10] + # Convert to JSON format with Spotify data enrichment hero_artists = [] for artist in similar_artists: @@ -15110,6 +15116,91 @@ def generate_custom_playlist(): traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/discover/decades/available', methods=['GET']) +def get_available_decades(): + """Get list of decades that have content in discovery pool""" + try: + database = get_database() + + with database._get_connection() as conn: + cursor = conn.cursor() + + # Get distinct decades from discovery pool + cursor.execute(""" + SELECT DISTINCT + (CAST(SUBSTR(release_date, 1, 4) AS INTEGER) / 10) * 10 as decade, + COUNT(*) as track_count + FROM discovery_pool + WHERE release_date IS NOT NULL + AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) >= 1950 + AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) <= 2029 + GROUP BY decade + HAVING track_count >= 10 + ORDER BY decade ASC + """) + + rows = cursor.fetchall() + decades = [] + for row in rows: + decades.append({ + 'year': row[0], + 'track_count': row[1] + }) + + return jsonify({ + "success": True, + "decades": decades + }) + + except Exception as e: + print(f"Error getting available decades: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/decade/', methods=['GET']) +def get_discover_decade_playlist(decade): + """Get tracks from a specific decade for discovery page""" + 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=50) + + if not tracks: + return jsonify({ + "success": True, + "tracks": [], + "decade": decade, + "message": f"No tracks found for the {decade}s" + }), 200 + + # Convert to Spotify format for modal compatibility + spotify_tracks = [] + for track in tracks: + spotify_tracks.append({ + 'id': track.get('spotify_track_id', track.get('id')), + 'name': track.get('track_name', track.get('name')), + 'artists': [track.get('artist_name', 'Unknown')], + 'album': { + 'name': track.get('album_name', 'Unknown'), + 'images': [{'url': track.get('album_cover_url')}] if track.get('album_cover_url') else [] + }, + 'duration_ms': track.get('duration_ms', 0) + }) + + return jsonify({ + "success": True, + "tracks": spotify_tracks, + "decade": decade + }) + + except Exception as e: + print(f"Error getting decade 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""" diff --git a/webui/index.html b/webui/index.html index 78b2d3a8..4f191ca2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1673,6 +1673,9 @@
FEATURED ARTIST

Loading...

Discover new music tailored to your taste

+
+ +
- +
-

More For You

-

Curated playlists based on your taste

+

⏰ Time Machine

+

Travel through the decades

-
+
+ + +
+
+

⚡ Quick Picks

+

Jump into curated experiences

+
+
+ +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 9e1c6066..e2336b6e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -4204,6 +4204,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : 'YouTube'; @@ -24741,7 +24742,8 @@ async function loadDiscoverPage() { loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites loadDiscoveryShuffle(), // NEW: Discovery Shuffle loadFamiliarFavorites(), // NEW: Familiar Favorites - loadMoreForYou() + loadDecadeBrowser(), // Decade browser + loadQuickPicks() // Quick picks action cards ]); // Check for active syncs after page load @@ -24878,6 +24880,7 @@ async function loadDiscoverHero() { function displayDiscoverHeroArtist(artist) { const titleEl = document.getElementById('discover-hero-title'); const subtitleEl = document.getElementById('discover-hero-subtitle'); + const metaEl = document.getElementById('discover-hero-meta'); const imageEl = document.getElementById('discover-hero-image'); const bgEl = document.getElementById('discover-hero-bg'); @@ -24893,14 +24896,37 @@ function displayDiscoverHeroArtist(artist) { } else { subtitle = 'Similar to an artist in your watchlist'; } + subtitleEl.textContent = subtitle; + } - // Add genre context if available - if (artist.genres && artist.genres.length > 0) { - const topGenres = artist.genres.slice(0, 2).join(', '); - subtitle += ` • ${topGenres}`; + // Build metadata section with popularity and genres + if (metaEl) { + let metaHTML = '
'; + + // Add popularity indicator + if (artist.popularity !== undefined && artist.popularity > 0) { + const popularityClass = artist.popularity >= 80 ? 'high' : + artist.popularity >= 50 ? 'medium' : 'low'; + metaHTML += ` +
+ + ${artist.popularity}/100 + Popularity +
+ `; } - subtitleEl.textContent = subtitle; + // Add genre tags + if (artist.genres && artist.genres.length > 0) { + metaHTML += '
'; + artist.genres.slice(0, 3).forEach(genre => { + metaHTML += `${genre}`; + }); + metaHTML += '
'; + } + + metaHTML += '
'; + metaEl.innerHTML = metaHTML; } if (imageEl && artist.image_url) { @@ -25225,98 +25251,152 @@ async function loadDiscoverWeekly() { } } -async function loadMoreForYou() { +// =============================== +// DECADE BROWSER +// =============================== + +let selectedDecade = null; +let decadeTracks = []; + +async function loadDecadeBrowser() { try { - const grid = document.getElementById('more-playlists-grid'); - if (!grid) return; + const carousel = document.getElementById('decade-browser-carousel'); + if (!carousel) return; - grid.innerHTML = '

Loading playlists...

'; - - // Fetch discovery pool tracks to create curated playlists - const response = await fetch('/api/discover/weekly'); + // Fetch available decades from backend + const response = await fetch('/api/discover/decades/available'); if (!response.ok) { - throw new Error('Failed to fetch tracks for playlists'); + throw new Error('Failed to fetch available decades'); + } + + const data = await response.json(); + if (!data.success || !data.decades || data.decades.length === 0) { + carousel.innerHTML = '

No decade content available yet. Run a watchlist scan to populate your discovery pool!

'; + return; + } + + // Build decade cards from available decades + let html = ''; + data.decades.forEach(decade => { + const icon = getDecadeIcon(decade.year); + const label = `${decade.year}s`; + html += ` +
+
+
${icon}
+
${label}
+
${decade.track_count} tracks
+
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading decade browser:', error); + const carousel = document.getElementById('decade-browser-carousel'); + if (carousel) { + carousel.innerHTML = '

Failed to load decades

'; + } + } +} + +function getDecadeIcon(year) { + const icons = { + 1950: '🎺', + 1960: '🎸', + 1970: '🕺', + 1980: '📻', + 1990: '💿', + 2000: '📱', + 2010: '🎧', + 2020: '🌐' + }; + return icons[year] || '🎵'; +} + +async function openDecadePlaylist(decade) { + try { + showLoadingOverlay(`Loading ${decade}s playlist...`); + + const response = await fetch(`/api/discover/decade/${decade}`); + if (!response.ok) { + throw new Error('Failed to fetch decade playlist'); } const data = await response.json(); if (!data.success || !data.tracks || data.tracks.length === 0) { - grid.innerHTML = '

No playlists available yet

'; + const message = data.message || `No tracks found for the ${decade}s`; + showToast(message, 'info'); + hideLoadingOverlay(); return; } - const tracks = data.tracks; + selectedDecade = decade; + decadeTracks = data.tracks; - // Create curated playlists - const playlists = []; + // Open download modal + const playlistName = `${decade}s Classics`; + const virtualPlaylistId = `decade_${decade}`; - // 1. Popular Picks (by popularity score) - const popularTracks = [...tracks].sort((a, b) => b.popularity - a.popularity).slice(0, 20); - if (popularTracks.length > 0) { - playlists.push({ - name: 'Popular Picks', - description: 'Trending tracks from similar artists', - track_count: popularTracks.length, - cover: popularTracks[0].album_cover_url - }); - } + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks); + hideLoadingOverlay(); - // 2. Deep Cuts (lower popularity, hidden gems) - const deepCuts = [...tracks].filter(t => t.popularity < 50).slice(0, 20); - if (deepCuts.length > 0) { - playlists.push({ - name: 'Deep Cuts', - description: 'Hidden gems you might have missed', - track_count: deepCuts.length, - cover: deepCuts[0].album_cover_url - }); - } + } catch (error) { + console.error(`Error opening ${decade}s playlist:`, error); + showToast(`Failed to load ${decade}s playlist`, 'error'); + hideLoadingOverlay(); + } +} - // 3. Fresh Finds (newest additions to pool) - const freshFinds = [...tracks].slice(0, 20); - if (freshFinds.length > 0) { - playlists.push({ - name: 'Fresh Finds', - description: 'Recently added to your discovery pool', - track_count: freshFinds.length, - cover: freshFinds[0].album_cover_url - }); - } +// =============================== +// QUICK PICKS +// =============================== - // 4. Artist Mix (group by artist diversity) - const artistMap = {}; - tracks.forEach(track => { - if (!artistMap[track.artist_name]) { - artistMap[track.artist_name] = []; +function loadQuickPicks() { + try { + const grid = document.getElementById('quick-picks-grid'); + if (!grid) return; + + const quickPicks = [ + { + icon: '🎲', + title: 'Surprise Me', + description: 'Shuffle your discovery pool', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + action: 'scrollToSection("personalized-discovery-shuffle")' + }, + { + icon: '🔥', + title: 'Trending Now', + description: 'High popularity discoveries', + gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', + action: 'scrollToSection("personalized-popular-picks")' + }, + { + icon: '💎', + title: 'Deep Cuts', + description: 'Underground hidden gems', + gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', + action: 'scrollToSection("personalized-hidden-gems")' + }, + { + icon: '⏰', + title: 'Time Machine', + description: 'Browse by decade', + gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', + action: 'scrollToSection("decade-browser-carousel")' } - if (artistMap[track.artist_name].length < 3) { - artistMap[track.artist_name].push(track); - } - }); - const mixTracks = Object.values(artistMap).flat().slice(0, 25); - if (mixTracks.length > 0) { - playlists.push({ - name: 'Artist Mix', - description: 'Diverse selection from multiple artists', - track_count: mixTracks.length, - cover: mixTracks[0].album_cover_url - }); - } + ]; - // Build playlist grid HTML let html = ''; - playlists.forEach(playlist => { - const coverUrl = playlist.cover || '/static/placeholder-album.png'; + quickPicks.forEach(pick => { html += ` -
-
- ${playlist.name} -
-
-
-

${playlist.name}

-

${playlist.description}

-

${playlist.track_count} tracks

-
+
+
${pick.icon}
+

${pick.title}

+

${pick.description}

`; }); @@ -25324,12 +25404,31 @@ async function loadMoreForYou() { grid.innerHTML = html; } catch (error) { - console.error('Error loading more for you:', error); - const grid = document.getElementById('more-playlists-grid'); - if (grid) { - grid.innerHTML = '

Failed to load playlists

'; + console.error('Error loading quick picks:', error); + } +} + +function scrollToSection(sectionId) { + const section = document.getElementById(sectionId); + if (!section) { + console.warn(`Section not found: ${sectionId}`); + return; + } + + // Show section if it's hidden + const parentSection = section.closest('.discover-section'); + if (parentSection) { + const currentDisplay = window.getComputedStyle(parentSection).display; + if (currentDisplay === 'none') { + parentSection.style.display = 'block'; + console.log(`Showing hidden section: ${sectionId}`); } } + + // Wait a tick for layout, then scroll + setTimeout(() => { + section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 100); } // =============================== diff --git a/webui/static/style.css b/webui/static/style.css index 2149ae43..3d21e3ca 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -16239,11 +16239,82 @@ body { .discover-hero-subtitle { font-size: 19px; color: rgba(255, 255, 255, 0.9); - margin-bottom: 36px; + margin-bottom: 20px; line-height: 1.7; font-weight: 400; } +/* Hero Meta Section (Popularity & Genres) */ +.discover-hero-meta { + margin-bottom: 28px; +} + +.discover-hero-meta-content { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.hero-meta-item { + display: flex; + align-items: center; + gap: 8px; +} + +.hero-popularity { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + border-radius: 20px; + font-size: 14px; + font-weight: 600; +} + +.hero-popularity.high { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); +} + +.hero-popularity.medium { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.hero-popularity.low { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); +} + +.hero-popularity .meta-icon { + font-size: 16px; +} + +.hero-popularity .meta-value { + font-weight: 700; +} + +.hero-popularity .meta-label { + opacity: 0.8; + font-size: 12px; +} + +.hero-genres { + gap: 10px; +} + +.genre-tag { + padding: 6px 14px; + background: rgba(255, 255, 255, 0.15); + border-radius: 16px; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + transition: all 0.2s ease; +} + +.genre-tag:hover { + background: rgba(255, 255, 255, 0.25); + transform: translateY(-2px); +} + .discover-hero-actions { display: flex; gap: 16px; @@ -17223,3 +17294,149 @@ body { .build-playlist-metadata strong { color: #1db954; } + +/* ======================= + QUICK PICKS SECTION + ======================= */ + +.quick-picks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; + margin-top: 16px; +} + +.quick-pick-card { + position: relative; + padding: 32px 24px; + border-radius: 16px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +.quick-pick-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: inherit; + opacity: 0.9; + transition: opacity 0.3s ease; +} + +.quick-pick-card:hover::before { + opacity: 1; +} + +.quick-pick-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} + +.quick-pick-icon { + position: relative; + font-size: 48px; + margin-bottom: 16px; + z-index: 1; +} + +.quick-pick-title { + position: relative; + font-size: 20px; + font-weight: 700; + color: #fff; + margin: 0 0 8px 0; + z-index: 1; +} + +.quick-pick-description { + position: relative; + font-size: 14px; + color: rgba(255, 255, 255, 0.8); + margin: 0; + z-index: 1; +} + +/* ======================= + DECADE BROWSER SECTION + ======================= */ + +.decade-card { + position: relative; + width: 100%; + aspect-ratio: 1; + border-radius: 16px; + overflow: hidden; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); +} + +.decade-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); +} + +.decade-card-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; + background: linear-gradient(135deg, + rgba(102, 126, 234, 0.15) 0%, + rgba(118, 75, 162, 0.15) 100%); +} + +.decade-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.decade-card:hover::before { + opacity: 0.2; +} + +.decade-icon { + position: relative; + font-size: 80px; + line-height: 1; + z-index: 1; + transition: transform 0.3s ease; +} + +.decade-card:hover .decade-icon { + transform: scale(1.1); +} + +.decade-year { + position: relative; + font-size: 36px; + font-weight: 900; + color: #fff; + text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + letter-spacing: -1px; + z-index: 1; +} + +.decade-title { + position: relative; + font-size: 16px; + font-weight: 600; + color: rgba(255, 255, 255, 0.7); + text-transform: uppercase; + letter-spacing: 2px; + z-index: 1; +} +