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 @@ + +
Check to INCLUDE, leave unchecked to EXCLUDE (default: all excluded)
+ + +