From 288776a7f38be2f8ba1df8551f103a12036f1209 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:23:53 -0700 Subject: [PATCH] Add genre whitelist for filtering junk tags during enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New core/genre_filter.py with ~180 curated default genres. When strict mode is enabled in Settings → Library Preferences → Genre Whitelist, only whitelisted genres pass through during enrichment. Junk tags from Last.fm (artist names, radio shows, playlist names) are silently dropped. Applied at all 10 genre write points: Spotify, Last.fm, AudioDB, Deezer, Discogs, iTunes, Qobuz enrichment workers + post-processing genre merge + initial download artist/album creation. Strict mode is OFF by default — zero behavior change for existing users. First enable auto-populates the whitelist with defaults. Users can add, remove, search, and reset genres via the Settings UI. --- core/audiodb_worker.py | 24 +++++--- core/deezer_worker.py | 4 ++ core/discogs_worker.py | 12 ++-- core/genre_filter.py | 125 +++++++++++++++++++++++++++++++++++++++++ core/itunes_worker.py | 12 ++-- core/lastfm_worker.py | 11 ++-- core/qobuz_worker.py | 12 ++-- core/spotify_worker.py | 12 ++-- web_server.py | 17 +++++- webui/index.html | 26 +++++++++ webui/static/script.js | 95 +++++++++++++++++++++++++++++++ webui/static/style.css | 35 ++++++++++++ 12 files changed, 356 insertions(+), 29 deletions(-) create mode 100644 core/genre_filter.py diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index 16d665ed..cfb323bc 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -480,10 +480,14 @@ class AudioDBWorker: # Backfill genres if artist has none genre = data.get('strGenre') if genre: - cursor.execute(""" - UPDATE artists SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (json.dumps([genre]), artist_id)) + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + _filtered = filter_genres([genre], _cfg) + if _filtered: + cursor.execute(""" + UPDATE artists SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(_filtered), artist_id)) conn.commit() @@ -528,10 +532,14 @@ class AudioDBWorker: # Backfill genres if album has none genre = data.get('strGenre') if genre: - cursor.execute(""" - UPDATE albums SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (json.dumps([genre]), album_id)) + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + _filtered = filter_genres([genre], _cfg) + if _filtered: + cursor.execute(""" + UPDATE albums SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(_filtered), album_id)) conn.commit() diff --git a/core/deezer_worker.py b/core/deezer_worker.py index b70ea57a..b5e942fc 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -569,6 +569,10 @@ class DeezerWorker: genres_data = full_data.get('genres', {}).get('data', []) if genres_data: genre_names = [g.get('name') for g in genres_data if g.get('name')] + if genre_names: + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + genre_names = filter_genres(genre_names, _cfg) if genre_names: cursor.execute(""" UPDATE albums SET genres = ? diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 98112c94..99014882 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -438,10 +438,14 @@ class DiscogsWorker: # Backfill genres if empty if data.get('genres'): - cursor.execute(""" - UPDATE albums SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (genres, album_id)) + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + _filtered = filter_genres(data.get('genres', []), _cfg) + if _filtered: + cursor.execute(""" + UPDATE albums SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(_filtered), album_id)) # Backfill thumb_url if empty if image_url: diff --git a/core/genre_filter.py b/core/genre_filter.py new file mode 100644 index 00000000..15bdfc81 --- /dev/null +++ b/core/genre_filter.py @@ -0,0 +1,125 @@ +"""Genre whitelist filter for enrichment workers. + +When strict mode is enabled, only genres on the whitelist pass through +during enrichment. When disabled (default), all genres pass unchanged. +""" + +from utils.logging_config import get_logger + +logger = get_logger("genre_filter") + +# ~180 curated genres covering all major categories. +# This is the default whitelist — users can add/remove via Settings. +DEFAULT_GENRES = [ + # Rock + "Rock", "Alternative Rock", "Indie Rock", "Classic Rock", "Punk Rock", "Post-Punk", + "Psychedelic Rock", "Progressive Rock", "Garage Rock", "Grunge", "Shoegaze", "Surf Rock", + "Stoner Rock", "Southern Rock", "Hard Rock", "Soft Rock", "Art Rock", "Glam Rock", + "Noise Rock", "Math Rock", "Post-Rock", "Folk Rock", "Heartland Rock", "Brit Rock", + "Space Rock", "Krautrock", + # Punk + "Punk", "Hardcore Punk", "Pop Punk", "Ska Punk", "Post-Hardcore", + # Emo + "Emo", "Midwest Emo", "Screamo", + # Metal + "Metal", "Heavy Metal", "Death Metal", "Black Metal", "Thrash Metal", "Doom Metal", + "Power Metal", "Speed Metal", "Progressive Metal", "Symphonic Metal", "Metalcore", + "Deathcore", "Nu Metal", "Industrial Metal", "Gothic Metal", "Sludge Metal", + "Folk Metal", "Djent", "Groove Metal", "Post-Metal", + # Pop + "Pop", "Synth Pop", "Electropop", "Indie Pop", "Dream Pop", "Chamber Pop", "Art Pop", + "Dance Pop", "Power Pop", "Baroque Pop", "Bedroom Pop", "K-Pop", "J-Pop", "Teen Pop", + "Bubblegum Pop", + # Hip Hop / Rap + "Hip Hop", "Rap", "Trap", "Boom Bap", "Gangsta Rap", "Conscious Hip Hop", + "Southern Hip Hop", "West Coast Hip Hop", "East Coast Hip Hop", "Dirty South", "Crunk", + "Grime", "Drill", "Lo-Fi Hip Hop", "Abstract Hip Hop", + # Electronic / Dance + "Electronic", "EDM", "House", "Deep House", "Tech House", "Progressive House", + "Techno", "Trance", "Drum and Bass", "Dubstep", "Ambient", "IDM", "Downtempo", + "Trip Hop", "Breakbeat", "Jungle", "Garage", "UK Garage", "Future Bass", "Hardstyle", + "Electro", "Electronica", "Chillwave", "Synthwave", "Vaporwave", "Industrial", "EBM", + "Glitch", "Footwork", "Chillout", "Lo-Fi", "New Age", + # R&B / Soul / Funk + "R&B", "Soul", "Neo Soul", "Funk", "Disco", "Motown", "Gospel", "Quiet Storm", + "Contemporary R&B", "New Jack Swing", + # Jazz + "Jazz", "Bebop", "Cool Jazz", "Free Jazz", "Fusion", "Smooth Jazz", "Acid Jazz", + "Nu Jazz", "Swing", "Big Band", "Latin Jazz", "Vocal Jazz", + # Blues + "Blues", "Delta Blues", "Chicago Blues", "Electric Blues", "Blues Rock", "Country Blues", + # Country + "Country", "Alt-Country", "Americana", "Bluegrass", "Country Rock", "Outlaw Country", + "Country Pop", "Honky Tonk", "Western Swing", "Nashville Sound", + # Folk / Singer-Songwriter + "Folk", "Indie Folk", "Contemporary Folk", "Singer-Songwriter", "Acoustic", + "Freak Folk", "Folk Punk", "Neofolk", + # Classical + "Classical", "Opera", "Baroque", "Romantic", "Contemporary Classical", "Minimalism", + "Orchestral", "Chamber Music", "Choral", "Soundtrack", "Film Score", "Musical Theatre", + # Latin + "Latin", "Reggaeton", "Salsa", "Bachata", "Cumbia", "Merengue", "Latin Pop", + "Latin Rock", "Bossa Nova", "Samba", "MPB", "Tango", "Banda", "Norteño", "Corrido", + "Tropical", + # Reggae / Caribbean + "Reggae", "Dancehall", "Dub", "Ska", "Rocksteady", "Calypso", "Soca", + # World / International + "World", "Afrobeat", "Afropop", "Afrobeats", "Bhangra", "Celtic", "Flamenco", + "Fado", "Klezmer", "Polka", "Zydeco", "Highlife", + # Other + "Experimental", "Avant-Garde", "Noise", "Spoken Word", "Comedy", "Instrumental", + "A Cappella", "Worship", "Christian", "Christmas", "Holiday", "Easy Listening", + "Lounge", "Psychedelic", +] + +# Normalized lookup set — built once, used for O(1) matching +_DEFAULT_LOOKUP = {g.lower() for g in DEFAULT_GENRES} + + +def _normalize_for_match(genre: str) -> str: + """Normalize a genre string for whitelist comparison. + + Handles common variations: 'R&B' vs 'RnB', 'Hip-Hop' vs 'Hip Hop'. + """ + g = genre.lower().strip() + g = g.replace('-', ' ').replace('&', ' and ') + # Collapse multiple spaces + return ' '.join(g.split()) + + +def _build_lookup(genre_list): + """Build a normalized lookup set from a genre list.""" + return {_normalize_for_match(g) for g in genre_list} + + +def filter_genres(genres, config_manager=None): + """Filter a list of genres against the whitelist. + + Args: + genres: List of genre strings to filter + config_manager: ConfigManager instance (None = no filtering) + + Returns: + Filtered list of genres. When strict mode is off, returns genres unchanged. + """ + if not genres or not isinstance(genres, list): + return genres or [] + + # Check if strict mode is enabled + if config_manager is None: + return genres + + enabled = config_manager.get('genre_whitelist.enabled', False) + if not enabled: + return genres + + # Get user's whitelist (falls back to defaults if not configured) + user_genres = config_manager.get('genre_whitelist.genres', None) + if user_genres and isinstance(user_genres, list): + lookup = _build_lookup(user_genres) + else: + lookup = _DEFAULT_LOOKUP + + # Filter — keep genres that match the whitelist (case-insensitive with normalization) + filtered = [g for g in genres if _normalize_for_match(g) in lookup] + return filtered diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 9a8bd085..9a4769b4 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -613,10 +613,14 @@ class iTunesWorker: # Backfill genres if empty if artist_obj.genres: - cursor.execute(""" - UPDATE artists SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (json.dumps(artist_obj.genres), artist_id)) + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + _filtered = filter_genres(list(artist_obj.genres), _cfg) + if _filtered: + cursor.execute(""" + UPDATE artists SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(_filtered), artist_id)) conn.commit() except Exception as e: diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py index 6c0f4de3..f876c380 100644 --- a/core/lastfm_worker.py +++ b/core/lastfm_worker.py @@ -521,10 +521,13 @@ class LastFMWorker: # Backfill genres from tags if tags: - cursor.execute(""" - UPDATE albums SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (json.dumps(tags[:10]), album_id)) + from core.genre_filter import filter_genres + filtered_tags = filter_genres(tags[:10], config_manager) + if filtered_tags: + cursor.execute(""" + UPDATE albums SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(filtered_tags), album_id)) conn.commit() diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index d47b58b4..15f7da98 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -626,10 +626,14 @@ class QobuzWorker: genre = data.get('genre', {}) genre_name = genre.get('name', '') if isinstance(genre, dict) else str(genre) if genre else '' if genre_name: - cursor.execute(""" - UPDATE albums SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (json.dumps([genre_name]), album_id)) + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + _filtered = filter_genres([genre_name], _cfg) + if _filtered: + cursor.execute(""" + UPDATE albums SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(_filtered), album_id)) upc = data.get('upc') if upc: diff --git a/core/spotify_worker.py b/core/spotify_worker.py index f51e78a5..89a7935b 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -726,10 +726,14 @@ class SpotifyWorker: # Backfill genres if empty if artist_obj.genres: - cursor.execute(""" - UPDATE artists SET genres = ? - WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') - """, (json.dumps(artist_obj.genres), artist_id)) + from core.genre_filter import filter_genres + from config.settings import config_manager as _cfg + _filtered = filter_genres(list(artist_obj.genres), _cfg) + if _filtered: + cursor.execute(""" + UPDATE artists SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(_filtered), artist_id)) conn.commit() except Exception as e: diff --git a/web_server.py b/web_server.py index 28e9d5c7..f75ae5c5 100644 --- a/web_server.py +++ b/web_server.py @@ -2237,6 +2237,9 @@ def _record_soulsync_library_entry(context, spotify_artist, album_info): spotify_track_id = track_info.get('id', '') or original_search.get('id', '') genres = (spotify_artist or {}).get('genres', []) + if genres: + from core.genre_filter import filter_genres as _gf2 + genres = _gf2(genres, config_manager) genres_json = json.dumps(genres) if genres else '' bitrate = 0 @@ -6257,6 +6260,13 @@ def handle_log_level(): # AUTOMATIONS API # =========================== +@app.route('/api/genre-whitelist/defaults', methods=['GET']) +def get_genre_whitelist_defaults(): + """Return the default genre whitelist.""" + from core.genre_filter import DEFAULT_GENRES + return jsonify({'genres': sorted(DEFAULT_GENRES, key=str.lower)}) + + @app.route('/api/automations', methods=['GET']) def list_automations(): """List all automations for the current profile.""" @@ -19204,7 +19214,10 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> metadata['date'] = spotify_album['release_date'][:4] if artist.get('genres'): - metadata['genre'] = ', '.join(artist['genres'][:2]) + from core.genre_filter import filter_genres + _genre_list = filter_genres(list(artist['genres'][:2]), config_manager) + if _genre_list: + metadata['genre'] = ', '.join(_genre_list) metadata['album_art_url'] = album_info.get('album_image_url') @@ -19945,6 +19958,8 @@ def _embed_source_ids(audio_file, metadata: dict, context: dict = None): ([audiodb_genre] if audiodb_genre and _tag_enabled('audiodb.tags.genre') else []) + \ (lastfm_tags if _tag_enabled('lastfm.tags.genres') else []) if enrichment_genres: + from core.genre_filter import filter_genres as _gf + enrichment_genres = _gf(enrichment_genres, config_manager) spotify_genres = [g.strip() for g in metadata.get('genre', '').split(',') if g.strip()] seen = set() merged = [] diff --git a/webui/index.html b/webui/index.html index 5d4744a3..83fd2e68 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5672,6 +5672,32 @@ + +
+

🎵 Genre Whitelist

+ +
+ +
+
+ When enabled, only genres on the whitelist below are kept during enrichment. Junk tags (artist names, radio shows, playlist names) are silently dropped. When disabled, all genres pass through unchanged. +
+ + +
+

📥 Import Settings

diff --git a/webui/static/script.js b/webui/static/script.js index 4e9abacc..f383acdc 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -6180,6 +6180,15 @@ async function loadSettingsData() { // Populate Content Filter settings document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false; + // Populate Genre Whitelist + const gwEnabled = settings.genre_whitelist?.enabled === true; + document.getElementById('genre-whitelist-enabled').checked = gwEnabled; + const gwContainer = document.getElementById('genre-whitelist-container'); + if (gwContainer) gwContainer.style.display = gwEnabled ? '' : 'none'; + if (gwEnabled) { + _genreWhitelistRender(settings.genre_whitelist?.genres || []); + } + // Populate Import settings document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true; @@ -6882,6 +6891,88 @@ function collectMusicPaths() { return paths; } +// ── Genre Whitelist ── +let _genreWhitelistCache = []; + +function _genreWhitelistRender(genres) { + _genreWhitelistCache = genres && genres.length ? genres : []; + const container = document.getElementById('genre-whitelist-chips'); + const countEl = document.getElementById('genre-whitelist-count'); + if (!container) return; + if (!_genreWhitelistCache.length) { + container.innerHTML = '
No genres configured. Click "Reset to Defaults" to load the default whitelist.
'; + if (countEl) countEl.textContent = ''; + return; + } + const searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase(); + const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache; + container.innerHTML = filtered.map(g => + `${escapeHtml(g)}` + ).join(''); + if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`; +} + +function _genreWhitelistRemove(genre) { + _genreWhitelistCache = _genreWhitelistCache.filter(g => g !== genre); + _genreWhitelistRender(_genreWhitelistCache); + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); +} + +function _genreWhitelistAdd(genre) { + genre = genre.trim(); + if (!genre) return; + if (_genreWhitelistCache.some(g => g.toLowerCase() === genre.toLowerCase())) return; + _genreWhitelistCache.push(genre); + _genreWhitelistCache.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + _genreWhitelistRender(_genreWhitelistCache); + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); +} + +async function _genreWhitelistReset() { + try { + const resp = await fetch('/api/genre-whitelist/defaults'); + const data = await resp.json(); + if (data.genres) { + _genreWhitelistCache = data.genres; + _genreWhitelistRender(_genreWhitelistCache); + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); + showToast(`Loaded ${data.genres.length} default genres`, 'success'); + } + } catch (e) { + showToast('Failed to load defaults', 'error'); + } +} + +// Toggle whitelist container visibility + init +document.addEventListener('change', (e) => { + if (e.target.id === 'genre-whitelist-enabled') { + const container = document.getElementById('genre-whitelist-container'); + if (container) container.style.display = e.target.checked ? '' : 'none'; + // Auto-populate with defaults on first enable if empty + if (e.target.checked && _genreWhitelistCache.length === 0) { + _genreWhitelistReset(); + } + } +}); + +// Search/add handler +document.addEventListener('keydown', (e) => { + if (e.target.id === 'genre-whitelist-search' && e.key === 'Enter') { + e.preventDefault(); + _genreWhitelistAdd(e.target.value); + e.target.value = ''; + } +}); +document.addEventListener('input', (e) => { + if (e.target.id === 'genre-whitelist-search') { + _genreWhitelistRender(_genreWhitelistCache); + } +}); + +function _collectGenreWhitelist() { + return _genreWhitelistCache; +} + // ── Database Maintenance ── async function loadDbMaintenanceInfo() { try { @@ -7361,6 +7452,10 @@ async function saveSettings(quiet = false) { content_filter: { allow_explicit: document.getElementById('allow-explicit').checked }, + genre_whitelist: { + enabled: document.getElementById('genre-whitelist-enabled').checked, + genres: _collectGenreWhitelist(), + }, library: { music_paths: collectMusicPaths(), music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos' diff --git a/webui/static/style.css b/webui/static/style.css index 2b926122..3c802c1d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -17178,6 +17178,41 @@ body.helper-mode-active #dashboard-activity-feed:hover { white-space: nowrap; } +.genre-whitelist-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 240px; + overflow-y: auto; + padding: 4px 0; +} + +.genre-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); + font-size: 12px; + white-space: nowrap; +} + +.genre-chip-x { + background: none; + border: none; + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + font-size: 14px; + padding: 0 2px; + line-height: 1; +} + +.genre-chip-x:hover { + color: #ef5350; +} + .config-options { display: flex; flex-direction: column;