This commit is contained in:
Broque Thomas 2025-08-18 10:36:57 -07:00
parent 86d0028446
commit 620c78766b
9 changed files with 2148 additions and 314 deletions

View file

@ -14,7 +14,7 @@ from config.settings import config_manager
logger = get_logger("database_update_worker")
class DatabaseUpdateWorker(QThread):
"""Worker thread for updating SoulSync database with Plex library data"""
"""Worker thread for updating SoulSync database with media server library data (Plex or Jellyfin)"""
# Signals for progress reporting
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
@ -23,9 +23,20 @@ class DatabaseUpdateWorker(QThread):
error = pyqtSignal(str) # error_message
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
def __init__(self, plex_client, database_path: str = "database/music_library.db", full_refresh: bool = False):
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"):
super().__init__()
self.plex_client = plex_client
# Support both old plex_client parameter and new media_client parameter for backward compatibility
if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower():
self.media_client = media_client
self.server_type = "plex"
# Keep old attribute for backward compatibility
self.plex_client = media_client
else:
self.media_client = media_client
self.server_type = server_type
# Keep old attribute for backward compatibility with existing code that expects it
self.plex_client = media_client if server_type == "plex" else None
self.database_path = database_path
self.full_refresh = full_refresh
self.should_stop = False
@ -39,8 +50,19 @@ class DatabaseUpdateWorker(QThread):
# Threading control - get from config or default to 5
database_config = config_manager.get('database', {})
self.max_workers = database_config.get('max_workers', 5)
logger.info(f"Using {self.max_workers} worker threads for database update")
base_max_workers = database_config.get('max_workers', 5)
# Optimize worker count - reduce for database concurrency safety
if self.server_type == "jellyfin":
# Reduce workers to prevent database lock issues with bulk inserts
self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety
if base_max_workers > 3:
logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety")
else:
# Plex uses standard worker count
self.max_workers = base_max_workers
logger.info(f"Using {self.max_workers} worker threads for {self.server_type} database update")
self.thread_lock = threading.Lock()
# Database instance
@ -49,6 +71,15 @@ class DatabaseUpdateWorker(QThread):
def stop(self):
"""Stop the database update process"""
self.should_stop = True
# Clear Jellyfin cache when user stops scan to free memory
if self.server_type == "jellyfin" and hasattr(self, 'media_client'):
try:
cache_stats = self.media_client.get_cache_stats()
self.media_client.clear_cache()
logger.info(f"🧹 Cleared Jellyfin cache after user stop - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
except Exception as e:
logger.warning(f"Could not clear Jellyfin cache on stop: {e}")
def run(self):
"""Main worker thread execution"""
@ -57,14 +88,22 @@ class DatabaseUpdateWorker(QThread):
self.database = get_database(self.database_path)
if self.full_refresh:
logger.info("Performing full database refresh - clearing existing data")
self.database.clear_all_data()
# For full refresh, use the old method (all artists)
logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data")
self.database.clear_server_data(self.server_type)
# Show cache preparation phase for Jellyfin and set up progress callback
if self.server_type == "jellyfin":
self.phase_changed.emit("Preparing Jellyfin cache for fast processing...")
# Connect Jellyfin client progress to UI
if hasattr(self.media_client, 'set_progress_callback'):
self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(msg))
# For full refresh, get all artists
artists_to_process = self._get_all_artists()
if not artists_to_process:
self.error.emit("No artists found in Plex library or connection failed")
self.error.emit(f"No artists found in {self.server_type} library or connection failed")
return
logger.info(f"Full refresh: Found {len(artists_to_process)} artists in Plex library")
logger.info(f"Full refresh: Found {len(artists_to_process)} artists in {self.server_type} library")
else:
logger.info("Performing smart incremental update - checking recently added content")
# For incremental, use smart recent-first approach
@ -78,7 +117,13 @@ class DatabaseUpdateWorker(QThread):
# Phase 2: Process artists and their albums/tracks
self.phase_changed.emit("Processing artists, albums, and tracks...")
self._process_all_artists(artists_to_process)
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
self._process_jellyfin_new_tracks_directly(artists_to_process)
else:
# Standard artist processing for Plex or full refresh
self._process_all_artists(artists_to_process)
# Record full refresh completion for tracking purposes
if self.full_refresh and self.database:
@ -88,6 +133,15 @@ class DatabaseUpdateWorker(QThread):
except Exception as e:
logger.warning(f"Could not record full refresh completion: {e}")
# Clear Jellyfin cache after full refresh to free memory
if self.full_refresh and self.server_type == "jellyfin":
try:
cache_stats = self.media_client.get_cache_stats()
self.media_client.clear_cache()
logger.info(f"🧹 Cleared Jellyfin cache after full refresh - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
except Exception as e:
logger.warning(f"Could not clear Jellyfin cache: {e}")
# Cleanup orphaned records after incremental updates (catches fixed matches)
if not self.full_refresh and self.database:
try:
@ -121,33 +175,38 @@ class DatabaseUpdateWorker(QThread):
self.error.emit(f"Database update failed: {str(e)}")
def _get_all_artists(self) -> List:
"""Get all artists from Plex library"""
"""Get all artists from media server library"""
try:
if not self.plex_client.ensure_connection():
logger.error("Could not connect to Plex server")
if not self.media_client.ensure_connection():
logger.error(f"Could not connect to {self.server_type} server")
return []
artists = self.plex_client.get_all_artists()
artists = self.media_client.get_all_artists()
return artists
except Exception as e:
logger.error(f"Error getting artists from Plex: {e}")
logger.error(f"Error getting artists from {self.server_type}: {e}")
return []
def _get_artists_for_incremental_update(self) -> List:
"""Get artists that need processing for incremental update using smart early-stopping logic"""
try:
if not self.plex_client.ensure_connection():
logger.error("Could not connect to Plex server")
if not self.media_client.ensure_connection():
logger.error(f"Could not connect to {self.server_type} server")
return []
if not self.plex_client.music_library:
# Check for music library (Plex-specific check)
if self.server_type == "plex" and not self.media_client.music_library:
logger.error("No music library found in Plex")
return []
# Check if database has enough content for incremental updates
# Check if database has enough content for incremental updates (server-specific)
try:
stats = self.database.get_database_info()
# Get stats for the specific server we're updating
if hasattr(self.database, 'get_database_info_for_server'):
stats = self.database.get_database_info_for_server(self.server_type)
else:
stats = self.database.get_database_info()
track_count = stats.get('tracks', 0)
if track_count < 100: # Minimum threshold for meaningful incremental updates
@ -165,105 +224,34 @@ class DatabaseUpdateWorker(QThread):
return self._get_all_artists()
# Enhanced Strategy: Get both recently added AND recently updated content
# This catches both new content and metadata corrections done in Plex
# This catches both new content and metadata corrections done on the server
logger.info("Getting recently added and recently updated content...")
logger.info(f"Getting recently added and recently updated content from {self.server_type}...")
# Get both recently added and recently updated albums
all_recent_content = []
# For Jellyfin, we need to set up progress callback for potential cache population during incremental
if self.server_type == "jellyfin":
if hasattr(self.media_client, 'set_progress_callback'):
self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(f"Incremental: {msg}"))
try:
# Get recently added albums (up to 400 to catch more recent content)
try:
recently_added = self.plex_client.music_library.recentlyAdded(libtype='album', maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added albums")
except:
# Fallback to general recently added
recently_added = self.plex_client.music_library.recentlyAdded(maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
# Get recently updated albums (catches metadata corrections) - increased limit
try:
recently_updated = self.plex_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
# Remove duplicates (items that are both recently added and updated)
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
all_recent_content.extend(unique_updated)
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
except Exception as e:
logger.warning(f"Could not get recently updated content: {e}")
recent_content = all_recent_content
logger.info(f"Combined total: {len(recent_content)} recent albums (added + updated)")
# Filter to only get Album objects and convert Artist objects to their albums
recent_albums = []
artist_count = 0
album_count = 0
for item in recent_content:
try:
item_type = type(item).__name__
logger.info(f"Processing recently added item: {item_type} - '{getattr(item, 'title', 'Unknown')}'")
if hasattr(item, 'tracks') and hasattr(item, 'artist'):
# This is an Album - add directly
recent_albums.append(item)
album_count += 1
logger.info(f"✅ Added album directly: '{item.title}'")
elif hasattr(item, 'albums'):
# This is an Artist - get their albums
try:
artist_albums = list(item.albums())
if artist_albums:
recent_albums.extend(artist_albums)
artist_count += 1
logger.info(f"✅ Added {len(artist_albums)} albums from artist '{item.title}'")
else:
logger.info(f"⚠️ Artist '{item.title}' has no albums")
except Exception as albums_error:
logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}")
else:
# Unknown type - skip
logger.info(f"❌ Skipping unsupported type: {item_type} (has tracks: {hasattr(item, 'tracks')}, has albums: {hasattr(item, 'albums')}, has artist: {hasattr(item, 'artist')})")
except Exception as e:
logger.warning(f"Error processing recently added item: {e}")
continue
logger.info(f"Processed recently added content: {artist_count} artists → albums, {album_count} direct albums")
logger.info(f"Extracted {len(recent_albums)} albums from recently added content")
except Exception as e:
logger.warning(f"Could not get recently added albums: {e}")
# Fallback: get recently added tracks instead
try:
recent_tracks = self.plex_client.music_library.recentlyAdded(libtype='track', maxresults=1000)
logger.info(f"Fallback: Found {len(recent_tracks)} recently added tracks")
# Extract albums from tracks
recent_albums = []
seen_albums = set()
for track in recent_tracks:
try:
album = track.album()
if album and album.ratingKey not in seen_albums:
recent_albums.append(album)
seen_albums.add(album.ratingKey)
except:
continue
logger.info(f"Extracted {len(recent_albums)} unique albums from tracks")
except Exception as e2:
logger.error(f"Could not get recently added content: {e2}")
return []
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
if self.server_type == "jellyfin":
return self._get_artists_for_jellyfin_track_incremental_update()
# Plex uses album-based approach (established and working)
recent_albums = self._get_recent_albums_for_server()
if not recent_albums:
logger.info("No recently added albums found")
return []
# Sort albums by added date (newest first)
# Sort albums by added date (newest first) - handle None dates properly
try:
recent_albums.sort(key=lambda x: getattr(x, 'addedAt', 0), reverse=True)
def get_sort_date(album):
date_val = getattr(album, 'addedAt', None)
if date_val is None:
return 0 # Fallback for albums with no date
return date_val
recent_albums.sort(key=get_sort_date, reverse=True)
logger.info("Sorted albums by recently added date (newest first)")
except Exception as e:
logger.warning(f"Could not sort albums by date: {e}")
@ -315,10 +303,18 @@ class DatabaseUpdateWorker(QThread):
for track in tracks:
total_tracks_checked += 1
try:
track_id = int(track.ratingKey)
# Handle both Plex (integer) and Jellyfin (string GUID) IDs
track_id = str(track.ratingKey)
track_title = getattr(track, 'title', 'Unknown Track')
if not self.database.track_exists(track_id):
# Use server-aware track existence check
if hasattr(self.database, 'track_exists_by_server'):
track_exists = self.database.track_exists_by_server(track_id, self.server_type)
else:
# Fallback to generic check (works for string IDs)
track_exists = self.database.track_exists(track_id)
if not track_exists:
missing_tracks_count += 1
album_has_new_tracks = True
logger.debug(f"📀 Track '{track_title}' is new - album needs processing")
@ -366,7 +362,8 @@ class DatabaseUpdateWorker(QThread):
try:
album_artist = album.artist()
if album_artist:
artist_id = int(album_artist.ratingKey)
# Handle both Plex (integer) and Jellyfin (string GUID) artist IDs
artist_id = str(album_artist.ratingKey)
# Skip if we've already queued this artist
if artist_id not in processed_artist_ids:
@ -398,16 +395,208 @@ class DatabaseUpdateWorker(QThread):
# Fallback to empty list - user can try full refresh
return []
def _check_for_metadata_changes(self, plex_tracks) -> bool:
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
try:
logger.info("🚀 FAST Jellyfin incremental: getting recent tracks directly...")
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
recent_updated_tracks = self.media_client.get_recently_updated_tracks(5000)
# Combine and deduplicate
all_recent_tracks = recent_added_tracks[:]
added_ids = {track.ratingKey for track in recent_added_tracks}
unique_updated = [track for track in recent_updated_tracks if track.ratingKey not in added_ids]
all_recent_tracks.extend(unique_updated)
logger.info(f"Found {len(recent_added_tracks)} recent + {len(unique_updated)} updated = {len(all_recent_tracks)} tracks to check")
if not all_recent_tracks:
logger.info("No recent tracks found")
return []
# Check which tracks are actually new (FAST - database lookups only)
new_tracks = []
consecutive_existing_tracks = 0
processed_artists = set()
for i, track in enumerate(all_recent_tracks):
try:
track_id = str(track.ratingKey)
# Check if track exists in database
if hasattr(self.database, 'track_exists_by_server'):
track_exists = self.database.track_exists_by_server(track_id, self.server_type)
else:
track_exists = self.database.track_exists(track_id)
if not track_exists:
new_tracks.append(track)
consecutive_existing_tracks = 0 # Reset counter
logger.debug(f"🎵 New track: {track.title}")
else:
consecutive_existing_tracks += 1
logger.debug(f"✅ Track exists: {track.title}")
# Early stopping: if we find 100 consecutive existing tracks, we're done
if consecutive_existing_tracks >= 100:
logger.info(f"🛑 Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
break
except Exception as e:
logger.debug(f"Error checking track {getattr(track, 'title', 'Unknown')}: {e}")
continue
logger.info(f"Found {len(new_tracks)} genuinely new tracks (early stopped after {consecutive_existing_tracks} consecutive existing)")
if not new_tracks:
logger.info("All recent tracks already exist - database is up to date")
return []
# Store new tracks for direct processing (avoid slow artist->album->track lookups)
self._jellyfin_new_tracks = new_tracks
# Extract unique artists from new tracks (FAST - no additional API calls needed)
artists_to_process = []
for track in new_tracks:
try:
# Track already has artist info from the API call
track_artist = track.artist() # This will make an API call, but only for new tracks
if track_artist:
artist_id = str(track_artist.ratingKey)
if artist_id not in processed_artists:
processed_artists.add(artist_id)
artists_to_process.append(track_artist)
logger.info(f"✅ Added artist '{track_artist.title}' (from new track '{track.title}')")
except Exception as e:
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
continue
logger.info(f"🚀 FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
return artists_to_process
except Exception as e:
logger.error(f"Error in fast Jellyfin incremental update: {e}")
return []
def _process_jellyfin_new_tracks_directly(self, artists_to_process):
"""Process new Jellyfin tracks directly without slow artist->album->track lookups"""
try:
new_tracks = getattr(self, '_jellyfin_new_tracks', [])
if not new_tracks:
logger.warning("No new tracks to process directly")
return
logger.info(f"🚀 FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
# Group tracks by album and artist for efficient processing
tracks_by_album = {}
albums_by_artist = {}
for track in new_tracks:
try:
# Track already has album and artist IDs from API response
album_id = str(track._album_id) if track._album_id else "unknown"
artist_id = str(track._artist_ids[0]) if track._artist_ids else "unknown"
if album_id not in tracks_by_album:
tracks_by_album[album_id] = []
tracks_by_album[album_id].append(track)
if artist_id not in albums_by_artist:
albums_by_artist[artist_id] = set()
albums_by_artist[artist_id].add(album_id)
except Exception as e:
logger.debug(f"Error grouping track {getattr(track, 'title', 'Unknown')}: {e}")
continue
total_processed_tracks = 0
total_processed_albums = 0
total_processed_artists = 0
# Process each artist
for artist in artists_to_process:
if self.should_stop:
break
try:
artist_id = str(artist.ratingKey)
artist_name = getattr(artist, 'title', 'Unknown Artist')
# Insert/update the artist
artist_success = self.database.insert_or_update_media_artist(artist, server_source=self.server_type)
if artist_success:
total_processed_artists += 1
# Process albums for this artist
artist_album_ids = albums_by_artist.get(artist_id, set())
for album_id in artist_album_ids:
if self.should_stop:
break
try:
# Get album from the first track (they all have the same album)
album_tracks = tracks_by_album[album_id]
if album_tracks:
album = album_tracks[0].album() # Get album object
if album:
# Insert/update album
album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type)
if album_success:
total_processed_albums += 1
# Process all tracks in this album
for track in album_tracks:
if self.should_stop:
break
try:
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
if track_success:
total_processed_tracks += 1
logger.debug(f"✅ Processed new track: {track.title}")
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Failed to process album {album_id}: {e}")
# Emit progress for this artist
artist_albums = len(artist_album_ids)
artist_tracks = sum(len(tracks_by_album[aid]) for aid in artist_album_ids if aid in tracks_by_album)
self.artist_processed.emit(artist_name, True, f"Processed {artist_albums} albums, {artist_tracks} tracks", artist_albums, artist_tracks)
except Exception as e:
logger.error(f"Error processing artist '{getattr(artist, 'title', 'Unknown')}': {e}")
self.artist_processed.emit(getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
# Update totals
with self.thread_lock:
self.processed_artists += total_processed_artists
self.processed_albums += total_processed_albums
self.processed_tracks += total_processed_tracks
self.successful_operations += total_processed_artists # Count successful artists
logger.info(f"🚀 FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
# Clean up
delattr(self, '_jellyfin_new_tracks')
except Exception as e:
logger.error(f"Error in fast Jellyfin track processing: {e}")
def _check_for_metadata_changes(self, media_tracks) -> bool:
"""Check if any tracks in the list have metadata changes compared to database"""
try:
if not self.database or not plex_tracks:
if not self.database or not media_tracks:
return False
changes_detected = 0
for track in plex_tracks:
for track in media_tracks:
try:
track_id = int(track.ratingKey)
# Handle both Plex (integer) and Jellyfin (string GUID) IDs
track_id = str(track.ratingKey)
# Get current data from database
db_track = self.database.get_track_by_id(track_id)
@ -442,6 +631,102 @@ class DatabaseUpdateWorker(QThread):
logger.debug(f"Error checking for metadata changes: {e}")
return False # Assume no changes if we can't check
def _get_recent_albums_for_server(self) -> List:
"""Get recently added albums using server-specific methods"""
try:
if self.server_type == "plex":
return self._get_recent_albums_plex()
elif self.server_type == "jellyfin":
return self._get_recent_albums_jellyfin()
else:
logger.error(f"Unknown server type: {self.server_type}")
return []
except Exception as e:
logger.error(f"Error getting recent albums for {self.server_type}: {e}")
return []
def _get_recent_albums_plex(self) -> List:
"""Get recently added and updated albums from Plex"""
all_recent_content = []
try:
# Get recently added albums (up to 400 to catch more recent content)
try:
recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added albums")
except:
# Fallback to general recently added
recently_added = self.media_client.music_library.recentlyAdded(maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
# Get recently updated albums (catches metadata corrections)
try:
recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
# Remove duplicates (items that are both recently added and updated)
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
all_recent_content.extend(unique_updated)
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
except Exception as e:
logger.warning(f"Could not get recently updated content: {e}")
# Filter to only get Album objects and convert Artist objects to their albums
recent_albums = []
artist_count = 0
album_count = 0
for item in all_recent_content:
try:
if hasattr(item, 'tracks') and hasattr(item, 'artist'):
# This is an Album - add directly
recent_albums.append(item)
album_count += 1
elif hasattr(item, 'albums'):
# This is an Artist - get their albums
try:
artist_albums = list(item.albums())
if artist_albums:
recent_albums.extend(artist_albums)
artist_count += 1
except Exception as albums_error:
logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}")
except Exception as e:
logger.warning(f"Error processing recently added item: {e}")
continue
logger.info(f"Processed {artist_count} artists → albums, {album_count} direct albums")
return recent_albums
except Exception as e:
logger.error(f"Error getting recent Plex albums: {e}")
return []
def _get_recent_albums_jellyfin(self) -> List:
"""Get recently added and updated albums from Jellyfin"""
try:
all_recent_albums = []
# Get recently added albums
recently_added = self.media_client.get_recently_added_albums(400)
all_recent_albums.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added albums")
# Get recently updated albums
recently_updated = self.media_client.get_recently_updated_albums(400)
# Remove duplicates
added_ids = {album.ratingKey for album in all_recent_albums}
unique_updated = [album for album in recently_updated if album.ratingKey not in added_ids]
all_recent_albums.extend(unique_updated)
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
return all_recent_albums
except Exception as e:
logger.error(f"Error getting recent Jellyfin albums: {e}")
return []
def _process_all_artists(self, artists: List):
"""Process all artists and their albums/tracks using thread pool"""
total_artists = len(artists)
@ -505,21 +790,21 @@ class DatabaseUpdateWorker(QThread):
# Emit progress signal
self.artist_processed.emit(artist_name, success, details, album_count, track_count)
def _process_artist_with_content(self, plex_artist) -> tuple[bool, str, int, int]:
"""Process an artist and all their albums and tracks"""
def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
"""Process an artist and all their albums and tracks with optimized API usage"""
try:
artist_name = getattr(plex_artist, 'title', 'Unknown Artist')
artist_name = getattr(media_artist, 'title', 'Unknown Artist')
# 1. Insert/update the artist
artist_success = self.database.insert_or_update_artist(plex_artist)
# 1. Insert/update the artist using server-agnostic method
artist_success = self.database.insert_or_update_media_artist(media_artist, server_source=self.server_type)
if not artist_success:
return False, "Failed to update artist data", 0, 0
artist_id = int(plex_artist.ratingKey)
artist_id = str(media_artist.ratingKey)
# 2. Get all albums for this artist
# 2. Get all albums for this artist (cached from aggressive pre-population)
try:
albums = list(plex_artist.albums())
albums = list(media_artist.albums())
except Exception as e:
logger.warning(f"Could not get albums for artist '{artist_name}': {e}")
return True, "Artist updated (no albums accessible)", 0, 0
@ -527,44 +812,56 @@ class DatabaseUpdateWorker(QThread):
album_count = 0
track_count = 0
# 3. Process each album
for album in albums:
# 3. Process albums in smaller batches to reduce memory usage
batch_size = 10 # Process 10 albums at a time
for i in range(0, len(albums), batch_size):
if self.should_stop:
break
try:
# Insert/update album
album_success = self.database.insert_or_update_album(album, artist_id)
if album_success:
album_count += 1
album_id = int(album.ratingKey)
# 4. Process tracks in this album
try:
tracks = list(album.tracks())
for track in tracks:
if self.should_stop:
break
try:
track_success = self.database.insert_or_update_track(track, album_id, artist_id)
if track_success:
track_count += 1
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
album_batch = albums[i:i + batch_size]
for album in album_batch:
if self.should_stop:
break
try:
# Insert/update album using server-agnostic method
album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type)
if album_success:
album_count += 1
album_id = str(album.ratingKey)
# 4. Process tracks in this album (cached from aggressive pre-population)
try:
tracks = list(album.tracks())
# Batch insert tracks for better database performance
track_batch = []
for track in tracks:
if self.should_stop:
break
track_batch.append((track, album_id, artist_id))
# Process track batch
for track, alb_id, art_id in track_batch:
try:
track_success = self.database.insert_or_update_media_track(track, alb_id, art_id, server_source=self.server_type)
if track_success:
track_count += 1
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
details = f"Updated with {album_count} albums, {track_count} tracks"
return True, details, album_count, track_count
except Exception as e:
logger.error(f"Error processing artist '{getattr(plex_artist, 'title', 'Unknown')}': {e}")
logger.error(f"Error processing artist '{getattr(media_artist, 'title', 'Unknown')}': {e}")
return False, f"Processing error: {str(e)}", 0, 0
class DatabaseStatsWorker(QThread):
@ -591,18 +888,23 @@ class DatabaseStatsWorker(QThread):
if self.should_stop:
return
# Get full database info (includes last_full_refresh)
info = database.get_database_info()
# Get database info for active server (server-aware statistics)
info = database.get_database_info_for_server()
if not self.should_stop:
self.stats_updated.emit(info)
except Exception as e:
logger.error(f"Error getting database stats: {e}")
if not self.should_stop:
# Import here to avoid circular imports
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
self.stats_updated.emit({
'artists': 0,
'albums': 0,
'tracks': 0,
'database_size_mb': 0.0,
'last_update': None,
'last_full_refresh': None
'last_full_refresh': None,
'server_source': active_server
})

1078
core/jellyfin_client.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -362,8 +362,10 @@ class WatchlistScanner:
for artist_name in artists_to_search:
for query_title in unique_title_variations:
# Use same database check as modals
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
# Use same database check as modals with server awareness
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")

View file

@ -6,6 +6,7 @@ import logging
import os
import re
import threading
import time
from datetime import datetime
from typing import List, Optional, Dict, Any, Tuple
from dataclasses import dataclass
@ -207,6 +208,12 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
# Add server_source columns for multi-server support (migration)
self._add_server_source_columns(cursor)
# Migrate ID columns to support both integer (Plex) and string (Jellyfin) IDs
self._migrate_id_columns_to_text(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -214,13 +221,168 @@ class MusicDatabase:
logger.error(f"Error initializing database: {e}")
raise
def _add_server_source_columns(self, cursor):
"""Add server_source columns to existing tables for multi-server support"""
try:
# Check if server_source column exists in artists table
cursor.execute("PRAGMA table_info(artists)")
artists_columns = [column[1] for column in cursor.fetchall()]
if 'server_source' not in artists_columns:
cursor.execute("ALTER TABLE artists ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to artists table")
# Check if server_source column exists in albums table
cursor.execute("PRAGMA table_info(albums)")
albums_columns = [column[1] for column in cursor.fetchall()]
if 'server_source' not in albums_columns:
cursor.execute("ALTER TABLE albums ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to albums table")
# Check if server_source column exists in tracks table
cursor.execute("PRAGMA table_info(tracks)")
tracks_columns = [column[1] for column in cursor.fetchall()]
if 'server_source' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to tracks table")
# Create indexes for server_source columns for performance
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
except Exception as e:
logger.error(f"Error adding server_source columns: {e}")
# Don't raise - this is a migration, database can still function without it
def _migrate_id_columns_to_text(self, cursor):
"""Migrate ID columns from INTEGER to TEXT to support both Plex (int) and Jellyfin (GUID) IDs"""
try:
# Check if migration has already been applied by looking for a specific marker
cursor.execute("SELECT value FROM metadata WHERE key = 'id_columns_migrated' LIMIT 1")
migration_done = cursor.fetchone()
if migration_done:
logger.debug("ID columns migration already applied")
return
logger.info("Migrating ID columns to support both integer and string IDs...")
# SQLite doesn't support changing column types directly, so we need to recreate tables
# This is a complex migration - let's do it safely
# Step 1: Create new tables with TEXT IDs
cursor.execute("""
CREATE TABLE IF NOT EXISTS artists_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
thumb_url TEXT,
genres TEXT,
summary TEXT,
server_source TEXT DEFAULT 'plex',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS albums_new (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER,
thumb_url TEXT,
genres TEXT,
track_count INTEGER,
duration INTEGER,
server_source TEXT DEFAULT 'plex',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS tracks_new (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
track_number INTEGER,
duration INTEGER,
file_path TEXT,
bitrate INTEGER,
server_source TEXT DEFAULT 'plex',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES albums_new (id) ON DELETE CASCADE,
FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
)
""")
# Step 2: Copy existing data (converting INTEGER IDs to TEXT)
cursor.execute("""
INSERT INTO artists_new (id, name, thumb_url, genres, summary, server_source, created_at, updated_at)
SELECT CAST(id AS TEXT), name, thumb_url, genres, summary,
COALESCE(server_source, 'plex'), created_at, updated_at
FROM artists
""")
cursor.execute("""
INSERT INTO albums_new (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source, created_at, updated_at)
SELECT CAST(id AS TEXT), CAST(artist_id AS TEXT), title, year, thumb_url, genres, track_count, duration,
COALESCE(server_source, 'plex'), created_at, updated_at
FROM albums
""")
cursor.execute("""
INSERT INTO tracks_new (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, created_at, updated_at)
SELECT CAST(id AS TEXT), CAST(album_id AS TEXT), CAST(artist_id AS TEXT), title, track_number, duration, file_path, bitrate,
COALESCE(server_source, 'plex'), created_at, updated_at
FROM tracks
""")
# Step 3: Drop old tables and rename new ones
cursor.execute("DROP TABLE IF EXISTS tracks")
cursor.execute("DROP TABLE IF EXISTS albums")
cursor.execute("DROP TABLE IF EXISTS artists")
cursor.execute("ALTER TABLE artists_new RENAME TO artists")
cursor.execute("ALTER TABLE albums_new RENAME TO albums")
cursor.execute("ALTER TABLE tracks_new RENAME TO tracks")
# Step 4: Recreate indexes
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
# Step 5: Mark migration as complete
cursor.execute("""
INSERT OR REPLACE INTO metadata (key, value, updated_at)
VALUES ('id_columns_migrated', 'true', CURRENT_TIMESTAMP)
""")
logger.info("ID columns migration completed successfully")
except Exception as e:
logger.error(f"Error migrating ID columns: {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
pass
def get_statistics(self) -> Dict[str, int]:
"""Get database statistics"""
"""Get database statistics for all servers (legacy method)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
@ -243,8 +405,44 @@ class MusicDatabase:
logger.error(f"Error getting database statistics: {e}")
return {'artists': 0, 'albums': 0, 'tracks': 0}
def get_statistics_for_server(self, server_source: str = None) -> Dict[str, int]:
"""Get database statistics filtered by server source"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
if server_source:
# Get counts for specific server
cursor.execute("SELECT COUNT(*) FROM artists WHERE server_source = ?", (server_source,))
artist_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM albums WHERE server_source = ?", (server_source,))
album_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM tracks WHERE server_source = ?", (server_source,))
track_count = cursor.fetchone()[0]
else:
# Get total counts (all servers)
cursor.execute("SELECT COUNT(*) FROM artists")
artist_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM albums")
album_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM tracks")
track_count = cursor.fetchone()[0]
return {
'artists': artist_count,
'albums': album_count,
'tracks': track_count
}
except Exception as e:
logger.error(f"Error getting database statistics for {server_source}: {e}")
return {'artists': 0, 'albums': 0, 'tracks': 0}
def clear_all_data(self):
"""Clear all data from database (for full refresh)"""
"""Clear all data from database (for full refresh) - DEPRECATED: Use clear_server_data instead"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
@ -265,6 +463,38 @@ class MusicDatabase:
logger.error(f"Error clearing database: {e}")
raise
def clear_server_data(self, server_source: str):
"""Clear data for specific server only (server-aware full refresh)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Delete only data from the specified server
# Order matters: tracks -> albums -> artists (foreign key constraints)
cursor.execute("DELETE FROM tracks WHERE server_source = ?", (server_source,))
tracks_deleted = cursor.rowcount
cursor.execute("DELETE FROM albums WHERE server_source = ?", (server_source,))
albums_deleted = cursor.rowcount
cursor.execute("DELETE FROM artists WHERE server_source = ?", (server_source,))
artists_deleted = cursor.rowcount
conn.commit()
# Only VACUUM if we deleted a significant amount of data
if tracks_deleted > 1000 or albums_deleted > 100:
logger.info("Vacuuming database to reclaim disk space...")
cursor.execute("VACUUM")
logger.info(f"Cleared {server_source} data: {artists_deleted} artists, {albums_deleted} albums, {tracks_deleted} tracks")
# Note: Watchlist and wishlist are preserved as they are server-agnostic
except Exception as e:
logger.error(f"Error clearing {server_source} database data: {e}")
raise
def cleanup_orphaned_records(self) -> Dict[str, int]:
"""Remove artists and albums that have no associated tracks"""
try:
@ -314,26 +544,31 @@ class MusicDatabase:
# Artist operations
def insert_or_update_artist(self, plex_artist) -> bool:
"""Insert or update artist from Plex artist object"""
"""Insert or update artist from Plex artist object - DEPRECATED: Use insert_or_update_media_artist instead"""
return self.insert_or_update_media_artist(plex_artist, server_source='plex')
def insert_or_update_media_artist(self, artist_obj, server_source: str = 'plex') -> bool:
"""Insert or update artist from media server artist object (Plex or Jellyfin)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
artist_id = int(plex_artist.ratingKey)
name = plex_artist.title
thumb_url = getattr(plex_artist, 'thumb', None)
summary = getattr(plex_artist, 'summary', None)
# Convert artist ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
artist_id = str(artist_obj.ratingKey)
name = artist_obj.title
thumb_url = getattr(artist_obj, 'thumb', None)
summary = getattr(artist_obj, 'summary', None)
# Get genres
# Get genres (handle both Plex and Jellyfin formats)
genres = []
if hasattr(plex_artist, 'genres') and plex_artist.genres:
if hasattr(artist_obj, 'genres') and artist_obj.genres:
genres = [genre.tag if hasattr(genre, 'tag') else str(genre)
for genre in plex_artist.genres]
for genre in artist_obj.genres]
genres_json = json.dumps(genres) if genres else None
# Check if artist exists
cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,))
# Check if artist exists with this ID and server source
cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = ?", (artist_id, server_source))
exists = cursor.fetchone()
if exists:
@ -341,20 +576,20 @@ class MusicDatabase:
cursor.execute("""
UPDATE artists
SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (name, thumb_url, genres_json, summary, artist_id))
WHERE id = ? AND server_source = ?
""", (name, thumb_url, genres_json, summary, artist_id, server_source))
else:
# Insert new artist
cursor.execute("""
INSERT INTO artists (id, name, thumb_url, genres, summary)
VALUES (?, ?, ?, ?, ?)
""", (artist_id, name, thumb_url, genres_json, summary))
INSERT INTO artists (id, name, thumb_url, genres, summary, server_source)
VALUES (?, ?, ?, ?, ?, ?)
""", (artist_id, name, thumb_url, genres_json, summary, server_source))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting/updating artist {getattr(plex_artist, 'title', 'Unknown')}: {e}")
logger.error(f"Error inserting/updating {server_source} artist {getattr(artist_obj, 'title', 'Unknown')}: {e}")
return False
def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]:
@ -385,30 +620,35 @@ class MusicDatabase:
# Album operations
def insert_or_update_album(self, plex_album, artist_id: int) -> bool:
"""Insert or update album from Plex album object"""
"""Insert or update album from Plex album object - DEPRECATED: Use insert_or_update_media_album instead"""
return self.insert_or_update_media_album(plex_album, artist_id, server_source='plex')
def insert_or_update_media_album(self, album_obj, artist_id: str, server_source: str = 'plex') -> bool:
"""Insert or update album from media server album object (Plex or Jellyfin)"""
try:
conn = self._get_connection()
cursor = conn.cursor()
album_id = int(plex_album.ratingKey)
title = plex_album.title
year = getattr(plex_album, 'year', None)
thumb_url = getattr(plex_album, 'thumb', None)
# Convert album ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
album_id = str(album_obj.ratingKey)
title = album_obj.title
year = getattr(album_obj, 'year', None)
thumb_url = getattr(album_obj, 'thumb', None)
# Get track count and duration
track_count = getattr(plex_album, 'leafCount', None)
duration = getattr(plex_album, 'duration', None)
# Get track count and duration (handle different server attributes)
track_count = getattr(album_obj, 'leafCount', None) or getattr(album_obj, 'childCount', None)
duration = getattr(album_obj, 'duration', None)
# Get genres
# Get genres (handle both Plex and Jellyfin formats)
genres = []
if hasattr(plex_album, 'genres') and plex_album.genres:
if hasattr(album_obj, 'genres') and album_obj.genres:
genres = [genre.tag if hasattr(genre, 'tag') else str(genre)
for genre in plex_album.genres]
for genre in album_obj.genres]
genres_json = json.dumps(genres) if genres else None
# Check if album exists
cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,))
# Check if album exists with this ID and server source
cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = ?", (album_id, server_source))
exists = cursor.fetchone()
if exists:
@ -417,20 +657,20 @@ class MusicDatabase:
UPDATE albums
SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id))
WHERE id = ? AND server_source = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id, server_source))
else:
# Insert new album
cursor.execute("""
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration))
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting/updating album {getattr(plex_album, 'title', 'Unknown')}: {e}")
logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
return False
def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
@ -466,60 +706,70 @@ class MusicDatabase:
# Track operations
def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool:
"""Insert or update track from Plex track object"""
try:
conn = self._get_connection()
cursor = conn.cursor()
track_id = int(plex_track.ratingKey)
title = plex_track.title
track_number = getattr(plex_track, 'trackNumber', None)
duration = getattr(plex_track, 'duration', None)
# Get file path and media info
file_path = None
bitrate = None
if hasattr(plex_track, 'media') and plex_track.media:
media = plex_track.media[0] if plex_track.media else None
if media:
if hasattr(media, 'parts') and media.parts:
part = media.parts[0]
file_path = getattr(part, 'file', None)
bitrate = getattr(media, 'bitrate', None)
# Check if track exists
cursor.execute("SELECT id FROM tracks WHERE id = ?", (track_id,))
exists = cursor.fetchone()
if exists:
# Update existing track
cursor.execute("""
UPDATE tracks
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
duration = ?, file_path = ?, bitrate = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, track_id))
else:
# Insert new track
cursor.execute("""
INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting/updating track {getattr(plex_track, 'title', 'Unknown')}: {e}")
return False
"""Insert or update track from Plex track object - DEPRECATED: Use insert_or_update_media_track instead"""
return self.insert_or_update_media_track(plex_track, album_id, artist_id, server_source='plex')
def track_exists(self, track_id: int) -> bool:
"""Check if a track exists in the database by Plex ID"""
def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, server_source: str = 'plex') -> bool:
"""Insert or update track from media server track object (Plex or Jellyfin) with retry logic"""
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
conn = self._get_connection()
cursor = conn.cursor()
# Set shorter timeout to prevent long locks
cursor.execute("PRAGMA busy_timeout = 10000") # 10 second timeout
# Convert track ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
track_id = str(track_obj.ratingKey)
title = track_obj.title
track_number = getattr(track_obj, 'trackNumber', None)
duration = getattr(track_obj, 'duration', None)
# Get file path and media info (Plex-specific, Jellyfin may not have these)
file_path = None
bitrate = None
if hasattr(track_obj, 'media') and track_obj.media:
media = track_obj.media[0] if track_obj.media else None
if media:
if hasattr(media, 'parts') and media.parts:
part = media.parts[0]
file_path = getattr(part, 'file', None)
bitrate = getattr(media, 'bitrate', None)
# Use INSERT OR REPLACE to handle duplicate IDs gracefully
cursor.execute("""
INSERT OR REPLACE INTO tracks
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
conn.commit()
return True
except Exception as e:
retry_count += 1
if "database is locked" in str(e).lower() and retry_count < max_retries:
logger.warning(f"Database locked on track '{getattr(track_obj, 'title', 'Unknown')}', retrying {retry_count}/{max_retries}...")
time.sleep(0.1 * retry_count) # Exponential backoff
continue
else:
logger.error(f"Error inserting/updating {server_source} track {getattr(track_obj, 'title', 'Unknown')}: {e}")
return False
return False
def track_exists(self, track_id) -> bool:
"""Check if a track exists in the database by ID (supports both int and string IDs)"""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
# Convert to string to handle both Plex integers and Jellyfin GUIDs
track_id_str = str(track_id)
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id_str,))
result = cursor.fetchone()
return result is not None
@ -528,12 +778,31 @@ class MusicDatabase:
logger.error(f"Error checking if track {track_id} exists: {e}")
return False
def get_track_by_id(self, track_id: int) -> Optional[DatabaseTrackWithMetadata]:
"""Get a track with artist and album names by Plex ID"""
def track_exists_by_server(self, track_id, server_source: str) -> bool:
"""Check if a track exists in the database by ID and server source"""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Convert to string to handle both Plex integers and Jellyfin GUIDs
track_id_str = str(track_id)
cursor.execute("SELECT 1 FROM tracks WHERE id = ? AND server_source = ? LIMIT 1", (track_id_str, server_source))
result = cursor.fetchone()
return result is not None
except Exception as e:
logger.error(f"Error checking if track {track_id} exists for server {server_source}: {e}")
return False
def get_track_by_id(self, track_id) -> Optional[DatabaseTrackWithMetadata]:
"""Get a track with artist and album names by ID (supports both int and string IDs)"""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Convert to string to handle both Plex integers and Jellyfin GUIDs
track_id_str = str(track_id)
cursor.execute("""
SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number,
t.duration, t.created_at, t.updated_at,
@ -542,7 +811,7 @@ class MusicDatabase:
JOIN artists a ON t.artist_id = a.id
JOIN albums al ON t.album_id = al.id
WHERE t.id = ?
""", (track_id,))
""", (track_id_str,))
row = cursor.fetchone()
if row:
@ -628,7 +897,7 @@ class MusicDatabase:
logger.error(f"Error searching artists with query '{query}': {e}")
return []
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50) -> List[DatabaseTrack]:
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]:
"""Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
try:
if not title and not artist:
@ -638,7 +907,7 @@ class MusicDatabase:
cursor = conn.cursor()
# STRATEGY 1: Try basic SQL LIKE search first (fastest)
basic_results = self._search_tracks_basic(cursor, title, artist, limit)
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
if basic_results:
logger.debug(f"🔍 Basic search found {len(basic_results)} results")
@ -652,7 +921,7 @@ class MusicDatabase:
unicode_support = False
if unicode_support:
normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit)
normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit, server_source)
if normalized_results:
logger.debug(f"🔍 Unicode fallback search found {len(normalized_results)} results")
return normalized_results
@ -668,7 +937,7 @@ class MusicDatabase:
logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}")
return []
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]:
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
"""Basic SQL LIKE search - fastest method"""
where_conditions = []
params = []
@ -681,6 +950,11 @@ class MusicDatabase:
where_conditions.append("artists.name LIKE ?")
params.append(f"%{artist}%")
# Add server filter if specified
if server_source:
where_conditions.append("tracks.server_source = ?")
params.append(server_source)
if not where_conditions:
return []
@ -699,7 +973,7 @@ class MusicDatabase:
return self._rows_to_tracks(cursor.fetchall())
def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]:
def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
"""Unicode-aware fallback search - tries normalized versions"""
from unidecode import unidecode
@ -719,6 +993,11 @@ class MusicDatabase:
where_conditions.append("LOWER(artists.name) LIKE ?")
params.append(f"%{artist_norm}%")
# Add server filter if specified
if server_source:
where_conditions.append("tracks.server_source = ?")
params.append(server_source)
if not where_conditions:
return []
@ -918,7 +1197,7 @@ class MusicDatabase:
return list(set(variations))
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseTrack], float]:
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None) -> Tuple[Optional[DatabaseTrack], float]:
"""
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
Now uses the same sophisticated matching approach as album checking for consistency.
@ -941,7 +1220,7 @@ class MusicDatabase:
potential_matches = []
artist_variations = self._get_artist_variations(artist)
for artist_variation in artist_variations:
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20))
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source))
if not potential_matches:
continue
@ -1809,7 +2088,7 @@ class MusicDatabase:
return 0
def get_database_info(self) -> Dict[str, Any]:
"""Get comprehensive database information"""
"""Get comprehensive database information for all servers (legacy method)"""
try:
stats = self.get_statistics()
@ -1857,6 +2136,65 @@ class MusicDatabase:
'last_update': None,
'last_full_refresh': None
}
def get_database_info_for_server(self, server_source: str = None) -> Dict[str, Any]:
"""Get comprehensive database information filtered by server source"""
try:
# Import here to avoid circular imports
from config.settings import config_manager
# If no server specified, use active server
if server_source is None:
server_source = config_manager.get_active_media_server()
stats = self.get_statistics_for_server(server_source)
# Get database file size (always total, not server-specific)
db_size = self.database_path.stat().st_size if self.database_path.exists() else 0
db_size_mb = db_size / (1024 * 1024)
# Get last update time for this server
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT MAX(updated_at) as last_update
FROM (
SELECT updated_at FROM artists WHERE server_source = ?
UNION ALL
SELECT updated_at FROM albums WHERE server_source = ?
UNION ALL
SELECT updated_at FROM tracks WHERE server_source = ?
)
""", (server_source, server_source, server_source))
result = cursor.fetchone()
last_update = result['last_update'] if result and result['last_update'] else None
# Get last full refresh (global setting, not server-specific)
last_full_refresh = self.get_last_full_refresh()
return {
**stats,
'database_size_mb': round(db_size_mb, 2),
'database_path': str(self.database_path),
'last_update': last_update,
'last_full_refresh': last_full_refresh,
'server_source': server_source
}
except Exception as e:
logger.error(f"Error getting database info for {server_source}: {e}")
return {
'artists': 0,
'albums': 0,
'tracks': 0,
'database_size_mb': 0.0,
'database_path': str(self.database_path),
'last_update': None,
'last_full_refresh': None,
'server_source': server_source
}
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance

