discover page progress

This commit is contained in:
Broque Thomas 2025-11-17 10:09:08 -08:00
parent ee266920a9
commit cc62b3b48e
7 changed files with 516 additions and 8 deletions

View file

@ -9,6 +9,7 @@ from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, timedelta
from collections import Counter
import random
import json
from utils.logging_config import get_logger
logger = get_logger("personalized_playlists")
@ -162,6 +163,170 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting decade playlist for {decade}s: {e}")
return []
def get_available_genres(self) -> List[Dict]:
"""
Get list of genres with track counts from discovery pool.
Uses cached artist genres from database (populated during discovery scan).
"""
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Get all tracks with genres from discovery pool
cursor.execute("""
SELECT artist_genres
FROM discovery_pool
WHERE artist_genres IS NOT NULL
""")
rows = cursor.fetchall()
if not rows:
logger.warning("No genres found in discovery pool - genres may not be populated yet")
return []
# Count tracks per genre
genre_track_count = {} # {genre: count}
for row in rows:
try:
artist_genres_json = row[0]
if artist_genres_json:
genres = json.loads(artist_genres_json)
for genre in genres:
genre_track_count[genre] = genre_track_count.get(genre, 0) + 1
except Exception as e:
logger.debug(f"Error parsing genres JSON: {e}")
continue
# Filter genres with at least 10 tracks and sort by count
available_genres = [
{'name': genre, 'track_count': count}
for genre, count in genre_track_count.items()
if count >= 10
]
available_genres.sort(key=lambda x: x['track_count'], reverse=True)
logger.info(f"Found {len(available_genres)} genres with 10+ tracks")
return available_genres[:20] # Top 20 genres
except Exception as e:
logger.error(f"Error getting available genres: {e}")
return []
def get_genre_playlist(self, genre: str, limit: int = 50) -> List[Dict]:
"""
Get tracks from a specific genre with diversity filtering.
Uses cached artist genres from database (populated during discovery scan).
"""
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Get all tracks with this genre (query cached genres)
cursor.execute("""
SELECT
spotify_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity,
artist_genres
FROM discovery_pool
WHERE artist_genres IS NOT NULL
""")
rows = cursor.fetchall()
# Filter tracks that match the genre (using partial matching)
matching_tracks = []
genre_lower = genre.lower()
for row in rows:
try:
artist_genres_json = row[7] # artist_genres column
if artist_genres_json:
genres = json.loads(artist_genres_json)
# Partial match: if search genre is in any artist genre
# e.g., "house" matches "electro house", "progressive house", etc.
genre_match = any(genre_lower in g.lower() for g in genres)
if genre_match:
# Convert row to dict (exclude artist_genres from output)
track_dict = {
'spotify_track_id': row[0],
'track_name': row[1],
'artist_name': row[2],
'album_name': row[3],
'album_cover_url': row[4],
'duration_ms': row[5],
'popularity': row[6]
}
matching_tracks.append(track_dict)
except Exception as e:
logger.debug(f"Error parsing genres for track: {e}")
continue
if not matching_tracks:
logger.warning(f"No tracks found for genre: {genre}")
return []
# Shuffle before limiting for better variety
random.shuffle(matching_tracks)
# Limit to 10x for diversity filtering
all_tracks = matching_tracks[:limit * 10] if len(matching_tracks) > limit * 10 else matching_tracks
if not all_tracks:
return []
# Apply adaptive diversity filtering (relaxed for genres)
unique_artists = len(set(track['artist_name'] for track in all_tracks))
if unique_artists >= 20:
max_per_album = 3
max_per_artist = 5
elif unique_artists >= 10:
max_per_album = 4
max_per_artist = 10
elif unique_artists >= 5:
max_per_album = 6
max_per_artist = 15
else:
# Very limited artist pool - be more lenient
max_per_album = 8
max_per_artist = 25
logger.info(f"Genre '{genre}' has {unique_artists} artists, {len(all_tracks)} total tracks - limits: {max_per_album}/album, {max_per_artist}/artist")
# Shuffle and apply diversity
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']
album_count = tracks_by_album.get(album, 0)
artist_count = tracks_by_artist.get(artist, 0)
if album_count < max_per_album and artist_count < max_per_artist:
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 for genre '{genre}'")
return diverse_tracks[:limit]
except Exception as e:
logger.error(f"Error getting genre playlist for {genre}: {e}")
return []
# ========================================
# DISCOVERY POOL PLAYLISTS
# ========================================

