discover progress

This commit is contained in:
Broque Thomas 2025-11-17 08:30:38 -08:00
parent 08a4decda8
commit 5a13b5fe94
5 changed files with 564 additions and 140 deletions

View file

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

View file

@ -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/<int: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"""

View file

@ -1673,6 +1673,9 @@
<div class="discover-hero-label">FEATURED ARTIST</div>
<h1 class="discover-hero-title" id="discover-hero-title">Loading...</h1>
<p class="discover-hero-subtitle" id="discover-hero-subtitle">Discover new music tailored to your taste</p>
<div class="discover-hero-meta" id="discover-hero-meta">
<!-- Popularity and genres will be populated here -->
</div>
<div class="discover-hero-actions">
<button class="discover-hero-button secondary" id="discover-hero-discography" onclick="viewDiscoverHeroDiscography()">
<span class="button-icon">📀</span>
@ -2054,20 +2057,31 @@
</div>
</div>
<!-- More Playlists Section -->
<!-- Decade Browser Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">More For You</h2>
<p class="discover-section-subtitle">Curated playlists based on your taste</p>
<h2 class="discover-section-title">⏰ Time Machine</h2>
<p class="discover-section-subtitle">Travel through the decades</p>
</div>
<div class="discover-playlists-grid" id="more-playlists-grid">
<div class="discover-carousel" id="decade-browser-carousel">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading playlists...</p>
<p>Loading decades...</p>
</div>
</div>
</div>
<!-- Quick Picks Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">⚡ Quick Picks</h2>
<p class="discover-section-subtitle">Jump into curated experiences</p>
</div>
<div class="quick-picks-grid" id="quick-picks-grid">
<!-- Content will be populated dynamically -->
</div>
</div>
</div>
</div>

View file

@ -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 = '<div class="discover-hero-meta-content">';
// Add popularity indicator
if (artist.popularity !== undefined && artist.popularity > 0) {
const popularityClass = artist.popularity >= 80 ? 'high' :
artist.popularity >= 50 ? 'medium' : 'low';
metaHTML += `
<div class="hero-meta-item hero-popularity ${popularityClass}">
<span class="meta-icon"></span>
<span class="meta-value">${artist.popularity}/100</span>
<span class="meta-label">Popularity</span>
</div>
`;
}
subtitleEl.textContent = subtitle;
// Add genre tags
if (artist.genres && artist.genres.length > 0) {
metaHTML += '<div class="hero-meta-item hero-genres">';
artist.genres.slice(0, 3).forEach(genre => {
metaHTML += `<span class="genre-tag">${genre}</span>`;
});
metaHTML += '</div>';
}
metaHTML += '</div>';
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 = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading playlists...</p></div>';
// 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 = '<div class="discover-empty"><p>No decade content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
return;
}
// Build decade cards from available decades
let html = '';
data.decades.forEach(decade => {
const icon = getDecadeIcon(decade.year);
const label = `${decade.year}s`;
html += `
<div class="decade-card" onclick="openDecadePlaylist(${decade.year})">
<div class="decade-card-content">
<div class="decade-icon">${icon}</div>
<div class="decade-year">${label}</div>
<div class="decade-title">${decade.track_count} tracks</div>
</div>
</div>
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading decade browser:', error);
const carousel = document.getElementById('decade-browser-carousel');
if (carousel) {
carousel.innerHTML = '<div class="discover-empty"><p>Failed to load decades</p></div>';
}
}
}
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 = '<div class="discover-empty"><p>No playlists available yet</p></div>';
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 += `
<div class="discover-playlist-card">
<div class="discover-playlist-cover">
<img src="${coverUrl}" alt="${playlist.name}">
<div class="playlist-play-overlay"></div>
</div>
<div class="discover-playlist-info">
<h4 class="discover-playlist-name">${playlist.name}</h4>
<p class="discover-playlist-description">${playlist.description}</p>
<p class="discover-playlist-count">${playlist.track_count} tracks</p>
</div>
<div class="quick-pick-card" onclick="${pick.action}" style="background: ${pick.gradient};">
<div class="quick-pick-icon">${pick.icon}</div>
<h3 class="quick-pick-title">${pick.title}</h3>
<p class="quick-pick-description">${pick.description}</p>
</div>
`;
});
@ -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 = '<div class="discover-empty"><p>Failed to load playlists</p></div>';
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);
}
// ===============================

View file

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