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 datetime import datetime, timedelta
from collections import Counter from collections import Counter
import random import random
import json
from utils.logging_config import get_logger from utils.logging_config import get_logger
logger = get_logger("personalized_playlists") logger = get_logger("personalized_playlists")
@ -162,6 +163,170 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting decade playlist for {decade}s: {e}") logger.error(f"Error getting decade playlist for {decade}s: {e}")
return [] 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 # DISCOVERY POOL PLAYLISTS
# ======================================== # ========================================

View file

@ -763,6 +763,15 @@ class WatchlistScanner:
logger.debug(f"No albums found for {similar_artist.similar_artist_name}") logger.debug(f"No albums found for {similar_artist.similar_artist_name}")
continue 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 # IMPROVED: Smart selection mixing albums, singles, and EPs
# Prioritize recent releases and popular content # Prioritize recent releases and popular content
@ -833,7 +842,8 @@ class WatchlistScanner:
'popularity': album_data.get('popularity', 0), 'popularity': album_data.get('popularity', 0),
'release_date': album_data.get('release_date', ''), 'release_date': album_data.get('release_date', ''),
'is_new_release': is_new, '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 # Add to discovery pool
@ -893,6 +903,17 @@ class WatchlistScanner:
if album_data and 'tracks' in album_data: if album_data and 'tracks' in album_data:
tracks = album_data['tracks'].get('items', []) 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 # Check if new release
is_new = False is_new = False
try: try:
@ -918,7 +939,8 @@ class WatchlistScanner:
'popularity': album_data.get('popularity', 0), 'popularity': album_data.get('popularity', 0),
'release_date': album_data.get('release_date', ''), 'release_date': album_data.get('release_date', ''),
'is_new_release': is_new, '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): if self.database.add_to_discovery_pool(track_data):
@ -1010,6 +1032,15 @@ class WatchlistScanner:
if not recent_releases: if not recent_releases:
continue 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: for release in recent_releases:
try: try:
# Check if release is within cutoff # Check if release is within cutoff
@ -1050,7 +1081,8 @@ class WatchlistScanner:
'popularity': album_data.get('popularity', 0), 'popularity': album_data.get('popularity', 0),
'release_date': album_data.get('release_date', ''), 'release_date': album_data.get('release_date', ''),
'is_new_release': is_new, '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): 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 # 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...") 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: for track_data in release_radar_track_data:
try: 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) # Format track data for discovery pool (expects specific structure)
formatted_track = { formatted_track = {
'spotify_track_id': track_data['id'], 'spotify_track_id': track_data['id'],
@ -1329,7 +1381,8 @@ class WatchlistScanner:
'popularity': track_data.get('popularity', 0), 'popularity': track_data.get('popularity', 0),
'release_date': track_data['album'].get('release_date', ''), 'release_date': track_data['album'].get('release_date', ''),
'is_new_release': True, '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) self.database.add_to_discovery_pool(formatted_track)
except Exception as e: 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_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)") 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") logger.info("Discovery tables created successfully")
except Exception as e: except Exception as e:
@ -2589,12 +2597,16 @@ class MusicDatabase:
if cursor.fetchone()['count'] > 0: if cursor.fetchone()['count'] > 0:
return True # Already in pool 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(""" cursor.execute("""
INSERT INTO discovery_pool INSERT INTO discovery_pool
(spotify_track_id, spotify_album_id, spotify_artist_id, track_name, artist_name, (spotify_track_id, spotify_album_id, spotify_artist_id, track_name, artist_name,
album_name, album_cover_url, duration_ms, popularity, release_date, album_name, album_cover_url, duration_ms, popularity, release_date,
is_new_release, track_data_json, added_date) is_new_release, track_data_json, artist_genres, added_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", ( """, (
track_data['spotify_track_id'], track_data['spotify_track_id'],
track_data['spotify_album_id'], track_data['spotify_album_id'],
@ -2607,7 +2619,8 @@ class MusicDatabase:
track_data.get('popularity', 0), track_data.get('popularity', 0),
track_data['release_date'], track_data['release_date'],
track_data.get('is_new_release', False), 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() conn.commit()

View file

@ -15201,6 +15201,73 @@ def get_discover_decade_playlist(decade):
traceback.print_exc() traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500 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']) @app.route('/api/metadata/start', methods=['POST'])
def start_metadata_update(): def start_metadata_update():
"""Start the metadata update process - EXACT copy of dashboard.py logic""" """Start the metadata update process - EXACT copy of dashboard.py logic"""

View file

@ -2071,6 +2071,21 @@
</div> </div>
</div> </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>
</div> </div>

View file

@ -24742,7 +24742,8 @@ async function loadDiscoverPage() {
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
loadDiscoveryShuffle(), // NEW: Discovery Shuffle loadDiscoveryShuffle(), // NEW: Discovery Shuffle
loadFamiliarFavorites(), // NEW: Familiar Favorites loadFamiliarFavorites(), // NEW: Familiar Favorites
loadDecadeBrowser() // Decade browser loadDecadeBrowser(), // Decade browser
loadGenreBrowser() // Genre browser
]); ]);
// Check for active syncs after page load // 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 // SEASONAL DISCOVERY
// =============================== // ===============================

View file

@ -17339,3 +17339,47 @@ body {
transform: scale(1.1) rotate(5deg); 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);
}