32
main.py
View file

@ -12,6 +12,7 @@ from config.settings import config_manager
from utils.logging_config import setup_logging, get_logger
from core.spotify_client import SpotifyClient
from core.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
from ui.sidebar import ModernSidebar
@ -27,10 +28,11 @@ logger = get_logger("main")
class ServiceStatusThread(QThread):
status_updated = pyqtSignal(str, bool)
def __init__(self, spotify_client, plex_client, soulseek_client):
def __init__(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
super().__init__()
self.spotify_client = spotify_client
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client
self.running = True
@ -51,20 +53,8 @@ class ServiceStatusThread(QThread):
server_status = self.plex_client.is_connected()
self.status_updated.emit("plex", server_status)
elif active_server == "jellyfin":
# For now, do a basic config check for Jellyfin until JellyfinClient is implemented
jellyfin_config = self.config_manager.get_jellyfin_config()
jellyfin_status = bool(jellyfin_config.get('base_url')) and bool(jellyfin_config.get('api_key'))
if jellyfin_status:
# Do a quick HTTP test to verify connection
try:
import requests
base_url = jellyfin_config.get('base_url', '').rstrip('/')
api_key = jellyfin_config.get('api_key', '')
headers = {'X-Emby-Token': api_key} if api_key else {}
response = requests.get(f"{base_url}/System/Info", headers=headers, timeout=3)
jellyfin_status = response.status_code == 200
except:
jellyfin_status = False
# Use the JellyfinClient for status checking
jellyfin_status = self.jellyfin_client.is_connected()
self.status_updated.emit("jellyfin", jellyfin_status)
# Check Soulseek connection (simplified check to avoid event loop issues)
@ -91,6 +81,7 @@ class MainWindow(QMainWindow):
self.spotify_client = SpotifyClient()
self.plex_client = PlexClient()
self.jellyfin_client = JellyfinClient()
self.soulseek_client = SoulseekClient()
self.status_thread = None
@ -183,7 +174,13 @@ class MainWindow(QMainWindow):
# Create and add pages
self.dashboard_page = DashboardPage()
self.downloads_page = DownloadsPage(self.soulseek_client)
self.sync_page = SyncPage(self.spotify_client, self.plex_client, self.soulseek_client, self.downloads_page)
self.sync_page = SyncPage(
spotify_client=self.spotify_client,
plex_client=self.plex_client,
soulseek_client=self.soulseek_client,
downloads_page=self.downloads_page,
jellyfin_client=self.jellyfin_client
)
self.artists_page = ArtistsPage(downloads_page=self.downloads_page)
self.settings_page = SettingsPage()
@ -194,7 +191,7 @@ class MainWindow(QMainWindow):
self.settings_page.set_toast_manager(self.toast_manager)
# Configure dashboard with service clients and page references
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.soulseek_client)
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.soulseek_client)
self.dashboard_page.set_page_references(self.downloads_page, self.sync_page)
self.dashboard_page.set_app_start_time(self.app_start_time)
self.dashboard_page.set_toast_manager(self.toast_manager)
@ -241,6 +238,7 @@ class MainWindow(QMainWindow):
self.status_thread = ServiceStatusThread(
self.spotify_client,
self.plex_client,
self.jellyfin_client,
self.soulseek_client
)
self.status_thread.status_updated.connect(self.update_service_status)

View file

@ -5,6 +5,7 @@ from datetime import datetime
from utils.logging_config import get_logger
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
from core.plex_client import PlexClient, PlexTrackInfo
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
from core.matching_engine import MusicMatchingEngine, MatchResult
@ -40,15 +41,33 @@ class SyncProgress:
failed_tracks: int = 0
class PlaylistSyncService:
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient):
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None):
self.spotify_client = spotify_client
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client
self.progress_callbacks = {} # Playlist-specific progress callbacks
self.syncing_playlists = set() # Track multiple syncing playlists
self._cancelled = False
self.matching_engine = MusicMatchingEngine()
def _get_active_media_client(self):
"""Get the active media client based on config settings"""
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "jellyfin":
if not self.jellyfin_client:
logger.error("Jellyfin client not provided to sync service")
return None, "jellyfin"
return self.jellyfin_client, "jellyfin"
else: # Default to Plex
return self.plex_client, "plex"
except Exception as e:
logger.error(f"Error determining active media server: {e}")
return self.plex_client, "plex" # Fallback to Plex
@property
def is_syncing(self):
"""Check if any playlist is currently syncing"""
@ -145,7 +164,7 @@ class PlaylistSyncService:
failed_tracks=len([r for r in match_results if not r.is_match]))
# Use the robust search approach
plex_match, confidence = await self._find_track_in_plex(track)
plex_match, confidence = await self._find_track_in_media_server(track)
match_result = MatchResult(
spotify_track=track,
@ -204,7 +223,14 @@ class PlaylistSyncService:
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(plex_tracks)} tracks are valid Plex objects with ratingKeys")
plex_tracks = valid_tracks
sync_success = self.plex_client.update_playlist(playlist.name, plex_tracks)
# Use active media server for playlist sync
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error(f"No active media client available for playlist sync")
sync_success = False
else:
logger.info(f"Syncing playlist '{playlist.name}' to {server_type.upper()} server")
sync_success = media_client.update_playlist(playlist.name, plex_tracks)
synced_tracks = len(plex_tracks) if sync_success else 0
failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks
@ -239,11 +265,13 @@ class PlaylistSyncService:
self.clear_progress_callback(playlist.name)
self._cancelled = False
async def _find_track_in_plex(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]:
async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]:
"""Find a track using the same improved database matching as Download Missing Tracks modal"""
try:
if not self.plex_client or not self.plex_client.is_connected():
logger.warning("Plex client not connected")
# Check active media server connection
media_client, server_type = self._get_active_media_client()
if not media_client or not media_client.is_connected():
logger.warning(f"{server_type.upper()} client not connected")
return None, 0.0
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker
@ -258,25 +286,40 @@ class PlaylistSyncService:
artist_name = artist if isinstance(artist, str) else artist
# Use the improved database check_track_exists method
# Use the improved database check_track_exists method with server awareness
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db = MusicDatabase()
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7)
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
logger.debug(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
# Fetch the actual Plex track object using the database track ID
# Fetch the actual track object from active media server using the database track ID
try:
actual_plex_track = self.plex_client.server.fetchItem(db_track.id)
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
return actual_plex_track, confidence
if server_type == "jellyfin":
# For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem)
class JellyfinTrackFromDB:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
actual_track = JellyfinTrackFromDB(db_track)
logger.debug(f"✔️ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
return actual_track, confidence
else:
logger.warning(f"❌ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
# For Plex, use the original fetchItem approach
actual_plex_track = media_client.server.fetchItem(db_track.id)
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
return actual_plex_track, confidence
else:
logger.warning(f"❌ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
except Exception as fetch_error:
logger.error(f"❌ Failed to fetch actual Plex track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
logger.error(f"❌ Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
# Continue to try other artists rather than fail completely
continue

View file

@ -544,8 +544,10 @@ class SinglesEPsLibraryWorker(QThread):
print(f" 🔍 Searching for single track: '{track_name}' by '{artist_name}'")
# Search for the track anywhere in the library
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7)
# Search for the track anywhere in the library (active server only)
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
print(f" ✅ Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})")
@ -645,8 +647,10 @@ class SinglesEPsLibraryWorker(QThread):
track_name = track['name']
artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "")
# Search for this track
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7)
# Search for this track (active server only)
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
owned_tracks += 1

View file

@ -119,7 +119,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations:
if self._cancelled: return None, 0.0
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
# Use server-aware database query to check only active server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
class MockPlexTrack:
@ -1935,10 +1938,11 @@ class DashboardDataProvider(QObject):
self.system_stats_timer.timeout.connect(self.update_system_stats)
self.system_stats_timer.start(10000) # Update every 10 seconds
def set_service_clients(self, spotify_client, plex_client, soulseek_client):
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
self.service_clients = {
'spotify_client': spotify_client,
'plex_client': plex_client,
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'soulseek_client': soulseek_client
}
@ -2344,8 +2348,11 @@ class MetadataUpdaterWidget(QFrame):
layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(12)
# Header
header_label = QLabel("Plex Metadata Updater")
# Header - Make it dynamic based on active server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_display = active_server.title()
header_label = QLabel(f"{server_display} Metadata Updater")
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
header_label.setStyleSheet("color: #ffffff;")
@ -2683,14 +2690,15 @@ class DashboardPage(QWidget):
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
return True
def set_service_clients(self, spotify_client, plex_client, soulseek_client, downloads_page=None):
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client, downloads_page=None):
"""Called from main window to provide service client references"""
self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client)
self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, soulseek_client)
# Store service clients for wishlist modal
self.service_clients = {
'spotify_client': spotify_client,
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'soulseek_client': soulseek_client,
'downloads_page': downloads_page
}
@ -2767,11 +2775,24 @@ class DashboardPage(QWidget):
return
# Create worker for incremental update only
plex_client = self.service_clients.get('plex_client')
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# Get the appropriate client
if active_server == "plex":
media_client = self.service_clients.get('plex_client')
elif active_server == "jellyfin":
from core.jellyfin_client import JellyfinClient
media_client = JellyfinClient()
else:
logger.error(f"Unknown active server for auto-update: {active_server}")
return
self._auto_database_worker = DatabaseUpdateWorker(
plex_client,
media_client,
"database/music_library.db",
full_refresh=False # Always incremental for automatic updates
full_refresh=False, # Always incremental for automatic updates
server_type=active_server
)
# Connect completion signal to log result
@ -3147,9 +3168,24 @@ class DashboardPage(QWidget):
logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}")
logger.debug(f"Plex client: {self.data_provider.service_clients.get('plex')}")
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'):
# Check that we have a data provider
if not hasattr(self, 'data_provider'):
self.add_activity_item("", "Database Update", "Service clients not available", "Now")
return
# Get the active media server and check if client is available
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'):
self.add_activity_item("", "Database Update", "Plex client not available", "Now")
return
elif active_server == "jellyfin":
# Jellyfin client will be created on-demand, just verify config exists
jellyfin_config = config_manager.get_jellyfin_config()
if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'):
self.add_activity_item("", "Database Update", "Jellyfin not configured", "Now")
return
try:
# Get update type from dropdown
@ -3172,11 +3208,28 @@ class DashboardPage(QWidget):
logger.debug("Full refresh cancelled by user")
return # Cancel the operation
# Get the active media server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# Get the appropriate client
if active_server == "plex":
media_client = self.data_provider.service_clients['plex_client']
elif active_server == "jellyfin":
# Import and get Jellyfin client
from core.jellyfin_client import JellyfinClient
media_client = JellyfinClient()
else:
logger.error(f"Unknown active server: {active_server}")
self.add_activity_item("", "Database Update", f"Unknown server type: {active_server}", "Now")
return
# Start the database update worker
self.database_worker = DatabaseUpdateWorker(
self.data_provider.service_clients['plex_client'],
media_client,
"database/music_library.db",
full_refresh
full_refresh,
server_type=active_server
)
# Connect signals
@ -3189,7 +3242,8 @@ class DashboardPage(QWidget):
# Update UI and start
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
update_type = "Full refresh" if full_refresh else "Incremental update"
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()}...", "Now")
server_display = active_server.title() # "Plex" or "Jellyfin"
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now")
self.database_worker.start()
@ -3349,6 +3403,15 @@ class DashboardPage(QWidget):
if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'):
logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}")
# Check active server and client availability
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# Currently metadata updater only supports Plex
if active_server != "plex":
self.add_activity_item("", "Metadata Update", f"Metadata updater only supports Plex (active: {active_server.title()})", "Now")
return
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'):
self.add_activity_item("", "Metadata Update", "Plex client not available", "Now")
return

