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
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ?
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, "\\'"))}')">×</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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue