Add instrumental filter & custom exclusion terms to watchlist content filters

This commit is contained in:
Broque Thomas 2026-03-13 23:09:12 -07:00
parent 9856ec6772
commit a5e72cff05
6 changed files with 187 additions and 11 deletions

View file

@ -234,6 +234,63 @@ def is_acoustic_version(track_name: str, album_name: str = "") -> bool:
return False
def is_instrumental_version(track_name: str, album_name: str = "") -> bool:
"""
Detect if a track is an instrumental version.
Args:
track_name: Track name to check
album_name: Album name to check (optional)
Returns:
True if this is an instrumental version, False otherwise
"""
if not track_name:
return False
text_to_check = f"{track_name} {album_name}".lower()
instrumental_patterns = [
r'\binstrumental\b', # Instrumental, Instrumental Version
r'\binst\.\b', # Inst. (abbreviation)
r'\bkaraoke\b', # Karaoke version
r'\bbacking track\b', # Backing Track
]
for pattern in instrumental_patterns:
if re.search(pattern, text_to_check, re.IGNORECASE):
return True
return False
def matches_custom_exclude_terms(track_name: str, album_name: str, exclude_terms: list) -> str:
"""
Check if a track or album name contains any user-defined exclusion terms.
Args:
track_name: Track name to check
album_name: Album name to check
exclude_terms: List of terms to exclude (case-insensitive)
Returns:
The matched term if found, empty string if no match
"""
if not exclude_terms:
return ""
text_to_check = f"{track_name} {album_name}".lower()
for term in exclude_terms:
term = term.strip().lower()
if not term:
continue
if term in text_to_check:
return term
return ""
def is_compilation_album(album_name: str) -> bool:
"""
Detect if an album is a compilation/greatest hits album.
@ -1161,6 +1218,7 @@ class WatchlistScanner:
include_remixes = getattr(watchlist_artist, 'include_remixes', False)
include_acoustic = getattr(watchlist_artist, 'include_acoustic', False)
include_compilations = getattr(watchlist_artist, 'include_compilations', False)
include_instrumentals = getattr(watchlist_artist, 'include_instrumentals', False)
# Check compilation albums (album-level filter)
if not include_compilations:
@ -1184,6 +1242,25 @@ class WatchlistScanner:
logger.debug(f"Skipping acoustic version: {track_name}")
return False
# Check instrumental versions
if not include_instrumentals:
if is_instrumental_version(track_name, album_name):
logger.debug(f"Skipping instrumental version: {track_name}")
return False
# Check custom exclusion terms
try:
from config.settings import config_manager as _cfg
exclude_terms_str = _cfg.get('watchlist.exclude_terms', '')
if exclude_terms_str:
exclude_terms = [t.strip() for t in exclude_terms_str.split(',') if t.strip()]
matched_term = matches_custom_exclude_terms(track_name, album_name, exclude_terms)
if matched_term:
logger.debug(f"Skipping track '{track_name}' — matched custom exclusion term: '{matched_term}'")
return False
except Exception as e:
logger.warning(f"Error checking custom exclusion terms: {e}")
# Track passes all filters
return True

View file

@ -94,6 +94,7 @@ class WatchlistArtist:
include_remixes: bool = False
include_acoustic: bool = False
include_compilations: bool = False
include_instrumentals: bool = False
profile_id: int = 1
@dataclass
@ -1047,7 +1048,8 @@ class MusicDatabase:
'include_live': ('INTEGER', '0'), # 0 = False (exclude live versions by default)
'include_remixes': ('INTEGER', '0'), # 0 = False (exclude remixes by default)
'include_acoustic': ('INTEGER', '0'), # 0 = False (exclude acoustic by default)
'include_compilations': ('INTEGER', '0') # 0 = False (exclude compilations by default)
'include_compilations': ('INTEGER', '0'), # 0 = False (exclude compilations by default)
'include_instrumentals': ('INTEGER', '0') # 0 = False (exclude instrumentals by default)
}
for column_name, (column_type, default_value) in columns_to_add.items():
@ -5069,7 +5071,8 @@ class MusicDatabase:
base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'itunes_artist_id', 'include_albums', 'include_eps', 'include_singles',
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations']
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals']
columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]
@ -5101,6 +5104,7 @@ class MusicDatabase:
include_remixes = bool(row['include_remixes']) if 'include_remixes' in existing_columns else False
include_acoustic = bool(row['include_acoustic']) if 'include_acoustic' in existing_columns else False
include_compilations = bool(row['include_compilations']) if 'include_compilations' in existing_columns else False
include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False
watchlist_artists.append(WatchlistArtist(
id=row['id'],
@ -5119,6 +5123,7 @@ class MusicDatabase:
include_remixes=include_remixes,
include_acoustic=include_acoustic,
include_compilations=include_compilations,
include_instrumentals=include_instrumentals,
profile_id=profile_id
))

View file

@ -29012,7 +29012,7 @@ def watchlist_artist_config(artist_id):
SELECT include_albums, include_eps, include_singles,
include_live, include_remixes, include_acoustic, include_compilations,
artist_name, image_url, spotify_artist_id, itunes_artist_id,
last_scan_timestamp, date_added
last_scan_timestamp, date_added, include_instrumentals
FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
""", (artist_id, artist_id))
@ -29110,6 +29110,7 @@ def watchlist_artist_config(artist_id):
'include_remixes': bool(result[4]),
'include_acoustic': bool(result[5]),
'include_compilations': bool(result[6]),
'include_instrumentals': bool(result[13]) if result[13] is not None else False,
'last_scan_timestamp': result[11],
'date_added': result[12],
}
@ -29136,6 +29137,7 @@ def watchlist_artist_config(artist_id):
include_remixes = data.get('include_remixes', False)
include_acoustic = data.get('include_acoustic', False)
include_compilations = data.get('include_compilations', False)
include_instrumentals = data.get('include_instrumentals', False)
# Validate at least one release type is selected
if not (include_albums or include_eps or include_singles):
@ -29148,10 +29150,12 @@ def watchlist_artist_config(artist_id):
UPDATE watchlist_artists
SET include_albums = ?, include_eps = ?, include_singles = ?,
include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?,
include_instrumentals = ?,
updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
""", (int(include_albums), int(include_eps), int(include_singles),
int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations),
int(include_instrumentals),
artist_id, artist_id))
conn.commit()
@ -29161,7 +29165,7 @@ def watchlist_artist_config(artist_id):
conn.close()
print(f"✅ Updated watchlist config for artist {artist_id}: albums={include_albums}, eps={include_eps}, singles={include_singles}, live={include_live}, remixes={include_remixes}, acoustic={include_acoustic}, compilations={include_compilations}")
print(f"✅ Updated watchlist config for artist {artist_id}: albums={include_albums}, eps={include_eps}, singles={include_singles}, live={include_live}, remixes={include_remixes}, acoustic={include_acoustic}, compilations={include_compilations}, instrumentals={include_instrumentals}")
return jsonify({
"success": True,
@ -29173,7 +29177,8 @@ def watchlist_artist_config(artist_id):
'include_live': include_live,
'include_remixes': include_remixes,
'include_acoustic': include_acoustic,
'include_compilations': include_compilations
'include_compilations': include_compilations,
'include_instrumentals': include_instrumentals,
}
})
@ -29265,6 +29270,8 @@ def watchlist_global_config():
'include_remixes': config_manager.get('watchlist.global_include_remixes', False),
'include_acoustic': config_manager.get('watchlist.global_include_acoustic', False),
'include_compilations': config_manager.get('watchlist.global_include_compilations', False),
'include_instrumentals': config_manager.get('watchlist.global_include_instrumentals', False),
'exclude_terms': config_manager.get('watchlist.exclude_terms', ''),
}
return jsonify({"success": True, "config": config})
@ -29281,6 +29288,8 @@ def watchlist_global_config():
include_remixes = data.get('include_remixes', False)
include_acoustic = data.get('include_acoustic', False)
include_compilations = data.get('include_compilations', False)
include_instrumentals = data.get('include_instrumentals', False)
exclude_terms = data.get('exclude_terms', '')
# When override is enabled, validate at least one release type
if global_override_enabled and not (include_albums or include_eps or include_singles):
@ -29294,11 +29303,14 @@ def watchlist_global_config():
config_manager.set('watchlist.global_include_remixes', include_remixes)
config_manager.set('watchlist.global_include_acoustic', include_acoustic)
config_manager.set('watchlist.global_include_compilations', include_compilations)
config_manager.set('watchlist.global_include_instrumentals', include_instrumentals)
config_manager.set('watchlist.exclude_terms', exclude_terms)
print(f"✅ Updated global watchlist config: override={global_override_enabled}, "
f"albums={include_albums}, eps={include_eps}, singles={include_singles}, "
f"live={include_live}, remixes={include_remixes}, acoustic={include_acoustic}, "
f"compilations={include_compilations}")
f"compilations={include_compilations}, instrumentals={include_instrumentals}, "
f"exclude_terms='{exclude_terms}'")
return jsonify({
"success": True,
@ -29312,6 +29324,8 @@ def watchlist_global_config():
'include_remixes': include_remixes,
'include_acoustic': include_acoustic,
'include_compilations': include_compilations,
'include_instrumentals': include_instrumentals,
'exclude_terms': exclude_terms,
}
})
@ -29333,9 +29347,11 @@ def _apply_watchlist_global_overrides(watchlist_artists):
g_remixes = config_manager.get('watchlist.global_include_remixes', False)
g_acoustic = config_manager.get('watchlist.global_include_acoustic', False)
g_compilations = config_manager.get('watchlist.global_include_compilations', False)
g_instrumentals = config_manager.get('watchlist.global_include_instrumentals', False)
print(f"🌐 [Watchlist] Global override is ACTIVE — applying to {len(watchlist_artists)} artists "
f"(albums={g_albums}, eps={g_eps}, singles={g_singles}, live={g_live}, "
f"remixes={g_remixes}, acoustic={g_acoustic}, compilations={g_compilations})")
f"remixes={g_remixes}, acoustic={g_acoustic}, compilations={g_compilations}, "
f"instrumentals={g_instrumentals})")
for artist in watchlist_artists:
artist.include_albums = g_albums
artist.include_eps = g_eps
@ -29344,6 +29360,7 @@ def _apply_watchlist_global_overrides(watchlist_artists):
artist.include_remixes = g_remixes
artist.include_acoustic = g_acoustic
artist.include_compilations = g_compilations
artist.include_instrumentals = g_instrumentals
def _update_similar_artists_worker():
"""Background worker to update similar artists for all watchlist artists"""

View file

@ -5073,6 +5073,18 @@
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="config-include-instrumentals">
<div class="config-option-content">
<div class="config-option-icon">🎹</div>
<div class="config-option-text">
<span class="config-option-title">Include Instrumentals</span>
<span class="config-option-description">Check to include instrumental, karaoke,
and backing track versions</span>
</div>
</div>
</label>
</div>
</div>
@ -5227,6 +5239,17 @@
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="global-include-instrumentals">
<div class="config-option-content">
<div class="config-option-icon">🎹</div>
<div class="config-option-text">
<span class="config-option-title">Include Instrumentals</span>
<span class="config-option-description">Instrumental, karaoke, and backing track versions</span>
</div>
</div>
</label>
</div>
</div>
@ -5239,12 +5262,23 @@
<div class="config-option-text">
<span class="config-option-title">Include Everything</span>
<span class="config-option-description">Enable all release types AND content filters
(live, remixes, acoustic, compilations)</span>
(live, remixes, acoustic, compilations, instrumentals)</span>
</div>
</div>
</label>
</div>
</div>
<!-- Custom Exclusion Terms (always visible, independent of global override) -->
<div class="config-section">
<h3 class="config-section-title">Custom Exclusion Terms</h3>
<p class="config-section-subtitle">Comma-separated terms — tracks or albums matching any term will be skipped during scans</p>
<div class="config-exclude-terms-container">
<input type="text" id="global-exclude-terms" class="config-exclude-terms-input"
placeholder="e.g. commentary, interlude, skit, demo, a cappella">
<p class="config-exclude-terms-hint">Applied globally to all watchlist scans regardless of override setting. Case-insensitive substring matching.</p>
</div>
</div>
</div>
</div>

View file

@ -32302,6 +32302,7 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
document.getElementById('config-include-remixes').checked = config.include_remixes || false;
document.getElementById('config-include-acoustic').checked = config.include_acoustic || false;
document.getElementById('config-include-compilations').checked = config.include_compilations || false;
document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false;
// Show global override notice if active
const existingNotice = document.querySelector('.global-override-notice');
@ -32561,6 +32562,8 @@ async function openWatchlistGlobalSettingsModal() {
document.getElementById('global-include-remixes').checked = config.include_remixes;
document.getElementById('global-include-acoustic').checked = config.include_acoustic;
document.getElementById('global-include-compilations').checked = config.include_compilations;
document.getElementById('global-include-instrumentals').checked = config.include_instrumentals;
document.getElementById('global-exclude-terms').value = config.exclude_terms || '';
// Sync "Include Everything" checkbox
syncGlobalIncludeAllCheckbox();
@ -32621,7 +32624,7 @@ function toggleGlobalIncludeAll() {
const checked = document.getElementById('global-include-all').checked;
['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations'].forEach(id => {
'global-include-compilations', 'global-include-instrumentals'].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = checked;
});
@ -32633,7 +32636,7 @@ function toggleGlobalIncludeAll() {
function syncGlobalIncludeAllCheckbox() {
const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations'];
'global-include-compilations', 'global-include-instrumentals'];
const allChecked = allIds.every(id => {
const el = document.getElementById(id);
return el && el.checked;
@ -32655,6 +32658,8 @@ async function saveWatchlistGlobalConfig() {
const includeRemixes = document.getElementById('global-include-remixes').checked;
const includeAcoustic = document.getElementById('global-include-acoustic').checked;
const includeCompilations = document.getElementById('global-include-compilations').checked;
const includeInstrumentals = document.getElementById('global-include-instrumentals').checked;
const excludeTerms = (document.getElementById('global-exclude-terms').value || '').trim();
if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
@ -32679,6 +32684,8 @@ async function saveWatchlistGlobalConfig() {
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
exclude_terms: excludeTerms,
})
});
@ -32722,6 +32729,7 @@ async function saveWatchlistArtistConfig(artistId) {
const includeRemixes = document.getElementById('config-include-remixes').checked;
const includeAcoustic = document.getElementById('config-include-acoustic').checked;
const includeCompilations = document.getElementById('config-include-compilations').checked;
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
// Validate at least one release type is selected
if (!includeAlbums && !includeEps && !includeSingles) {
@ -32747,7 +32755,8 @@ async function saveWatchlistArtistConfig(artistId) {
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
})
});

View file

@ -13136,6 +13136,40 @@ body {
opacity: 0.5;
}
.config-exclude-terms-container {
padding: 4px 0;
}
.config-exclude-terms-input {
width: 100%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.2s ease, background 0.2s ease;
box-sizing: border-box;
}
.config-exclude-terms-input:focus {
border-color: rgba(var(--accent-rgb), 0.5);
background: rgba(255, 255, 255, 0.08);
}
.config-exclude-terms-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.config-exclude-terms-hint {
margin: 8px 0 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.4;
}
.watchlist-artist-config-footer {
padding: 20px 28px;
border-top: 1px solid rgba(255, 255, 255, 0.08);