Add genre whitelist for filtering junk tags during enrichment

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.
This commit is contained in:
Broque Thomas 2026-04-18 20:23:53 -07:00
parent c6de707f94
commit 288776a7f3
12 changed files with 356 additions and 29 deletions

View file

@ -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()

View file

@ -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 = ?

View file

@ -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:

125
core/genre_filter.py Normal file
View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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:

View file

@ -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:

View file

@ -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 = []

View file

@ -5672,6 +5672,32 @@
</div>
<!-- Genre Whitelist -->
<div class="settings-group" data-stg="library">
<h3>🎵 Genre Whitelist</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="genre-whitelist-enabled" onchange="if(typeof debouncedAutoSaveSettings==='function')debouncedAutoSaveSettings()">
Enable strict genre filtering
</label>
</div>
<div class="help-text" style="margin-bottom:12px;">
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.
</div>
<div id="genre-whitelist-container" style="display:none">
<div class="form-group" style="margin-bottom:8px;">
<input type="text" id="genre-whitelist-search" placeholder="Search or add genre..." class="form-input" style="margin-bottom:8px;">
</div>
<div id="genre-whitelist-chips" class="genre-whitelist-chips"></div>
<div class="form-actions" style="margin-top:8px;gap:8px;">
<button class="test-button" onclick="_genreWhitelistReset()">Reset to Defaults</button>
<span id="genre-whitelist-count" style="color:rgba(255,255,255,0.4);font-size:12px;"></span>
</div>
</div>
</div>
<!-- Import Quality Settings -->
<div class="settings-group" data-stg="library">
<h3>📥 Import Settings</h3>

View file

@ -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 = '<div style="color:rgba(255,255,255,0.3);font-size:13px;padding:4px 0;">No genres configured. Click "Reset to Defaults" to load the default whitelist.</div>';
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 =>
`<span class="genre-chip">${escapeHtml(g)}<button class="genre-chip-x" onclick="_genreWhitelistRemove('${escapeHtml(g.replace(/'/g, "\\'"))}')">&times;</button></span>`
).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'

View file

@ -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;