discover page progress
This commit is contained in:
parent
ee266920a9
commit
cc62b3b48e
7 changed files with 516 additions and 8 deletions
|
|
@ -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
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue