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:
parent
c6de707f94
commit
288776a7f3
12 changed files with 356 additions and 29 deletions
|
|
@ -480,10 +480,14 @@ class AudioDBWorker:
|
||||||
# Backfill genres if artist has none
|
# Backfill genres if artist has none
|
||||||
genre = data.get('strGenre')
|
genre = data.get('strGenre')
|
||||||
if genre:
|
if genre:
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE artists SET genres = ?
|
from config.settings import config_manager as _cfg
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
_filtered = filter_genres([genre], _cfg)
|
||||||
""", (json.dumps([genre]), artist_id))
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
@ -528,10 +532,14 @@ class AudioDBWorker:
|
||||||
# Backfill genres if album has none
|
# Backfill genres if album has none
|
||||||
genre = data.get('strGenre')
|
genre = data.get('strGenre')
|
||||||
if genre:
|
if genre:
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE albums SET genres = ?
|
from config.settings import config_manager as _cfg
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
_filtered = filter_genres([genre], _cfg)
|
||||||
""", (json.dumps([genre]), album_id))
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,10 @@ class DeezerWorker:
|
||||||
genres_data = full_data.get('genres', {}).get('data', [])
|
genres_data = full_data.get('genres', {}).get('data', [])
|
||||||
if genres_data:
|
if genres_data:
|
||||||
genre_names = [g.get('name') for g in genres_data if g.get('name')]
|
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:
|
if genre_names:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE albums SET genres = ?
|
UPDATE albums SET genres = ?
|
||||||
|
|
|
||||||
|
|
@ -438,10 +438,14 @@ class DiscogsWorker:
|
||||||
|
|
||||||
# Backfill genres if empty
|
# Backfill genres if empty
|
||||||
if data.get('genres'):
|
if data.get('genres'):
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE albums SET genres = ?
|
from config.settings import config_manager as _cfg
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
_filtered = filter_genres(data.get('genres', []), _cfg)
|
||||||
""", (genres, album_id))
|
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
|
# Backfill thumb_url if empty
|
||||||
if image_url:
|
if image_url:
|
||||||
|
|
|
||||||
125
core/genre_filter.py
Normal file
125
core/genre_filter.py
Normal 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
|
||||||
|
|
@ -613,10 +613,14 @@ class iTunesWorker:
|
||||||
|
|
||||||
# Backfill genres if empty
|
# Backfill genres if empty
|
||||||
if artist_obj.genres:
|
if artist_obj.genres:
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE artists SET genres = ?
|
from config.settings import config_manager as _cfg
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
_filtered = filter_genres(list(artist_obj.genres), _cfg)
|
||||||
""", (json.dumps(artist_obj.genres), artist_id))
|
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()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -521,10 +521,13 @@ class LastFMWorker:
|
||||||
|
|
||||||
# Backfill genres from tags
|
# Backfill genres from tags
|
||||||
if tags:
|
if tags:
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE albums SET genres = ?
|
filtered_tags = filter_genres(tags[:10], config_manager)
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
if filtered_tags:
|
||||||
""", (json.dumps(tags[:10]), album_id))
|
cursor.execute("""
|
||||||
|
UPDATE albums SET genres = ?
|
||||||
|
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
||||||
|
""", (json.dumps(filtered_tags), album_id))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -626,10 +626,14 @@ class QobuzWorker:
|
||||||
genre = data.get('genre', {})
|
genre = data.get('genre', {})
|
||||||
genre_name = genre.get('name', '') if isinstance(genre, dict) else str(genre) if genre else ''
|
genre_name = genre.get('name', '') if isinstance(genre, dict) else str(genre) if genre else ''
|
||||||
if genre_name:
|
if genre_name:
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE albums SET genres = ?
|
from config.settings import config_manager as _cfg
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
_filtered = filter_genres([genre_name], _cfg)
|
||||||
""", (json.dumps([genre_name]), album_id))
|
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')
|
upc = data.get('upc')
|
||||||
if upc:
|
if upc:
|
||||||
|
|
|
||||||
|
|
@ -726,10 +726,14 @@ class SpotifyWorker:
|
||||||
|
|
||||||
# Backfill genres if empty
|
# Backfill genres if empty
|
||||||
if artist_obj.genres:
|
if artist_obj.genres:
|
||||||
cursor.execute("""
|
from core.genre_filter import filter_genres
|
||||||
UPDATE artists SET genres = ?
|
from config.settings import config_manager as _cfg
|
||||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
_filtered = filter_genres(list(artist_obj.genres), _cfg)
|
||||||
""", (json.dumps(artist_obj.genres), artist_id))
|
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()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -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', '')
|
spotify_track_id = track_info.get('id', '') or original_search.get('id', '')
|
||||||
|
|
||||||
genres = (spotify_artist or {}).get('genres', [])
|
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 ''
|
genres_json = json.dumps(genres) if genres else ''
|
||||||
|
|
||||||
bitrate = 0
|
bitrate = 0
|
||||||
|
|
@ -6257,6 +6260,13 @@ def handle_log_level():
|
||||||
# AUTOMATIONS API
|
# 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'])
|
@app.route('/api/automations', methods=['GET'])
|
||||||
def list_automations():
|
def list_automations():
|
||||||
"""List all automations for the current profile."""
|
"""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]
|
metadata['date'] = spotify_album['release_date'][:4]
|
||||||
|
|
||||||
if artist.get('genres'):
|
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')
|
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 []) + \
|
([audiodb_genre] if audiodb_genre and _tag_enabled('audiodb.tags.genre') else []) + \
|
||||||
(lastfm_tags if _tag_enabled('lastfm.tags.genres') else [])
|
(lastfm_tags if _tag_enabled('lastfm.tags.genres') else [])
|
||||||
if enrichment_genres:
|
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()]
|
spotify_genres = [g.strip() for g in metadata.get('genre', '').split(',') if g.strip()]
|
||||||
seen = set()
|
seen = set()
|
||||||
merged = []
|
merged = []
|
||||||
|
|
|
||||||
|
|
@ -5672,6 +5672,32 @@
|
||||||
</div>
|
</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 -->
|
<!-- Import Quality Settings -->
|
||||||
<div class="settings-group" data-stg="library">
|
<div class="settings-group" data-stg="library">
|
||||||
<h3>📥 Import Settings</h3>
|
<h3>📥 Import Settings</h3>
|
||||||
|
|
|
||||||
|
|
@ -6180,6 +6180,15 @@ async function loadSettingsData() {
|
||||||
// Populate Content Filter settings
|
// Populate Content Filter settings
|
||||||
document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false;
|
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
|
// Populate Import settings
|
||||||
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
|
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
|
||||||
|
|
||||||
|
|
@ -6882,6 +6891,88 @@ function collectMusicPaths() {
|
||||||
return paths;
|
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, "\\'"))}')">×</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 ──
|
// ── Database Maintenance ──
|
||||||
async function loadDbMaintenanceInfo() {
|
async function loadDbMaintenanceInfo() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -7361,6 +7452,10 @@ async function saveSettings(quiet = false) {
|
||||||
content_filter: {
|
content_filter: {
|
||||||
allow_explicit: document.getElementById('allow-explicit').checked
|
allow_explicit: document.getElementById('allow-explicit').checked
|
||||||
},
|
},
|
||||||
|
genre_whitelist: {
|
||||||
|
enabled: document.getElementById('genre-whitelist-enabled').checked,
|
||||||
|
genres: _collectGenreWhitelist(),
|
||||||
|
},
|
||||||
library: {
|
library: {
|
||||||
music_paths: collectMusicPaths(),
|
music_paths: collectMusicPaths(),
|
||||||
music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos'
|
music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos'
|
||||||
|
|
|
||||||
|
|
@ -17178,6 +17178,41 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
white-space: nowrap;
|
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 {
|
.config-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue