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.
pull/115/head
Broque Thomas 1 month ago
parent d9a8162fb9
commit a8766828d9

@ -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()

@ -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.

@ -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

@ -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
}
})

@ -3302,6 +3302,57 @@
</label>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">Content Filters</h3>
<p class="config-section-subtitle">Check to INCLUDE, leave unchecked to EXCLUDE (default: all excluded)</p>
<div class="config-options">
<label class="config-option">
<input type="checkbox" id="config-include-live">
<div class="config-option-content">
<div class="config-option-icon">🎤</div>
<div class="config-option-text">
<span class="config-option-title">Include Live Versions</span>
<span class="config-option-description">Check to include live performances and concerts</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="config-include-remixes">
<div class="config-option-content">
<div class="config-option-icon">🎧</div>
<div class="config-option-text">
<span class="config-option-title">Include Remixes</span>
<span class="config-option-description">Check to include remix versions and edits</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="config-include-acoustic">
<div class="config-option-content">
<div class="config-option-icon">🎸</div>
<div class="config-option-text">
<span class="config-option-title">Include Acoustic Versions</span>
<span class="config-option-description">Check to include acoustic and stripped versions</span>
</div>
</div>
</label>
<label class="config-option">
<input type="checkbox" id="config-include-compilations">
<div class="config-option-content">
<div class="config-option-icon">📀</div>
<div class="config-option-text">
<span class="config-option-title">Include Compilations</span>
<span class="config-option-description">Check to include greatest hits and collections</span>
</div>
</div>
</label>
</div>
</div>
</div>
<div class="watchlist-artist-config-footer">

@ -23572,6 +23572,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
document.getElementById('config-include-albums').checked = config.include_albums;
document.getElementById('config-include-eps').checked = config.include_eps;
document.getElementById('config-include-singles').checked = config.include_singles;
document.getElementById('config-include-live').checked = config.include_live || false;
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;
// Store artist ID for saving
const modal = document.getElementById('watchlist-artist-config-modal');
@ -23627,8 +23631,12 @@ async function saveWatchlistArtistConfig(artistId) {
const includeAlbums = document.getElementById('config-include-albums').checked;
const includeEps = document.getElementById('config-include-eps').checked;
const includeSingles = document.getElementById('config-include-singles').checked;
const includeLive = document.getElementById('config-include-live').checked;
const includeRemixes = document.getElementById('config-include-remixes').checked;
const includeAcoustic = document.getElementById('config-include-acoustic').checked;
const includeCompilations = document.getElementById('config-include-compilations').checked;
// Validate at least one is selected
// Validate at least one release type is selected
if (!includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
@ -23648,7 +23656,11 @@ async function saveWatchlistArtistConfig(artistId) {
body: JSON.stringify({
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles
include_singles: includeSingles,
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations
})
});

Loading…
Cancel
Save