View file

@ -624,8 +624,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations:
if self._cancelled: return None, 0.0
# Use database check_track_exists method with consistent thresholds
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
# Use database check_track_exists method with consistent thresholds and active server filter
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
print(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
@ -1633,7 +1635,8 @@ class PlaylistDetailsModal(QDialog):
self.parent_page.sync_service = PlaylistSyncService(
self.parent_page.spotify_client,
self.parent_page.plex_client,
self.parent_page.soulseek_client
self.parent_page.soulseek_client,
getattr(self.parent_page, 'jellyfin_client', None)
)
# Start sync
@ -2673,10 +2676,11 @@ class SyncPage(QWidget):
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
database_updated_externally = pyqtSignal()
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, parent=None):
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, parent=None):
super().__init__(parent)
self.spotify_client = spotify_client
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client
self.downloads_page = downloads_page
self.sync_statuses = load_sync_status()
@ -2866,7 +2870,8 @@ class SyncPage(QWidget):
self.sync_service = PlaylistSyncService(
self.spotify_client,
self.plex_client,
self.soulseek_client
self.soulseek_client,
getattr(self, 'jellyfin_client', None)
)
# Create sync worker
@ -2919,7 +2924,8 @@ class SyncPage(QWidget):
self.sync_service = PlaylistSyncService(
self.spotify_client,
self.plex_client,
self.soulseek_client
self.soulseek_client,
getattr(self, 'jellyfin_client', None)
)
# Create sync worker for sequential sync