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") logger = get_logger("database_update_worker")
class DatabaseUpdateWorker(QThread): 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 # Signals for progress reporting
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
@ -23,9 +23,20 @@ class DatabaseUpdateWorker(QThread):
error = pyqtSignal(str) # error_message error = pyqtSignal(str) # error_message
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks) 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__() 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.database_path = database_path
self.full_refresh = full_refresh self.full_refresh = full_refresh
self.should_stop = False self.should_stop = False
@ -39,8 +50,19 @@ class DatabaseUpdateWorker(QThread):
# Threading control - get from config or default to 5 # Threading control - get from config or default to 5
database_config = config_manager.get('database', {}) database_config = config_manager.get('database', {})
self.max_workers = database_config.get('max_workers', 5) base_max_workers = database_config.get('max_workers', 5)
logger.info(f"Using {self.max_workers} worker threads for database update")
# 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() self.thread_lock = threading.Lock()
# Database instance # Database instance
@ -49,6 +71,15 @@ class DatabaseUpdateWorker(QThread):
def stop(self): def stop(self):
"""Stop the database update process""" """Stop the database update process"""
self.should_stop = True 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): def run(self):
"""Main worker thread execution""" """Main worker thread execution"""
@ -57,14 +88,22 @@ class DatabaseUpdateWorker(QThread):
self.database = get_database(self.database_path) self.database = get_database(self.database_path)
if self.full_refresh: if self.full_refresh:
logger.info("Performing full database refresh - clearing existing data") logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data")
self.database.clear_all_data() self.database.clear_server_data(self.server_type)
# For full refresh, use the old method (all artists)
# 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() artists_to_process = self._get_all_artists()
if not artists_to_process: 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 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: else:
logger.info("Performing smart incremental update - checking recently added content") logger.info("Performing smart incremental update - checking recently added content")
# For incremental, use smart recent-first approach # For incremental, use smart recent-first approach
@ -78,7 +117,13 @@ class DatabaseUpdateWorker(QThread):
# Phase 2: Process artists and their albums/tracks # Phase 2: Process artists and their albums/tracks
self.phase_changed.emit("Processing artists, albums, and 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 # Record full refresh completion for tracking purposes
if self.full_refresh and self.database: if self.full_refresh and self.database:
@ -88,6 +133,15 @@ class DatabaseUpdateWorker(QThread):
except Exception as e: except Exception as e:
logger.warning(f"Could not record full refresh completion: {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) # Cleanup orphaned records after incremental updates (catches fixed matches)
if not self.full_refresh and self.database: if not self.full_refresh and self.database:
try: try:
@ -121,33 +175,38 @@ class DatabaseUpdateWorker(QThread):
self.error.emit(f"Database update failed: {str(e)}") self.error.emit(f"Database update failed: {str(e)}")
def _get_all_artists(self) -> List: def _get_all_artists(self) -> List:
"""Get all artists from Plex library""" """Get all artists from media server library"""
try: try:
if not self.plex_client.ensure_connection(): if not self.media_client.ensure_connection():
logger.error("Could not connect to Plex server") logger.error(f"Could not connect to {self.server_type} server")
return [] return []
artists = self.plex_client.get_all_artists() artists = self.media_client.get_all_artists()
return artists return artists
except Exception as e: 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 [] return []
def _get_artists_for_incremental_update(self) -> List: def _get_artists_for_incremental_update(self) -> List:
"""Get artists that need processing for incremental update using smart early-stopping logic""" """Get artists that need processing for incremental update using smart early-stopping logic"""
try: try:
if not self.plex_client.ensure_connection(): if not self.media_client.ensure_connection():
logger.error("Could not connect to Plex server") logger.error(f"Could not connect to {self.server_type} server")
return [] 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") logger.error("No music library found in Plex")
return [] return []
# Check if database has enough content for incremental updates # Check if database has enough content for incremental updates (server-specific)
try: 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) track_count = stats.get('tracks', 0)
if track_count < 100: # Minimum threshold for meaningful incremental updates if track_count < 100: # Minimum threshold for meaningful incremental updates
@ -165,105 +224,34 @@ class DatabaseUpdateWorker(QThread):
return self._get_all_artists() return self._get_all_artists()
# Enhanced Strategy: Get both recently added AND recently updated content # 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 # For Jellyfin, we need to set up progress callback for potential cache population during incremental
all_recent_content = [] 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: # PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
# Get recently added albums (up to 400 to catch more recent content) if self.server_type == "jellyfin":
try: return self._get_artists_for_jellyfin_track_incremental_update()
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 []
# Plex uses album-based approach (established and working)
recent_albums = self._get_recent_albums_for_server()
if not recent_albums: if not recent_albums:
logger.info("No recently added albums found") logger.info("No recently added albums found")
return [] return []
# Sort albums by added date (newest first) # Sort albums by added date (newest first) - handle None dates properly
try: 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)") logger.info("Sorted albums by recently added date (newest first)")
except Exception as e: except Exception as e:
logger.warning(f"Could not sort albums by date: {e}") logger.warning(f"Could not sort albums by date: {e}")
@ -315,10 +303,18 @@ class DatabaseUpdateWorker(QThread):
for track in tracks: for track in tracks:
total_tracks_checked += 1 total_tracks_checked += 1
try: 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') 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 missing_tracks_count += 1
album_has_new_tracks = True album_has_new_tracks = True
logger.debug(f"📀 Track '{track_title}' is new - album needs processing") logger.debug(f"📀 Track '{track_title}' is new - album needs processing")
@ -366,7 +362,8 @@ class DatabaseUpdateWorker(QThread):
try: try:
album_artist = album.artist() album_artist = album.artist()
if 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 # Skip if we've already queued this artist
if artist_id not in processed_artist_ids: if artist_id not in processed_artist_ids:
@ -398,16 +395,208 @@ class DatabaseUpdateWorker(QThread):
# Fallback to empty list - user can try full refresh # Fallback to empty list - user can try full refresh
return [] 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""" """Check if any tracks in the list have metadata changes compared to database"""
try: try:
if not self.database or not plex_tracks: if not self.database or not media_tracks:
return False return False
changes_detected = 0 changes_detected = 0
for track in plex_tracks: for track in media_tracks:
try: 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 # Get current data from database
db_track = self.database.get_track_by_id(track_id) 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}") logger.debug(f"Error checking for metadata changes: {e}")
return False # Assume no changes if we can't check 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): def _process_all_artists(self, artists: List):
"""Process all artists and their albums/tracks using thread pool""" """Process all artists and their albums/tracks using thread pool"""
total_artists = len(artists) total_artists = len(artists)
@ -505,21 +790,21 @@ class DatabaseUpdateWorker(QThread):
# Emit progress signal # Emit progress signal
self.artist_processed.emit(artist_name, success, details, album_count, track_count) 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]: def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
"""Process an artist and all their albums and tracks""" """Process an artist and all their albums and tracks with optimized API usage"""
try: try:
artist_name = getattr(plex_artist, 'title', 'Unknown Artist') artist_name = getattr(media_artist, 'title', 'Unknown Artist')
# 1. Insert/update the artist # 1. Insert/update the artist using server-agnostic method
artist_success = self.database.insert_or_update_artist(plex_artist) artist_success = self.database.insert_or_update_media_artist(media_artist, server_source=self.server_type)
if not artist_success: if not artist_success:
return False, "Failed to update artist data", 0, 0 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: try:
albums = list(plex_artist.albums()) albums = list(media_artist.albums())
except Exception as e: except Exception as e:
logger.warning(f"Could not get albums for artist '{artist_name}': {e}") logger.warning(f"Could not get albums for artist '{artist_name}': {e}")
return True, "Artist updated (no albums accessible)", 0, 0 return True, "Artist updated (no albums accessible)", 0, 0
@ -527,44 +812,56 @@ class DatabaseUpdateWorker(QThread):
album_count = 0 album_count = 0
track_count = 0 track_count = 0
# 3. Process each album # 3. Process albums in smaller batches to reduce memory usage
for album in albums: batch_size = 10 # Process 10 albums at a time
for i in range(0, len(albums), batch_size):
if self.should_stop: if self.should_stop:
break 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: album_batch = albums[i:i + batch_size]
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
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" details = f"Updated with {album_count} albums, {track_count} tracks"
return True, details, album_count, track_count return True, details, album_count, track_count
except Exception as e: 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 return False, f"Processing error: {str(e)}", 0, 0
class DatabaseStatsWorker(QThread): class DatabaseStatsWorker(QThread):
@ -591,18 +888,23 @@ class DatabaseStatsWorker(QThread):
if self.should_stop: if self.should_stop:
return return
# Get full database info (includes last_full_refresh) # Get database info for active server (server-aware statistics)
info = database.get_database_info() info = database.get_database_info_for_server()
if not self.should_stop: if not self.should_stop:
self.stats_updated.emit(info) self.stats_updated.emit(info)
except Exception as e: except Exception as e:
logger.error(f"Error getting database stats: {e}") logger.error(f"Error getting database stats: {e}")
if not self.should_stop: 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({ self.stats_updated.emit({
'artists': 0, 'artists': 0,
'albums': 0, 'albums': 0,
'tracks': 0, 'tracks': 0,
'database_size_mb': 0.0, 'database_size_mb': 0.0,
'last_update': None, '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 artist_name in artists_to_search:
for query_title in unique_title_variations: for query_title in unique_title_variations:
# Use same database check as modals # Use same database check as modals with server awareness
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7) 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: if db_track and confidence >= 0.7:
logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})") 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 os
import re import re
import threading import threading
import time
from datetime import datetime from datetime import datetime
from typing import List, Optional, Dict, Any, Tuple from typing import List, Optional, Dict, Any, Tuple
from dataclasses import dataclass 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_albums_title ON albums (title)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (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() conn.commit()
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@ -214,13 +221,168 @@ class MusicDatabase:
logger.error(f"Error initializing database: {e}") logger.error(f"Error initializing database: {e}")
raise 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): def close(self):
"""Close database connection (no-op since we create connections per operation)""" """Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here # Each operation creates and closes its own connection, so nothing to do here
pass pass
def get_statistics(self) -> Dict[str, int]: def get_statistics(self) -> Dict[str, int]:
"""Get database statistics""" """Get database statistics for all servers (legacy method)"""
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -243,8 +405,44 @@ class MusicDatabase:
logger.error(f"Error getting database statistics: {e}") logger.error(f"Error getting database statistics: {e}")
return {'artists': 0, 'albums': 0, 'tracks': 0} 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): 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: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@ -265,6 +463,38 @@ class MusicDatabase:
logger.error(f"Error clearing database: {e}") logger.error(f"Error clearing database: {e}")
raise 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]: def cleanup_orphaned_records(self) -> Dict[str, int]:
"""Remove artists and albums that have no associated tracks""" """Remove artists and albums that have no associated tracks"""
try: try:
@ -314,26 +544,31 @@ class MusicDatabase:
# Artist operations # Artist operations
def insert_or_update_artist(self, plex_artist) -> bool: 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: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
artist_id = int(plex_artist.ratingKey) # Convert artist ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
name = plex_artist.title artist_id = str(artist_obj.ratingKey)
thumb_url = getattr(plex_artist, 'thumb', None) name = artist_obj.title
summary = getattr(plex_artist, 'summary', None) thumb_url = getattr(artist_obj, 'thumb', None)
summary = getattr(artist_obj, 'summary', None)
# Get genres # Get genres (handle both Plex and Jellyfin formats)
genres = [] 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) 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 genres_json = json.dumps(genres) if genres else None
# Check if artist exists # Check if artist exists with this ID and server source
cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,)) cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = ?", (artist_id, server_source))
exists = cursor.fetchone() exists = cursor.fetchone()
if exists: if exists:
@ -341,20 +576,20 @@ class MusicDatabase:
cursor.execute(""" cursor.execute("""
UPDATE artists UPDATE artists
SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ? AND server_source = ?
""", (name, thumb_url, genres_json, summary, artist_id)) """, (name, thumb_url, genres_json, summary, artist_id, server_source))
else: else:
# Insert new artist # Insert new artist
cursor.execute(""" cursor.execute("""
INSERT INTO artists (id, name, thumb_url, genres, summary) INSERT INTO artists (id, name, thumb_url, genres, summary, server_source)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", (artist_id, name, thumb_url, genres_json, summary)) """, (artist_id, name, thumb_url, genres_json, summary, server_source))
conn.commit() conn.commit()
return True return True
except Exception as e: 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 return False
def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]: def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]:
@ -385,30 +620,35 @@ class MusicDatabase:
# Album operations # Album operations
def insert_or_update_album(self, plex_album, artist_id: int) -> bool: 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: try:
conn = self._get_connection() conn = self._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
album_id = int(plex_album.ratingKey) # Convert album ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
title = plex_album.title album_id = str(album_obj.ratingKey)
year = getattr(plex_album, 'year', None) title = album_obj.title
thumb_url = getattr(plex_album, 'thumb', None) year = getattr(album_obj, 'year', None)
thumb_url = getattr(album_obj, 'thumb', None)
# Get track count and duration # Get track count and duration (handle different server attributes)
track_count = getattr(plex_album, 'leafCount', None) track_count = getattr(album_obj, 'leafCount', None) or getattr(album_obj, 'childCount', None)
duration = getattr(plex_album, 'duration', None) duration = getattr(album_obj, 'duration', None)
# Get genres # Get genres (handle both Plex and Jellyfin formats)
genres = [] 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) 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 genres_json = json.dumps(genres) if genres else None
# Check if album exists # Check if album exists with this ID and server source
cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,)) cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = ?", (album_id, server_source))
exists = cursor.fetchone() exists = cursor.fetchone()
if exists: if exists:
@ -417,20 +657,20 @@ class MusicDatabase:
UPDATE albums UPDATE albums
SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?, SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ? AND server_source = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id)) """, (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id, server_source))
else: else:
# Insert new album # Insert new album
cursor.execute(""" cursor.execute("""
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration) INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration)) """, (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source))
conn.commit() conn.commit()
return True return True
except Exception as e: 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 return False
def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]: def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
@ -466,60 +706,70 @@ class MusicDatabase:
# Track operations # Track operations
def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool: def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool:
"""Insert or update track from Plex track object""" """Insert or update track from Plex track object - DEPRECATED: Use insert_or_update_media_track instead"""
try: return self.insert_or_update_media_track(plex_track, album_id, artist_id, server_source='plex')
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
def track_exists(self, track_id: int) -> bool: def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, server_source: str = 'plex') -> bool:
"""Check if a track exists in the database by Plex ID""" """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: try:
conn = self._get_connection() conn = self._get_connection()
cursor = conn.cursor() 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() result = cursor.fetchone()
return result is not None return result is not None
@ -528,12 +778,31 @@ class MusicDatabase:
logger.error(f"Error checking if track {track_id} exists: {e}") logger.error(f"Error checking if track {track_id} exists: {e}")
return False return False
def get_track_by_id(self, track_id: int) -> Optional[DatabaseTrackWithMetadata]: def track_exists_by_server(self, track_id, server_source: str) -> bool:
"""Get a track with artist and album names by Plex ID""" """Check if a track exists in the database by ID and server source"""
try: try:
conn = self._get_connection() conn = self._get_connection()
cursor = conn.cursor() 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(""" cursor.execute("""
SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number, SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number,
t.duration, t.created_at, t.updated_at, t.duration, t.created_at, t.updated_at,
@ -542,7 +811,7 @@ class MusicDatabase:
JOIN artists a ON t.artist_id = a.id JOIN artists a ON t.artist_id = a.id
JOIN albums al ON t.album_id = al.id JOIN albums al ON t.album_id = al.id
WHERE t.id = ? WHERE t.id = ?
""", (track_id,)) """, (track_id_str,))
row = cursor.fetchone() row = cursor.fetchone()
if row: if row:
@ -628,7 +897,7 @@ class MusicDatabase:
logger.error(f"Error searching artists with query '{query}': {e}") logger.error(f"Error searching artists with query '{query}': {e}")
return [] 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""" """Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
try: try:
if not title and not artist: if not title and not artist:
@ -638,7 +907,7 @@ class MusicDatabase:
cursor = conn.cursor() cursor = conn.cursor()
# STRATEGY 1: Try basic SQL LIKE search first (fastest) # 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: if basic_results:
logger.debug(f"🔍 Basic search found {len(basic_results)} results") logger.debug(f"🔍 Basic search found {len(basic_results)} results")
@ -652,7 +921,7 @@ class MusicDatabase:
unicode_support = False unicode_support = False
if unicode_support: 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: if normalized_results:
logger.debug(f"🔍 Unicode fallback search found {len(normalized_results)} results") logger.debug(f"🔍 Unicode fallback search found {len(normalized_results)} results")
return normalized_results return normalized_results
@ -668,7 +937,7 @@ class MusicDatabase:
logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}") logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}")
return [] 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""" """Basic SQL LIKE search - fastest method"""
where_conditions = [] where_conditions = []
params = [] params = []
@ -681,6 +950,11 @@ class MusicDatabase:
where_conditions.append("artists.name LIKE ?") where_conditions.append("artists.name LIKE ?")
params.append(f"%{artist}%") 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: if not where_conditions:
return [] return []
@ -699,7 +973,7 @@ class MusicDatabase:
return self._rows_to_tracks(cursor.fetchall()) 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""" """Unicode-aware fallback search - tries normalized versions"""
from unidecode import unidecode from unidecode import unidecode
@ -719,6 +993,11 @@ class MusicDatabase:
where_conditions.append("LOWER(artists.name) LIKE ?") where_conditions.append("LOWER(artists.name) LIKE ?")
params.append(f"%{artist_norm}%") 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: if not where_conditions:
return [] return []
@ -918,7 +1197,7 @@ class MusicDatabase:
return list(set(variations)) 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. 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. Now uses the same sophisticated matching approach as album checking for consistency.
@ -941,7 +1220,7 @@ class MusicDatabase:
potential_matches = [] potential_matches = []
artist_variations = self._get_artist_variations(artist) artist_variations = self._get_artist_variations(artist)
for artist_variation in artist_variations: 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: if not potential_matches:
continue continue
@ -1809,7 +2088,7 @@ class MusicDatabase:
return 0 return 0
def get_database_info(self) -> Dict[str, Any]: def get_database_info(self) -> Dict[str, Any]:
"""Get comprehensive database information""" """Get comprehensive database information for all servers (legacy method)"""
try: try:
stats = self.get_statistics() stats = self.get_statistics()
@ -1857,6 +2136,65 @@ class MusicDatabase:
'last_update': None, 'last_update': None,
'last_full_refresh': 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 # Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _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 utils.logging_config import setup_logging, get_logger
from core.spotify_client import SpotifyClient from core.spotify_client import SpotifyClient
from core.plex_client import PlexClient from core.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient from core.soulseek_client import SoulseekClient
from ui.sidebar import ModernSidebar from ui.sidebar import ModernSidebar
@ -27,10 +28,11 @@ logger = get_logger("main")
class ServiceStatusThread(QThread): class ServiceStatusThread(QThread):
status_updated = pyqtSignal(str, bool) 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__() super().__init__()
self.spotify_client = spotify_client self.spotify_client = spotify_client
self.plex_client = plex_client self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client self.soulseek_client = soulseek_client
self.running = True self.running = True
@ -51,20 +53,8 @@ class ServiceStatusThread(QThread):
server_status = self.plex_client.is_connected() server_status = self.plex_client.is_connected()
self.status_updated.emit("plex", server_status) self.status_updated.emit("plex", server_status)
elif active_server == "jellyfin": elif active_server == "jellyfin":
# For now, do a basic config check for Jellyfin until JellyfinClient is implemented # Use the JellyfinClient for status checking
jellyfin_config = self.config_manager.get_jellyfin_config() jellyfin_status = self.jellyfin_client.is_connected()
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
self.status_updated.emit("jellyfin", jellyfin_status) self.status_updated.emit("jellyfin", jellyfin_status)
# Check Soulseek connection (simplified check to avoid event loop issues) # Check Soulseek connection (simplified check to avoid event loop issues)
@ -91,6 +81,7 @@ class MainWindow(QMainWindow):
self.spotify_client = SpotifyClient() self.spotify_client = SpotifyClient()
self.plex_client = PlexClient() self.plex_client = PlexClient()
self.jellyfin_client = JellyfinClient()
self.soulseek_client = SoulseekClient() self.soulseek_client = SoulseekClient()
self.status_thread = None self.status_thread = None
@ -183,7 +174,13 @@ class MainWindow(QMainWindow):
# Create and add pages # Create and add pages
self.dashboard_page = DashboardPage() self.dashboard_page = DashboardPage()
self.downloads_page = DownloadsPage(self.soulseek_client) 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.artists_page = ArtistsPage(downloads_page=self.downloads_page)
self.settings_page = SettingsPage() self.settings_page = SettingsPage()
@ -194,7 +191,7 @@ class MainWindow(QMainWindow):
self.settings_page.set_toast_manager(self.toast_manager) self.settings_page.set_toast_manager(self.toast_manager)
# Configure dashboard with service clients and page references # 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_page_references(self.downloads_page, self.sync_page)
self.dashboard_page.set_app_start_time(self.app_start_time) self.dashboard_page.set_app_start_time(self.app_start_time)
self.dashboard_page.set_toast_manager(self.toast_manager) self.dashboard_page.set_toast_manager(self.toast_manager)
@ -241,6 +238,7 @@ class MainWindow(QMainWindow):
self.status_thread = ServiceStatusThread( self.status_thread = ServiceStatusThread(
self.spotify_client, self.spotify_client,
self.plex_client, self.plex_client,
self.jellyfin_client,
self.soulseek_client self.soulseek_client
) )
self.status_thread.status_updated.connect(self.update_service_status) 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 utils.logging_config import get_logger
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
from core.plex_client import PlexClient, PlexTrackInfo from core.plex_client import PlexClient, PlexTrackInfo
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient from core.soulseek_client import SoulseekClient
from core.matching_engine import MusicMatchingEngine, MatchResult from core.matching_engine import MusicMatchingEngine, MatchResult
@ -40,15 +41,33 @@ class SyncProgress:
failed_tracks: int = 0 failed_tracks: int = 0
class PlaylistSyncService: 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.spotify_client = spotify_client
self.plex_client = plex_client self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client self.soulseek_client = soulseek_client
self.progress_callbacks = {} # Playlist-specific progress callbacks self.progress_callbacks = {} # Playlist-specific progress callbacks
self.syncing_playlists = set() # Track multiple syncing playlists self.syncing_playlists = set() # Track multiple syncing playlists
self._cancelled = False self._cancelled = False
self.matching_engine = MusicMatchingEngine() 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 @property
def is_syncing(self): def is_syncing(self):
"""Check if any playlist is currently syncing""" """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])) failed_tracks=len([r for r in match_results if not r.is_match]))
# Use the robust search approach # 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( match_result = MatchResult(
spotify_track=track, 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") logger.info(f"Playlist validation: {len(valid_tracks)}/{len(plex_tracks)} tracks are valid Plex objects with ratingKeys")
plex_tracks = valid_tracks 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 synced_tracks = len(plex_tracks) if sync_success else 0
failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks
@ -239,11 +265,13 @@ class PlaylistSyncService:
self.clear_progress_callback(playlist.name) self.clear_progress_callback(playlist.name)
self._cancelled = False 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""" """Find a track using the same improved database matching as Download Missing Tracks modal"""
try: try:
if not self.plex_client or not self.plex_client.is_connected(): # Check active media server connection
logger.warning("Plex client not connected") 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 return None, 0.0
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker # Use the SAME improved database matching as PlaylistTrackAnalysisWorker
@ -258,25 +286,40 @@ class PlaylistSyncService:
artist_name = artist if isinstance(artist, str) else artist 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: try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db = MusicDatabase() 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: 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}") 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: try:
actual_plex_track = self.plex_client.server.fetchItem(db_track.id) if server_type == "jellyfin":
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'): # For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem)
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})") class JellyfinTrackFromDB:
return actual_plex_track, confidence 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: 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: 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 to try other artists rather than fail completely
continue continue

View file

@ -544,8 +544,10 @@ class SinglesEPsLibraryWorker(QThread):
print(f" 🔍 Searching for single track: '{track_name}' by '{artist_name}'") print(f" 🔍 Searching for single track: '{track_name}' by '{artist_name}'")
# Search for the track anywhere in the library # Search for the track anywhere in the library (active server only)
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7) 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: if db_track and confidence >= 0.7:
print(f" ✅ Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})") 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'] track_name = track['name']
artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "") artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "")
# Search for this track # Search for this track (active server only)
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7) 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: if db_track and confidence >= 0.7:
owned_tracks += 1 owned_tracks += 1

View file

@ -119,7 +119,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations: for query_title in unique_title_variations:
if self._cancelled: return None, 0.0 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: if db_track and confidence >= 0.7:
class MockPlexTrack: class MockPlexTrack:
@ -1935,10 +1938,11 @@ class DashboardDataProvider(QObject):
self.system_stats_timer.timeout.connect(self.update_system_stats) self.system_stats_timer.timeout.connect(self.update_system_stats)
self.system_stats_timer.start(10000) # Update every 10 seconds 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 = { self.service_clients = {
'spotify_client': spotify_client, 'spotify_client': spotify_client,
'plex_client': plex_client, 'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'soulseek_client': soulseek_client 'soulseek_client': soulseek_client
} }
@ -2344,8 +2348,11 @@ class MetadataUpdaterWidget(QFrame):
layout.setContentsMargins(20, 15, 20, 15) layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(12) layout.setSpacing(12)
# Header # Header - Make it dynamic based on active server
header_label = QLabel("Plex Metadata Updater") 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.setFont(QFont("Arial", 14, QFont.Weight.Bold))
header_label.setStyleSheet("color: #ffffff;") header_label.setStyleSheet("color: #ffffff;")
@ -2683,14 +2690,15 @@ class DashboardPage(QWidget):
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished) self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
return True 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""" """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 # Store service clients for wishlist modal
self.service_clients = { self.service_clients = {
'spotify_client': spotify_client, 'spotify_client': spotify_client,
'plex_client': plex_client, 'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'soulseek_client': soulseek_client, 'soulseek_client': soulseek_client,
'downloads_page': downloads_page 'downloads_page': downloads_page
} }
@ -2767,11 +2775,24 @@ class DashboardPage(QWidget):
return return
# Create worker for incremental update only # 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( self._auto_database_worker = DatabaseUpdateWorker(
plex_client, media_client,
"database/music_library.db", "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 # 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"Service clients available: {list(self.data_provider.service_clients.keys())}")
logger.debug(f"Plex client: {self.data_provider.service_clients.get('plex')}") 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") self.add_activity_item("", "Database Update", "Plex client not available", "Now")
return 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: try:
# Get update type from dropdown # Get update type from dropdown
@ -3172,11 +3208,28 @@ class DashboardPage(QWidget):
logger.debug("Full refresh cancelled by user") logger.debug("Full refresh cancelled by user")
return # Cancel the operation 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 # Start the database update worker
self.database_worker = DatabaseUpdateWorker( self.database_worker = DatabaseUpdateWorker(
self.data_provider.service_clients['plex_client'], media_client,
"database/music_library.db", "database/music_library.db",
full_refresh full_refresh,
server_type=active_server
) )
# Connect signals # Connect signals
@ -3189,7 +3242,8 @@ class DashboardPage(QWidget):
# Update UI and start # Update UI and start
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0) self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
update_type = "Full refresh" if full_refresh else "Incremental update" 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() self.database_worker.start()
@ -3349,6 +3403,15 @@ class DashboardPage(QWidget):
if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'): 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())}") 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'): 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") self.add_activity_item("", "Metadata Update", "Plex client not available", "Now")
return return

View file

@ -624,8 +624,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations: for query_title in unique_title_variations:
if self._cancelled: return None, 0.0 if self._cancelled: return None, 0.0
# Use database check_track_exists method with consistent thresholds # Use database check_track_exists method with consistent thresholds and active server filter
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7) 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: 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}") 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.sync_service = PlaylistSyncService(
self.parent_page.spotify_client, self.parent_page.spotify_client,
self.parent_page.plex_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 # Start sync
@ -2673,10 +2676,11 @@ class SyncPage(QWidget):
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
database_updated_externally = pyqtSignal() 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) super().__init__(parent)
self.spotify_client = spotify_client self.spotify_client = spotify_client
self.plex_client = plex_client self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client self.soulseek_client = soulseek_client
self.downloads_page = downloads_page self.downloads_page = downloads_page
self.sync_statuses = load_sync_status() self.sync_statuses = load_sync_status()
@ -2866,7 +2870,8 @@ class SyncPage(QWidget):
self.sync_service = PlaylistSyncService( self.sync_service = PlaylistSyncService(
self.spotify_client, self.spotify_client,
self.plex_client, self.plex_client,
self.soulseek_client self.soulseek_client,
getattr(self, 'jellyfin_client', None)
) )
# Create sync worker # Create sync worker
@ -2919,7 +2924,8 @@ class SyncPage(QWidget):
self.sync_service = PlaylistSyncService( self.sync_service = PlaylistSyncService(
self.spotify_client, self.spotify_client,
self.plex_client, self.plex_client,
self.soulseek_client self.soulseek_client,
getattr(self, 'jellyfin_client', None)
) )
# Create sync worker for sequential sync # Create sync worker for sequential sync