diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 8fec5b6f..caf95274 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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 diff --git a/database/music_database.py b/database/music_database.py index 6850c01b..b5d4fa1b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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 )) diff --git a/web_server.py b/web_server.py index bc40d06d..b10b3936 100644 --- a/web_server.py +++ b/web_server.py @@ -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""" diff --git a/webui/index.html b/webui/index.html index 83864f54..82a87ca2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5073,6 +5073,18 @@ + + @@ -5227,6 +5239,17 @@ + + @@ -5239,12 +5262,23 @@
Comma-separated terms — tracks or albums matching any term will be skipped during scans
+Applied globally to all watchlist scans regardless of override setting. Case-insensitive substring matching.
+