fix genre discovery

This commit is contained in:
Broque Thomas 2025-11-17 12:22:49 -08:00
parent cc62b3b48e
commit 08eb014da8
2 changed files with 179 additions and 34 deletions

View file

@ -17,10 +17,104 @@ logger = get_logger("personalized_playlists")
class PersonalizedPlaylistsService:
"""Service for generating personalized playlists from library and discovery pool"""
# Genre consolidation mapping - maps specific Spotify genres to broad parent categories
GENRE_MAPPING = {
'Electronic/Dance': [
'house', 'techno', 'trance', 'edm', 'electro', 'dubstep', 'drum and bass',
'breakbeat', 'jungle', 'dnb', 'bass', 'garage', 'uk garage', 'future bass',
'trap', 'hardstyle', 'hardcore', 'rave', 'dance', 'electronic', 'electronica',
'synth', 'downtempo', 'chillwave', 'vaporwave', 'synthwave', 'idm', 'glitch'
],
'Hip Hop/Rap': [
'hip hop', 'rap', 'trap', 'drill', 'grime', 'boom bap', 'underground hip hop',
'conscious hip hop', 'gangsta rap', 'southern hip hop', 'east coast', 'west coast',
'crunk', 'hyphy', 'cloud rap', 'emo rap', 'mumble rap'
],
'Rock': [
'rock', 'alternative rock', 'indie rock', 'garage rock', 'post-punk', 'punk',
'hard rock', 'psychedelic rock', 'progressive rock', 'art rock', 'glam rock',
'blues rock', 'southern rock', 'surf rock', 'rockabilly', 'grunge', 'shoegaze',
'noise rock', 'post-rock', 'math rock', 'emo', 'screamo'
],
'Pop': [
'pop', 'dance pop', 'electropop', 'synth pop', 'indie pop', 'chamber pop',
'art pop', 'baroque pop', 'dream pop', 'power pop', 'bubblegum pop', 'k-pop',
'j-pop', 'hyperpop', 'pop rock', 'teen pop'
],
'R&B/Soul': [
'r&b', 'soul', 'neo soul', 'contemporary r&b', 'alternative r&b', 'funk',
'disco', 'motown', 'northern soul', 'quiet storm', 'new jack swing'
],
'Jazz': [
'jazz', 'bebop', 'cool jazz', 'hard bop', 'modal jazz', 'free jazz',
'fusion', 'jazz fusion', 'smooth jazz', 'contemporary jazz', 'latin jazz',
'afro-cuban jazz', 'swing', 'big band', 'ragtime', 'dixieland'
],
'Classical': [
'classical', 'baroque', 'romantic', 'contemporary classical', 'minimalism',
'opera', 'orchestral', 'chamber music', 'choral', 'renaissance', 'medieval'
],
'Metal': [
'metal', 'heavy metal', 'thrash metal', 'death metal', 'black metal',
'doom metal', 'power metal', 'progressive metal', 'metalcore', 'deathcore',
'djent', 'nu metal', 'industrial metal', 'symphonic metal', 'gothic metal'
],
'Country': [
'country', 'bluegrass', 'americana', 'outlaw country', 'country rock',
'alt-country', 'contemporary country', 'traditional country', 'honky tonk',
'western', 'nashville sound'
],
'Folk/Indie': [
'folk', 'indie folk', 'folk rock', 'freak folk', 'anti-folk', 'singer-songwriter',
'acoustic', 'indie', 'lo-fi', 'bedroom pop', 'slowcore', 'sadcore'
],
'Latin': [
'latin', 'reggaeton', 'salsa', 'bachata', 'merengue', 'cumbia', 'banda',
'regional mexican', 'mariachi', 'ranchera', 'corrido', 'latin pop',
'latin trap', 'urbano latino', 'bossa nova', 'samba', 'tango'
],
'Reggae/Dancehall': [
'reggae', 'dancehall', 'dub', 'roots reggae', 'ska', 'rocksteady',
'lovers rock', 'reggae fusion'
],
'World': [
'afrobeat', 'afropop', 'african', 'world', 'worldbeat', 'ethnic',
'traditional', 'folk music', 'celtic', 'klezmer', 'flamenco', 'fado',
'indian classical', 'raga', 'qawwali', 'k-indie', 'j-indie'
],
'Alternative': [
'alternative', 'experimental', 'avant-garde', 'noise', 'ambient',
'industrial', 'new wave', 'no wave', 'gothic', 'darkwave', 'coldwave',
'witch house', 'trip hop', 'downtempo'
],
'Blues': [
'blues', 'delta blues', 'chicago blues', 'electric blues', 'blues rock',
'rhythm and blues', 'soul blues', 'gospel blues'
],
'Funk/Disco': [
'funk', 'disco', 'p-funk', 'boogie', 'electro-funk', 'g-funk'
]
}
def __init__(self, database, spotify_client=None):
self.database = database
self.spotify_client = spotify_client
@staticmethod
def get_parent_genre(spotify_genre: str) -> str:
"""
Map a specific Spotify genre to its parent category.
Returns the parent genre or 'Other' if no match found.
"""
spotify_genre_lower = spotify_genre.lower()
for parent_genre, keywords in PersonalizedPlaylistsService.GENRE_MAPPING.items():
for keyword in keywords:
if keyword in spotify_genre_lower:
return parent_genre
return 'Other'
# ========================================
# LIBRARY-BASED PLAYLISTS
# ========================================
@ -165,8 +259,9 @@ class PersonalizedPlaylistsService:
def get_available_genres(self) -> List[Dict]:
"""
Get list of genres with track counts from discovery pool.
Get list of consolidated parent genres with track counts from discovery pool.
Uses cached artist genres from database (populated during discovery scan).
Consolidates specific Spotify genres into broader parent categories.
"""
try:
with self.database._get_connection() as conn:
@ -184,30 +279,38 @@ class PersonalizedPlaylistsService:
logger.warning("No genres found in discovery pool - genres may not be populated yet")
return []
# Count tracks per genre
genre_track_count = {} # {genre: count}
# Count tracks per PARENT genre (consolidated)
parent_genre_track_count = {} # {parent_genre: count}
for row in rows:
try:
artist_genres_json = row[0]
if artist_genres_json:
genres = json.loads(artist_genres_json)
# Map each Spotify genre to parent and count tracks
mapped_parents = set() # Use set to avoid double-counting per track
for genre in genres:
genre_track_count[genre] = genre_track_count.get(genre, 0) + 1
parent_genre = self.get_parent_genre(genre)
mapped_parents.add(parent_genre)
# Add this track to all parent genres
for parent_genre in mapped_parents:
parent_genre_track_count[parent_genre] = parent_genre_track_count.get(parent_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
# Exclude 'Other' category
available_genres = [
{'name': genre, 'track_count': count}
for genre, count in genre_track_count.items()
if count >= 10
for genre, count in parent_genre_track_count.items()
if count >= 10 and genre != 'Other'
]
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
logger.info(f"Found {len(available_genres)} consolidated genres with 10+ tracks")
return available_genres[:20] # Top 20 parent genres
except Exception as e:
logger.error(f"Error getting available genres: {e}")
@ -217,12 +320,13 @@ class PersonalizedPlaylistsService:
"""
Get tracks from a specific genre with diversity filtering.
Uses cached artist genres from database (populated during discovery scan).
Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house").
"""
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Get all tracks with this genre (query cached genres)
# Get all tracks with genres from discovery pool
cursor.execute("""
SELECT
spotify_track_id,
@ -238,18 +342,38 @@ class PersonalizedPlaylistsService:
""")
rows = cursor.fetchall()
# Filter tracks that match the genre (using partial matching)
# Determine if this is a parent genre or specific genre
is_parent_genre = genre in self.GENRE_MAPPING
search_keywords = []
if is_parent_genre:
# Use all child genre keywords for matching
search_keywords = self.GENRE_MAPPING[genre]
logger.info(f"Matching parent genre '{genre}' with {len(search_keywords)} child keywords")
else:
# Use the genre name itself for partial matching
search_keywords = [genre.lower()]
logger.info(f"Matching specific genre '{genre}' with partial matching")
# Filter tracks that match the genre
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)
# Check if any artist genre matches any search keyword
genre_match = False
for artist_genre in genres:
artist_genre_lower = artist_genre.lower()
for keyword in search_keywords:
if keyword in artist_genre_lower:
genre_match = True
break
if genre_match:
break
if genre_match:
# Convert row to dict (exclude artist_genres from output)

View file

@ -25410,46 +25410,64 @@ async function loadGenreBrowser() {
function getGenreIcon(genreName) {
const genre = genreName.toLowerCase();
// Electronic/Dance
// Parent genre exact matches (consolidated categories)
if (genre === 'electronic/dance') return '🎹';
if (genre === 'hip hop/rap') return '🎤';
if (genre === 'rock') return '🎸';
if (genre === 'pop') return '🎵';
if (genre === 'r&b/soul') return '🎙️';
if (genre === 'jazz') return '🎺';
if (genre === 'classical') return '🎻';
if (genre === 'metal') return '🤘';
if (genre === 'country') return '🪕';
if (genre === 'folk/indie') return '🎧';
if (genre === 'latin') return '💃';
if (genre === 'reggae/dancehall') return '🌴';
if (genre === 'world') return '🌍';
if (genre === 'alternative') return '🎭';
if (genre === 'blues') return '🎸';
if (genre === 'funk/disco') return '🕺';
// Fallback: partial matching for specific genres
if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') ||
genre.includes('electro') || genre.includes('trance')) {
genre.includes('electro') || genre.includes('trance') || genre.includes('electronic')) {
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')) {
if (genre.includes('rock') || genre.includes('punk')) {
return '🎸';
}
// Jazz/Blues
if (genre.includes('jazz') || genre.includes('blues') || genre.includes('soul')) {
if (genre.includes('metal')) {
return '🤘';
}
if (genre.includes('jazz') || genre.includes('blues')) {
return '🎺';
}
// Pop
if (genre.includes('pop')) {
return '🎵';
}
// R&B
if (genre.includes('r&b') || genre.includes('rnb')) {
if (genre.includes('r&b') || genre.includes('soul')) {
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 '☁️';
if (genre.includes('latin') || genre.includes('reggaeton') || genre.includes('salsa')) {
return '💃';
}
if (genre.includes('reggae') || genre.includes('dancehall')) {
return '🌴';
}
if (genre.includes('funk') || genre.includes('disco')) {
return '🕺';
}
// Default
@ -25457,10 +25475,13 @@ function getGenreIcon(genreName) {
}
function capitalizeGenre(genre) {
// Capitalize each word in genre
return genre.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Capitalize each word in genre, handling both spaces and slashes
return genre.split(/(\s|\/)/g)
.map(part => {
if (part === ' ' || part === '/') return part;
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join('');
}
function escapeHtml(text) {