View file

@ -763,6 +763,15 @@ class WatchlistScanner:
logger.debug(f"No albums found for {similar_artist.similar_artist_name}")
continue
# Fetch artist genres once for all tracks of this artist
artist_genres = []
try:
artist_data = self.spotify_client.get_artist(similar_artist.similar_artist_spotify_id)
if artist_data and 'genres' in artist_data:
artist_genres = artist_data['genres']
except Exception as e:
logger.debug(f"Could not fetch genres for {similar_artist.similar_artist_name}: {e}")
# IMPROVED: Smart selection mixing albums, singles, and EPs
# Prioritize recent releases and popular content
@ -833,7 +842,8 @@ class WatchlistScanner:
'popularity': album_data.get('popularity', 0),
'release_date': album_data.get('release_date', ''),
'is_new_release': is_new,
'track_data_json': track # Store full Spotify track object
'track_data_json': track, # Store full Spotify track object
'artist_genres': artist_genres # Add cached genres
}
# Add to discovery pool
@ -893,6 +903,17 @@ class WatchlistScanner:
if album_data and 'tracks' in album_data:
tracks = album_data['tracks'].get('items', [])
# Fetch artist genres
artist_genres = []
try:
if album_data.get('artists') and len(album_data['artists']) > 0:
artist_id = album_data['artists'][0]['id']
artist_data = self.spotify_client.get_artist(artist_id)
if artist_data and 'genres' in artist_data:
artist_genres = artist_data['genres']
except Exception as e:
logger.debug(f"Could not fetch genres for album artist: {e}")
# Check if new release
is_new = False
try:
@ -918,7 +939,8 @@ class WatchlistScanner:
'popularity': album_data.get('popularity', 0),
'release_date': album_data.get('release_date', ''),
'is_new_release': is_new,
'track_data_json': track
'track_data_json': track,
'artist_genres': artist_genres
}
if self.database.add_to_discovery_pool(track_data):
@ -1010,6 +1032,15 @@ class WatchlistScanner:
if not recent_releases:
continue
# Fetch artist genres once for all tracks of this artist
artist_genres = []
try:
artist_data = self.spotify_client.get_artist(artist.spotify_artist_id)
if artist_data and 'genres' in artist_data:
artist_genres = artist_data['genres']
except Exception as e:
logger.debug(f"Could not fetch genres for {artist.artist_name}: {e}")
for release in recent_releases:
try:
# Check if release is within cutoff
@ -1050,7 +1081,8 @@ class WatchlistScanner:
'popularity': album_data.get('popularity', 0),
'release_date': album_data.get('release_date', ''),
'is_new_release': is_new,
'track_data_json': track
'track_data_json': track,
'artist_genres': artist_genres
}
if self.database.add_to_discovery_pool(track_data):
@ -1314,8 +1346,28 @@ class WatchlistScanner:
# Add Release Radar tracks to discovery pool so they're available for fast lookup
logger.info(f"Adding {len(release_radar_track_data)} Release Radar tracks to discovery pool...")
# Cache genres by artist_id to avoid duplicate API calls
artist_genres_cache = {}
for track_data in release_radar_track_data:
try:
# Fetch artist genres (with caching)
artist_genres = []
if track_data['artists'] and len(track_data['artists']) > 0:
artist_id = track_data['artists'][0]['id']
if artist_id in artist_genres_cache:
artist_genres = artist_genres_cache[artist_id]
else:
try:
artist_data = self.spotify_client.get_artist(artist_id)
if artist_data and 'genres' in artist_data:
artist_genres = artist_data['genres']
artist_genres_cache[artist_id] = artist_genres
except Exception as e:
logger.debug(f"Could not fetch genres for artist {artist_id}: {e}")
# Format track data for discovery pool (expects specific structure)
formatted_track = {
'spotify_track_id': track_data['id'],
@ -1329,7 +1381,8 @@ class WatchlistScanner:
'popularity': track_data.get('popularity', 0),
'release_date': track_data['album'].get('release_date', ''),
'is_new_release': True,
'track_data_json': track_data
'track_data_json': track_data,
'artist_genres': artist_genres
}
self.database.add_to_discovery_pool(formatted_track)
except Exception as e:

