From a8766828d9112dafb2e44dcbf306af7d8fc63a3f Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 11 Jan 2026 01:33:39 -0800 Subject: [PATCH] Add content type filters for watchlist artists Introduces new filters for live versions, remixes, acoustic versions, and compilation albums to the watchlist artist configuration. Updates the database schema, backend API, and web UI to support these options, allowing users to customize which content types are included for each artist in their watchlist. --- core/plex_client.py | 46 ++++++-- core/watchlist_scanner.py | 215 ++++++++++++++++++++++++++++++++++++- database/music_database.py | 42 +++++++- web_server.py | 36 +++++-- webui/index.html | 51 +++++++++ webui/static/script.js | 16 ++- 6 files changed, 381 insertions(+), 25 deletions(-) diff --git a/core/plex_client.py b/core/plex_client.py index c030d9f..afd7db3 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -85,20 +85,40 @@ class PlexClient: def ensure_connection(self) -> bool: """Ensure connection to Plex server with lazy initialization.""" - if self._connection_attempted: - return self.server is not None - + # If we've successfully connected before and server object exists, return immediately + if self._connection_attempted and self.server is not None: + return True + + # Prevent concurrent connection attempts if self._is_connecting: return False - + self._is_connecting = True try: self._setup_client() - return self.server is not None + connection_successful = self.server is not None + + # Only mark as attempted if connection succeeded + # This allows retries if connection fails + if connection_successful: + self._connection_attempted = True + else: + # Reset flag to allow retry on next call + self._connection_attempted = False + + return connection_successful finally: self._is_connecting = False - self._connection_attempted = True - + + def reset_connection(self): + """Reset connection state to force reconnection on next ensure_connection() call. + Useful when config changes or connection needs to be refreshed.""" + logger.info("Resetting Plex connection state") + self.server = None + self.music_library = None + self._connection_attempted = False + self._last_connection_check = 0 + def _setup_client(self): config = config_manager.get_plex_config() @@ -226,13 +246,17 @@ class PlexClient: current_time = time.time() - # Only check connection if enough time has passed or never attempted - if (not self._connection_attempted or - current_time - self._last_connection_check > self._connection_check_interval): + # Always attempt connection if not connected OR cache expired + # This ensures we retry failed connections and detect disconnections + should_check = ( + self.server is None or # Not connected - always try + current_time - self._last_connection_check > self._connection_check_interval # Cache expired + ) + if should_check: self._last_connection_check = current_time - # Try to connect on first call, but don't block if already connecting + # Try to connect, but don't block if already connecting if not self._is_connecting: self.ensure_connection() diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index a01d139..3d25872 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -74,6 +74,150 @@ def clean_track_name_for_search(track_name): return cleaned_name +def is_live_version(track_name: str, album_name: str = "") -> bool: + """ + Detect if a track or album is a live version. + + Args: + track_name: Track name to check + album_name: Album name to check (optional) + + Returns: + True if this is a live version, False otherwise + """ + if not track_name: + return False + + # Combine track and album names for comprehensive checking + text_to_check = f"{track_name} {album_name}".lower() + + # Live version patterns + live_patterns = [ + r'\blive\b', # (Live), Live at, etc. + r'\blive at\b', # Live at Madison Square Garden + r'\bconcert\b', # Concert, Live Concert + r'\bin concert\b', # In Concert + r'\bunplugged\b', # MTV Unplugged (usually live) + r'\blive session\b', # Live Session + r'\blive from\b', # Live from... + r'\blive recording\b', # Live Recording + r'\bon stage\b', # On Stage + ] + + for pattern in live_patterns: + if re.search(pattern, text_to_check, re.IGNORECASE): + return True + + return False + +def is_remix_version(track_name: str, album_name: str = "") -> bool: + """ + Detect if a track is a remix. + + Args: + track_name: Track name to check + album_name: Album name to check (optional) + + Returns: + True if this is a remix, False otherwise + """ + if not track_name: + return False + + # Combine track and album names for comprehensive checking + text_to_check = f"{track_name} {album_name}".lower() + + # Remix patterns (but NOT remaster/remastered) + remix_patterns = [ + r'\bremix\b', # Remix, Remixed + r'\bmix\b(?!.*\bremaster)', # Mix (but not if followed by remaster) + r'\bedit\b', # Radio Edit, Extended Edit + r'\bversion\b(?=.*\bmix\b)', # Version with Mix (e.g., "Dance Version Mix") + r'\bclub mix\b', # Club Mix + r'\bdance mix\b', # Dance Mix + r'\bradio edit\b', # Radio Edit + r'\bextended\b(?=.*\bmix\b)', # Extended Mix + r'\bdub\b', # Dub version + r'\bvip mix\b', # VIP Mix + ] + + # But exclude remaster/remastered - those are originals + if re.search(r'\bremaster(ed)?\b', text_to_check, re.IGNORECASE): + return False + + for pattern in remix_patterns: + if re.search(pattern, text_to_check, re.IGNORECASE): + return True + + return False + +def is_acoustic_version(track_name: str, album_name: str = "") -> bool: + """ + Detect if a track is an acoustic version. + + Args: + track_name: Track name to check + album_name: Album name to check (optional) + + Returns: + True if this is an acoustic version, False otherwise + """ + if not track_name: + return False + + # Combine track and album names for comprehensive checking + text_to_check = f"{track_name} {album_name}".lower() + + # Acoustic version patterns + acoustic_patterns = [ + r'\bacoustic\b', # Acoustic, Acoustic Version + r'\bstripped\b', # Stripped version + r'\bpiano version\b', # Piano Version + r'\bunplugged\b', # MTV Unplugged (can be acoustic) + ] + + for pattern in acoustic_patterns: + if re.search(pattern, text_to_check, re.IGNORECASE): + return True + + return False + +def is_compilation_album(album_name: str) -> bool: + """ + Detect if an album is a compilation/greatest hits album. + + Args: + album_name: Album name to check + + Returns: + True if this is a compilation album, False otherwise + """ + if not album_name: + return False + + album_lower = album_name.lower() + + # Compilation album patterns + compilation_patterns = [ + r'\bgreatest hits\b', # Greatest Hits + r'\bbest of\b', # Best Of + r'\banthology\b', # Anthology + r'\bcollection\b', # Collection + r'\bcompilation\b', # Compilation + r'\bthe essential\b', # The Essential... + r'\bcomplete\b', # Complete Collection + r'\bhits\b', # Hits (standalone or at end) + r'\btop\s+\d+\b', # Top 10, Top 40, etc. + r'\bvery best\b', # Very Best Of + r'\bdefinitive\b', # Definitive Collection + ] + + for pattern in compilation_patterns: + if re.search(pattern, album_lower, re.IGNORECASE): + return True + + return False + @dataclass class ScanResult: """Result of scanning a single artist""" @@ -308,9 +452,13 @@ class WatchlistScanner: # Check each track for track in tracks: + # Check content type filters (live, remix, acoustic, compilation) + if not self._should_include_track(track, album_data, watchlist_artist): + continue # Skip this track based on content type preferences + if self.is_track_missing_from_library(track): new_tracks_found += 1 - + # Add to wishlist if self.add_track_to_wishlist(track, album_data, watchlist_artist): tracks_added_to_wishlist += 1 @@ -502,6 +650,71 @@ class WatchlistScanner: logger.warning(f"Error checking release inclusion: {e}") return True # Default to including on error + def _should_include_track(self, track, album_data, watchlist_artist: WatchlistArtist) -> bool: + """ + Check if a track should be included based on content type filters. + + Filters: + - Live versions + - Remixes + - Acoustic versions + - Compilation albums + + Args: + track: Track object or dict + album_data: Album data object or dict + watchlist_artist: WatchlistArtist object with user preferences + + Returns: + True if track should be included, False if should be skipped + """ + try: + # Get track name and album name + if isinstance(track, dict): + track_name = track.get('name', '') + else: + track_name = getattr(track, 'name', '') + + if isinstance(album_data, dict): + album_name = album_data.get('name', '') + else: + album_name = getattr(album_data, 'name', '') + + # Get user preferences (default to False = exclude by default) + include_live = getattr(watchlist_artist, 'include_live', False) + include_remixes = getattr(watchlist_artist, 'include_remixes', False) + include_acoustic = getattr(watchlist_artist, 'include_acoustic', False) + include_compilations = getattr(watchlist_artist, 'include_compilations', False) + + # Check compilation albums (album-level filter) + if not include_compilations: + if is_compilation_album(album_name): + logger.debug(f"Skipping compilation album: {album_name}") + return False + + # Check track content type filters + if not include_live: + if is_live_version(track_name, album_name): + logger.debug(f"Skipping live version: {track_name}") + return False + + if not include_remixes: + if is_remix_version(track_name, album_name): + logger.debug(f"Skipping remix: {track_name}") + return False + + if not include_acoustic: + if is_acoustic_version(track_name, album_name): + logger.debug(f"Skipping acoustic version: {track_name}") + return False + + # Track passes all filters + return True + + except Exception as e: + logger.warning(f"Error checking track content type inclusion: {e}") + return True # Default to including on error + def is_track_missing_from_library(self, track) -> bool: """ Check if a track is missing from the local Plex library. diff --git a/database/music_database.py b/database/music_database.py index 9c46d13..b70d061 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -89,6 +89,10 @@ class WatchlistArtist: include_albums: bool = True include_eps: bool = True include_singles: bool = True + include_live: bool = False + include_remixes: bool = False + include_acoustic: bool = False + include_compilations: bool = False @dataclass class SimilarArtist: @@ -273,6 +277,9 @@ class MusicDatabase: # Add album type filter columns to watchlist_artists (migration) self._add_watchlist_album_type_filters(cursor) + # Add content type filter columns to watchlist_artists (migration) + self._add_watchlist_content_type_filters(cursor) + conn.commit() logger.info("Database initialized successfully") @@ -623,6 +630,28 @@ class MusicDatabase: logger.error(f"Error adding album type filter columns to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function + def _add_watchlist_content_type_filters(self, cursor): + """Add content type filter columns to watchlist_artists table""" + try: + cursor.execute("PRAGMA table_info(watchlist_artists)") + columns = [column[1] for column in cursor.fetchall()] + + columns_to_add = { + '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) + } + + for column_name, (column_type, default_value) in columns_to_add.items(): + if column_name not in columns: + cursor.execute(f"ALTER TABLE watchlist_artists ADD COLUMN {column_name} {column_type} DEFAULT {default_value}") + logger.info(f"Added {column_name} column to watchlist_artists table") + + except Exception as e: + logger.error(f"Error adding content type filter columns to watchlist_artists: {e}") + # Don't raise - this is a migration, database can still function + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here @@ -2726,7 +2755,8 @@ class MusicDatabase: # Build SELECT query based on existing columns base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added', 'last_scan_timestamp', 'created_at', 'updated_at'] - optional_columns = ['image_url', 'include_albums', 'include_eps', 'include_singles'] + optional_columns = ['image_url', 'include_albums', 'include_eps', 'include_singles', + 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations'] columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns] @@ -2745,6 +2775,10 @@ class MusicDatabase: include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True + include_live = bool(row['include_live']) if 'include_live' in existing_columns else False + 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 watchlist_artists.append(WatchlistArtist( id=row['id'], @@ -2757,7 +2791,11 @@ class MusicDatabase: image_url=image_url, include_albums=include_albums, include_eps=include_eps, - include_singles=include_singles + include_singles=include_singles, + include_live=include_live, + include_remixes=include_remixes, + include_acoustic=include_acoustic, + include_compilations=include_compilations )) return watchlist_artists diff --git a/web_server.py b/web_server.py index dfca6b9..07e6539 100644 --- a/web_server.py +++ b/web_server.py @@ -17376,7 +17376,9 @@ def watchlist_artist_config(artist_id): conn = sqlite3.connect(str(database.database_path)) cursor = conn.cursor() cursor.execute(""" - SELECT include_albums, include_eps, include_singles, artist_name, image_url + SELECT include_albums, include_eps, include_singles, + include_live, include_remixes, include_acoustic, include_compilations, + artist_name, image_url FROM watchlist_artists WHERE spotify_artist_id = ? """, (artist_id,)) @@ -17407,8 +17409,8 @@ def watchlist_artist_config(artist_id): if not artist_info: artist_info = { 'id': artist_id, - 'name': result[3], # artist_name - 'image_url': result[4], # image_url + 'name': result[7], # artist_name + 'image_url': result[8], # image_url 'followers': 0, 'popularity': 0, 'genres': [] @@ -17417,7 +17419,11 @@ def watchlist_artist_config(artist_id): config = { 'include_albums': bool(result[0]), # Convert INTEGER to boolean 'include_eps': bool(result[1]), - 'include_singles': bool(result[2]) + 'include_singles': bool(result[2]), + 'include_live': bool(result[3]), + 'include_remixes': bool(result[4]), + 'include_acoustic': bool(result[5]), + 'include_compilations': bool(result[6]) } return jsonify({ @@ -17434,8 +17440,12 @@ def watchlist_artist_config(artist_id): include_albums = data.get('include_albums', True) include_eps = data.get('include_eps', True) include_singles = data.get('include_singles', True) + include_live = data.get('include_live', False) + include_remixes = data.get('include_remixes', False) + include_acoustic = data.get('include_acoustic', False) + include_compilations = data.get('include_compilations', False) - # Validate at least one is selected + # Validate at least one release type is selected if not (include_albums or include_eps or include_singles): return jsonify({"success": False, "error": "At least one release type must be selected"}), 400 @@ -17444,9 +17454,13 @@ def watchlist_artist_config(artist_id): cursor = conn.cursor() cursor.execute(""" UPDATE watchlist_artists - SET include_albums = ?, include_eps = ?, include_singles = ?, updated_at = CURRENT_TIMESTAMP + SET include_albums = ?, include_eps = ?, include_singles = ?, + include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?, + updated_at = CURRENT_TIMESTAMP WHERE spotify_artist_id = ? - """, (int(include_albums), int(include_eps), int(include_singles), artist_id)) + """, (int(include_albums), int(include_eps), int(include_singles), + int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations), + artist_id)) conn.commit() if cursor.rowcount == 0: @@ -17455,7 +17469,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}") + 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}") return jsonify({ "success": True, @@ -17463,7 +17477,11 @@ def watchlist_artist_config(artist_id): "config": { 'include_albums': include_albums, 'include_eps': include_eps, - 'include_singles': include_singles + 'include_singles': include_singles, + 'include_live': include_live, + 'include_remixes': include_remixes, + 'include_acoustic': include_acoustic, + 'include_compilations': include_compilations } }) diff --git a/webui/index.html b/webui/index.html index 3420443..0f2a0ea 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3302,6 +3302,57 @@ + +
+

Content Filters

+

Check to INCLUDE, leave unchecked to EXCLUDE (default: all excluded)

+ +
+ + + + + + + +
+