From cc62b3b48e26bdaf82733a2d365604367bd6c6c5 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 17 Nov 2025 10:09:08 -0800 Subject: [PATCH] discover page progress --- core/personalized_playlists.py | 165 +++++++++++++++++++++++++++++++++ core/watchlist_scanner.py | 61 +++++++++++- database/music_database.py | 19 +++- web_server.py | 67 +++++++++++++ webui/index.html | 15 +++ webui/static/script.js | 153 +++++++++++++++++++++++++++++- webui/static/style.css | 44 +++++++++ 7 files changed, 516 insertions(+), 8 deletions(-) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index b86c99f8..4d9aa665 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -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 # ======================================== diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index d7e08620..c947329d 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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: diff --git a/database/music_database.py b/database/music_database.py index cc8e579c..e34c9aad 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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() diff --git a/web_server.py b/web_server.py index 4e4257e8..c4a53cea 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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""" diff --git a/webui/index.html b/webui/index.html index 8c77a3be..62ad7eef 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2071,6 +2071,21 @@ + + +
+
+

🎵 Browse by Genre

+

Explore music by style

+
+ +
diff --git a/webui/static/script.js b/webui/static/script.js index 4f7502fe..c784e429 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = '

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

'; + 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 += ` +
+
+
${icon}
+
+
+

${displayName}

+

${genre.track_count} tracks

+

Curated

+
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading genre browser:', error); + const carousel = document.getElementById('genre-browser-carousel'); + if (carousel) { + carousel.innerHTML = '

Failed to load genres

'; + } + } +} + +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 // =============================== diff --git a/webui/static/style.css b/webui/static/style.css index 57e089a1..938bfb5b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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); +} +