View file

@ -521,6 +521,14 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_date ON discovery_recent_albums (release_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)")
# Add genres column to discovery_pool if it doesn't exist (migration)
cursor.execute("PRAGMA table_info(discovery_pool)")
discovery_pool_columns = [column[1] for column in cursor.fetchall()]
if 'artist_genres' not in discovery_pool_columns:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT")
logger.info("Added artist_genres column to discovery_pool table")
logger.info("Discovery tables created successfully")
except Exception as e:
@ -2589,12 +2597,16 @@ class MusicDatabase:
if cursor.fetchone()['count'] > 0:
return True # Already in pool
# Get artist genres if available
artist_genres = track_data.get('artist_genres')
artist_genres_json = json.dumps(artist_genres) if artist_genres else None
cursor.execute("""
INSERT INTO discovery_pool
(spotify_track_id, spotify_album_id, spotify_artist_id, track_name, artist_name,
album_name, album_cover_url, duration_ms, popularity, release_date,
is_new_release, track_data_json, added_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
is_new_release, track_data_json, artist_genres, added_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (
track_data['spotify_track_id'],
track_data['spotify_album_id'],
@ -2607,7 +2619,8 @@ class MusicDatabase:
track_data.get('popularity', 0),
track_data['release_date'],
track_data.get('is_new_release', False),
json.dumps(track_data['track_data_json'])
json.dumps(track_data['track_data_json']),
artist_genres_json
))
conn.commit()

View file

@ -15201,6 +15201,73 @@ def get_discover_decade_playlist(decade):
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/genres/available', methods=['GET'])
def get_available_genres():
"""Get list of genres that have content in discovery pool"""
try:
from core.personalized_playlists import get_personalized_playlists_service
database = get_database()
service = get_personalized_playlists_service(database, spotify_client)
genres = service.get_available_genres()
return jsonify({
"success": True,
"genres": genres
})
except Exception as e:
print(f"Error getting available genres: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/genre/<path:genre_name>', methods=['GET'])
def get_discover_genre_playlist(genre_name):
"""Get tracks from a specific genre 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_genre_playlist(genre_name, limit=50)
if not tracks:
return jsonify({
"success": True,
"tracks": [],
"genre": genre_name,
"message": f"No tracks found for {genre_name}"
}), 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,
"genre": genre_name
})
except Exception as e:
print(f"Error getting genre 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

@ -2071,6 +2071,21 @@
</div>
</div>
</div>
<!-- Genre Browser Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">🎵 Browse by Genre</h2>
<p class="discover-section-subtitle">Explore music by style</p>
</div>
<div class="discover-carousel" id="genre-browser-carousel">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading genres...</p>
</div>
</div>
</div>
</div>
</div>

View file

@ -24742,7 +24742,8 @@ async function loadDiscoverPage() {
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
loadFamiliarFavorites(), // NEW: Familiar Favorites
loadDecadeBrowser() // Decade browser
loadDecadeBrowser(), // Decade browser
loadGenreBrowser() // Genre browser
]);
// Check for active syncs after page load
@ -25352,6 +25353,156 @@ async function openDecadePlaylist(decade) {
}
}
// ===============================
// GENRE BROWSER
// ===============================
let selectedGenre = null;
let genreTracks = [];
async function loadGenreBrowser() {
try {
const carousel = document.getElementById('genre-browser-carousel');
if (!carousel) return;
// Fetch available genres from backend
const response = await fetch('/api/discover/genres/available');
if (!response.ok) {
throw new Error('Failed to fetch available genres');
}
const data = await response.json();
if (!data.success || !data.genres || data.genres.length === 0) {
carousel.innerHTML = '<div class="discover-empty"><p>No genre content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
return;
}
// Build genre cards matching Recent Releases style
let html = '';
data.genres.forEach(genre => {
const icon = getGenreIcon(genre.name);
const displayName = capitalizeGenre(genre.name);
html += `
<div class="discover-card genre-card-modern" onclick="openGenrePlaylist('${escapeHtml(genre.name)}')">
<div class="discover-card-image genre-card-image">
<div class="genre-icon-large">${icon}</div>
</div>
<div class="discover-card-info">
<h4 class="discover-card-title">${displayName}</h4>
<p class="discover-card-subtitle">${genre.track_count} tracks</p>
<p class="discover-card-meta">Curated</p>
</div>
</div>
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading genre browser:', error);
const carousel = document.getElementById('genre-browser-carousel');
if (carousel) {
carousel.innerHTML = '<div class="discover-empty"><p>Failed to load genres</p></div>';
}
}
}
function getGenreIcon(genreName) {
const genre = genreName.toLowerCase();
// Electronic/Dance
if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') ||
genre.includes('electro') || genre.includes('trance')) {
return '🎹';
}
// Hip Hop/Rap
if (genre.includes('hip hop') || genre.includes('rap') || genre.includes('trap')) {
return '🎤';
}
// Rock
if (genre.includes('rock') || genre.includes('metal') || genre.includes('punk')) {
return '🎸';
}
// Jazz/Blues
if (genre.includes('jazz') || genre.includes('blues') || genre.includes('soul')) {
return '🎺';
}
// Pop
if (genre.includes('pop')) {
return '🎵';
}
// R&B
if (genre.includes('r&b') || genre.includes('rnb')) {
return '🎙️';
}
// Country/Folk
if (genre.includes('country') || genre.includes('folk')) {
return '🪕';
}
// Classical
if (genre.includes('classical') || genre.includes('orchestra')) {
return '🎻';
}
// Indie/Alternative
if (genre.includes('indie') || genre.includes('alternative')) {
return '🎧';
}
// Lo-fi/Chill
if (genre.includes('lo-fi') || genre.includes('chill') || genre.includes('ambient')) {
return '☁️';
}
// Default
return '🎶';
}
function capitalizeGenre(genre) {
// Capitalize each word in genre
return genre.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function openGenrePlaylist(genre) {
try {
showLoadingOverlay(`Loading ${capitalizeGenre(genre)} playlist...`);
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre)}`);
if (!response.ok) {
throw new Error('Failed to fetch genre playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
const message = data.message || `No tracks found for ${genre}`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedGenre = genre;
genreTracks = data.tracks;
// Open download modal
const playlistName = `${capitalizeGenre(genre)} Mix`;
const virtualPlaylistId = `genre_${genre.replace(/\s+/g, '_')}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${genre} playlist:`, error);
showToast(`Failed to load ${genre} playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// SEASONAL DISCOVERY
// ===============================

View file

@ -17339,3 +17339,47 @@ body {
transform: scale(1.1) rotate(5deg);
}
/* =======================
Genre Browser Cards
======================= */
.genre-card-modern {
/* Inherits from .discover-card */
}
.genre-card-image {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.genre-card-image::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(102, 126, 234, 0.1) 0%,
rgba(118, 75, 162, 0.1) 100%);
opacity: 1;
transition: opacity 0.3s ease;
}
.genre-card-modern:hover .genre-card-image::before {
opacity: 0.3;
}
.genre-icon-large {
font-size: 96px;
line-height: 1;
position: relative;
z-index: 1;
transition: transform 0.3s ease;
}
.genre-card-modern:hover .genre-icon-large {
transform: scale(1.1) rotate(5deg);
}