From 620c78766b44c07f390ee4926c37ea4d7a8a61d6 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 18 Aug 2025 10:36:57 -0700 Subject: [PATCH] better --- core/database_update_worker.py | 620 +++++++++++++----- core/jellyfin_client.py | 1078 ++++++++++++++++++++++++++++++++ core/watchlist_scanner.py | 6 +- database/music_database.py | 532 +++++++++++++--- main.py | 32 +- services/sync_service.py | 73 ++- ui/pages/artists.py | 12 +- ui/pages/dashboard.py | 91 ++- ui/pages/sync.py | 18 +- 9 files changed, 2148 insertions(+), 314 deletions(-) create mode 100644 core/jellyfin_client.py diff --git a/core/database_update_worker.py b/core/database_update_worker.py index c86e1cc5..6471a2ab 100644 --- a/core/database_update_worker.py +++ b/core/database_update_worker.py @@ -14,7 +14,7 @@ from config.settings import config_manager logger = get_logger("database_update_worker") class DatabaseUpdateWorker(QThread): - """Worker thread for updating SoulSync database with Plex library data""" + """Worker thread for updating SoulSync database with media server library data (Plex or Jellyfin)""" # Signals for progress reporting progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage @@ -23,9 +23,20 @@ class DatabaseUpdateWorker(QThread): error = pyqtSignal(str) # error_message phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks) - def __init__(self, plex_client, database_path: str = "database/music_library.db", full_refresh: bool = False): + def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"): super().__init__() - self.plex_client = plex_client + # Support both old plex_client parameter and new media_client parameter for backward compatibility + if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower(): + self.media_client = media_client + self.server_type = "plex" + # Keep old attribute for backward compatibility + self.plex_client = media_client + else: + self.media_client = media_client + self.server_type = server_type + # Keep old attribute for backward compatibility with existing code that expects it + self.plex_client = media_client if server_type == "plex" else None + self.database_path = database_path self.full_refresh = full_refresh self.should_stop = False @@ -39,8 +50,19 @@ class DatabaseUpdateWorker(QThread): # Threading control - get from config or default to 5 database_config = config_manager.get('database', {}) - self.max_workers = database_config.get('max_workers', 5) - logger.info(f"Using {self.max_workers} worker threads for database update") + base_max_workers = database_config.get('max_workers', 5) + + # Optimize worker count - reduce for database concurrency safety + if self.server_type == "jellyfin": + # Reduce workers to prevent database lock issues with bulk inserts + self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety + if base_max_workers > 3: + logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety") + else: + # Plex uses standard worker count + self.max_workers = base_max_workers + + logger.info(f"Using {self.max_workers} worker threads for {self.server_type} database update") self.thread_lock = threading.Lock() # Database instance @@ -49,6 +71,15 @@ class DatabaseUpdateWorker(QThread): def stop(self): """Stop the database update process""" self.should_stop = True + + # Clear Jellyfin cache when user stops scan to free memory + if self.server_type == "jellyfin" and hasattr(self, 'media_client'): + try: + cache_stats = self.media_client.get_cache_stats() + self.media_client.clear_cache() + logger.info(f"๐Ÿงน Cleared Jellyfin cache after user stop - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory") + except Exception as e: + logger.warning(f"Could not clear Jellyfin cache on stop: {e}") def run(self): """Main worker thread execution""" @@ -57,14 +88,22 @@ class DatabaseUpdateWorker(QThread): self.database = get_database(self.database_path) if self.full_refresh: - logger.info("Performing full database refresh - clearing existing data") - self.database.clear_all_data() - # For full refresh, use the old method (all artists) + logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data") + self.database.clear_server_data(self.server_type) + + # Show cache preparation phase for Jellyfin and set up progress callback + if self.server_type == "jellyfin": + self.phase_changed.emit("Preparing Jellyfin cache for fast processing...") + # Connect Jellyfin client progress to UI + if hasattr(self.media_client, 'set_progress_callback'): + self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(msg)) + + # For full refresh, get all artists artists_to_process = self._get_all_artists() if not artists_to_process: - self.error.emit("No artists found in Plex library or connection failed") + self.error.emit(f"No artists found in {self.server_type} library or connection failed") return - logger.info(f"Full refresh: Found {len(artists_to_process)} artists in Plex library") + logger.info(f"Full refresh: Found {len(artists_to_process)} artists in {self.server_type} library") else: logger.info("Performing smart incremental update - checking recently added content") # For incremental, use smart recent-first approach @@ -78,7 +117,13 @@ class DatabaseUpdateWorker(QThread): # Phase 2: Process artists and their albums/tracks self.phase_changed.emit("Processing artists, albums, and tracks...") - self._process_all_artists(artists_to_process) + + # FAST PATH: For Jellyfin track-based incremental, process new tracks directly + if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'): + self._process_jellyfin_new_tracks_directly(artists_to_process) + else: + # Standard artist processing for Plex or full refresh + self._process_all_artists(artists_to_process) # Record full refresh completion for tracking purposes if self.full_refresh and self.database: @@ -88,6 +133,15 @@ class DatabaseUpdateWorker(QThread): except Exception as e: logger.warning(f"Could not record full refresh completion: {e}") + # Clear Jellyfin cache after full refresh to free memory + if self.full_refresh and self.server_type == "jellyfin": + try: + cache_stats = self.media_client.get_cache_stats() + self.media_client.clear_cache() + logger.info(f"๐Ÿงน Cleared Jellyfin cache after full refresh - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory") + except Exception as e: + logger.warning(f"Could not clear Jellyfin cache: {e}") + # Cleanup orphaned records after incremental updates (catches fixed matches) if not self.full_refresh and self.database: try: @@ -121,33 +175,38 @@ class DatabaseUpdateWorker(QThread): self.error.emit(f"Database update failed: {str(e)}") def _get_all_artists(self) -> List: - """Get all artists from Plex library""" + """Get all artists from media server library""" try: - if not self.plex_client.ensure_connection(): - logger.error("Could not connect to Plex server") + if not self.media_client.ensure_connection(): + logger.error(f"Could not connect to {self.server_type} server") return [] - artists = self.plex_client.get_all_artists() + artists = self.media_client.get_all_artists() return artists except Exception as e: - logger.error(f"Error getting artists from Plex: {e}") + logger.error(f"Error getting artists from {self.server_type}: {e}") return [] def _get_artists_for_incremental_update(self) -> List: """Get artists that need processing for incremental update using smart early-stopping logic""" try: - if not self.plex_client.ensure_connection(): - logger.error("Could not connect to Plex server") + if not self.media_client.ensure_connection(): + logger.error(f"Could not connect to {self.server_type} server") return [] - if not self.plex_client.music_library: + # Check for music library (Plex-specific check) + if self.server_type == "plex" and not self.media_client.music_library: logger.error("No music library found in Plex") return [] - # Check if database has enough content for incremental updates + # Check if database has enough content for incremental updates (server-specific) try: - stats = self.database.get_database_info() + # Get stats for the specific server we're updating + if hasattr(self.database, 'get_database_info_for_server'): + stats = self.database.get_database_info_for_server(self.server_type) + else: + stats = self.database.get_database_info() track_count = stats.get('tracks', 0) if track_count < 100: # Minimum threshold for meaningful incremental updates @@ -165,105 +224,34 @@ class DatabaseUpdateWorker(QThread): return self._get_all_artists() # Enhanced Strategy: Get both recently added AND recently updated content - # This catches both new content and metadata corrections done in Plex + # This catches both new content and metadata corrections done on the server - logger.info("Getting recently added and recently updated content...") + logger.info(f"Getting recently added and recently updated content from {self.server_type}...") - # Get both recently added and recently updated albums - all_recent_content = [] + # For Jellyfin, we need to set up progress callback for potential cache population during incremental + if self.server_type == "jellyfin": + if hasattr(self.media_client, 'set_progress_callback'): + self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(f"Incremental: {msg}")) - try: - # Get recently added albums (up to 400 to catch more recent content) - try: - recently_added = self.plex_client.music_library.recentlyAdded(libtype='album', maxresults=400) - all_recent_content.extend(recently_added) - logger.info(f"Found {len(recently_added)} recently added albums") - except: - # Fallback to general recently added - recently_added = self.plex_client.music_library.recentlyAdded(maxresults=400) - all_recent_content.extend(recently_added) - logger.info(f"Found {len(recently_added)} recently added items (mixed types)") - - # Get recently updated albums (catches metadata corrections) - increased limit - try: - recently_updated = self.plex_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400) - # Remove duplicates (items that are both recently added and updated) - added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content} - unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys] - all_recent_content.extend(unique_updated) - logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)") - except Exception as e: - logger.warning(f"Could not get recently updated content: {e}") - - recent_content = all_recent_content - logger.info(f"Combined total: {len(recent_content)} recent albums (added + updated)") - - # Filter to only get Album objects and convert Artist objects to their albums - recent_albums = [] - artist_count = 0 - album_count = 0 - - for item in recent_content: - try: - item_type = type(item).__name__ - logger.info(f"Processing recently added item: {item_type} - '{getattr(item, 'title', 'Unknown')}'") - - if hasattr(item, 'tracks') and hasattr(item, 'artist'): - # This is an Album - add directly - recent_albums.append(item) - album_count += 1 - logger.info(f"โœ… Added album directly: '{item.title}'") - elif hasattr(item, 'albums'): - # This is an Artist - get their albums - try: - artist_albums = list(item.albums()) - if artist_albums: - recent_albums.extend(artist_albums) - artist_count += 1 - logger.info(f"โœ… Added {len(artist_albums)} albums from artist '{item.title}'") - else: - logger.info(f"โš ๏ธ Artist '{item.title}' has no albums") - except Exception as albums_error: - logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}") - else: - # Unknown type - skip - logger.info(f"โŒ Skipping unsupported type: {item_type} (has tracks: {hasattr(item, 'tracks')}, has albums: {hasattr(item, 'albums')}, has artist: {hasattr(item, 'artist')})") - except Exception as e: - logger.warning(f"Error processing recently added item: {e}") - continue - - logger.info(f"Processed recently added content: {artist_count} artists โ†’ albums, {album_count} direct albums") - - logger.info(f"Extracted {len(recent_albums)} albums from recently added content") - except Exception as e: - logger.warning(f"Could not get recently added albums: {e}") - # Fallback: get recently added tracks instead - try: - recent_tracks = self.plex_client.music_library.recentlyAdded(libtype='track', maxresults=1000) - logger.info(f"Fallback: Found {len(recent_tracks)} recently added tracks") - # Extract albums from tracks - recent_albums = [] - seen_albums = set() - for track in recent_tracks: - try: - album = track.album() - if album and album.ratingKey not in seen_albums: - recent_albums.append(album) - seen_albums.add(album.ratingKey) - except: - continue - logger.info(f"Extracted {len(recent_albums)} unique albums from tracks") - except Exception as e2: - logger.error(f"Could not get recently added content: {e2}") - return [] + # PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster) + if self.server_type == "jellyfin": + return self._get_artists_for_jellyfin_track_incremental_update() + # Plex uses album-based approach (established and working) + recent_albums = self._get_recent_albums_for_server() if not recent_albums: logger.info("No recently added albums found") return [] - # Sort albums by added date (newest first) + # Sort albums by added date (newest first) - handle None dates properly try: - recent_albums.sort(key=lambda x: getattr(x, 'addedAt', 0), reverse=True) + def get_sort_date(album): + date_val = getattr(album, 'addedAt', None) + if date_val is None: + return 0 # Fallback for albums with no date + return date_val + + recent_albums.sort(key=get_sort_date, reverse=True) logger.info("Sorted albums by recently added date (newest first)") except Exception as e: logger.warning(f"Could not sort albums by date: {e}") @@ -315,10 +303,18 @@ class DatabaseUpdateWorker(QThread): for track in tracks: total_tracks_checked += 1 try: - track_id = int(track.ratingKey) + # Handle both Plex (integer) and Jellyfin (string GUID) IDs + track_id = str(track.ratingKey) track_title = getattr(track, 'title', 'Unknown Track') - if not self.database.track_exists(track_id): + # Use server-aware track existence check + if hasattr(self.database, 'track_exists_by_server'): + track_exists = self.database.track_exists_by_server(track_id, self.server_type) + else: + # Fallback to generic check (works for string IDs) + track_exists = self.database.track_exists(track_id) + + if not track_exists: missing_tracks_count += 1 album_has_new_tracks = True logger.debug(f"๐Ÿ“€ Track '{track_title}' is new - album needs processing") @@ -366,7 +362,8 @@ class DatabaseUpdateWorker(QThread): try: album_artist = album.artist() if album_artist: - artist_id = int(album_artist.ratingKey) + # Handle both Plex (integer) and Jellyfin (string GUID) artist IDs + artist_id = str(album_artist.ratingKey) # Skip if we've already queued this artist if artist_id not in processed_artist_ids: @@ -398,16 +395,208 @@ class DatabaseUpdateWorker(QThread): # Fallback to empty list - user can try full refresh return [] - def _check_for_metadata_changes(self, plex_tracks) -> bool: + def _get_artists_for_jellyfin_track_incremental_update(self) -> List: + """FAST Jellyfin incremental update using recent tracks directly (no caching needed)""" + try: + logger.info("๐Ÿš€ FAST Jellyfin incremental: getting recent tracks directly...") + + # Get recent tracks directly from Jellyfin (FAST - 2 API calls) + recent_added_tracks = self.media_client.get_recently_added_tracks(5000) + recent_updated_tracks = self.media_client.get_recently_updated_tracks(5000) + + # Combine and deduplicate + all_recent_tracks = recent_added_tracks[:] + added_ids = {track.ratingKey for track in recent_added_tracks} + unique_updated = [track for track in recent_updated_tracks if track.ratingKey not in added_ids] + all_recent_tracks.extend(unique_updated) + + logger.info(f"Found {len(recent_added_tracks)} recent + {len(unique_updated)} updated = {len(all_recent_tracks)} tracks to check") + + if not all_recent_tracks: + logger.info("No recent tracks found") + return [] + + # Check which tracks are actually new (FAST - database lookups only) + new_tracks = [] + consecutive_existing_tracks = 0 + processed_artists = set() + + for i, track in enumerate(all_recent_tracks): + try: + track_id = str(track.ratingKey) + + # Check if track exists in database + if hasattr(self.database, 'track_exists_by_server'): + track_exists = self.database.track_exists_by_server(track_id, self.server_type) + else: + track_exists = self.database.track_exists(track_id) + + if not track_exists: + new_tracks.append(track) + consecutive_existing_tracks = 0 # Reset counter + logger.debug(f"๐ŸŽต New track: {track.title}") + else: + consecutive_existing_tracks += 1 + logger.debug(f"โœ… Track exists: {track.title}") + + # Early stopping: if we find 100 consecutive existing tracks, we're done + if consecutive_existing_tracks >= 100: + logger.info(f"๐Ÿ›‘ Found 100 consecutive existing tracks - stopping after checking {i+1} tracks") + break + + except Exception as e: + logger.debug(f"Error checking track {getattr(track, 'title', 'Unknown')}: {e}") + continue + + logger.info(f"Found {len(new_tracks)} genuinely new tracks (early stopped after {consecutive_existing_tracks} consecutive existing)") + + if not new_tracks: + logger.info("All recent tracks already exist - database is up to date") + return [] + + # Store new tracks for direct processing (avoid slow artist->album->track lookups) + self._jellyfin_new_tracks = new_tracks + + # Extract unique artists from new tracks (FAST - no additional API calls needed) + artists_to_process = [] + for track in new_tracks: + try: + # Track already has artist info from the API call + track_artist = track.artist() # This will make an API call, but only for new tracks + if track_artist: + artist_id = str(track_artist.ratingKey) + if artist_id not in processed_artists: + processed_artists.add(artist_id) + artists_to_process.append(track_artist) + logger.info(f"โœ… Added artist '{track_artist.title}' (from new track '{track.title}')") + except Exception as e: + logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}") + continue + + logger.info(f"๐Ÿš€ FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)") + return artists_to_process + + except Exception as e: + logger.error(f"Error in fast Jellyfin incremental update: {e}") + return [] + + def _process_jellyfin_new_tracks_directly(self, artists_to_process): + """Process new Jellyfin tracks directly without slow artist->album->track lookups""" + try: + new_tracks = getattr(self, '_jellyfin_new_tracks', []) + if not new_tracks: + logger.warning("No new tracks to process directly") + return + + logger.info(f"๐Ÿš€ FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...") + + # Group tracks by album and artist for efficient processing + tracks_by_album = {} + albums_by_artist = {} + + for track in new_tracks: + try: + # Track already has album and artist IDs from API response + album_id = str(track._album_id) if track._album_id else "unknown" + artist_id = str(track._artist_ids[0]) if track._artist_ids else "unknown" + + if album_id not in tracks_by_album: + tracks_by_album[album_id] = [] + tracks_by_album[album_id].append(track) + + if artist_id not in albums_by_artist: + albums_by_artist[artist_id] = set() + albums_by_artist[artist_id].add(album_id) + + except Exception as e: + logger.debug(f"Error grouping track {getattr(track, 'title', 'Unknown')}: {e}") + continue + + total_processed_tracks = 0 + total_processed_albums = 0 + total_processed_artists = 0 + + # Process each artist + for artist in artists_to_process: + if self.should_stop: + break + + try: + artist_id = str(artist.ratingKey) + artist_name = getattr(artist, 'title', 'Unknown Artist') + + # Insert/update the artist + artist_success = self.database.insert_or_update_media_artist(artist, server_source=self.server_type) + if artist_success: + total_processed_artists += 1 + + # Process albums for this artist + artist_album_ids = albums_by_artist.get(artist_id, set()) + for album_id in artist_album_ids: + if self.should_stop: + break + + try: + # Get album from the first track (they all have the same album) + album_tracks = tracks_by_album[album_id] + if album_tracks: + album = album_tracks[0].album() # Get album object + if album: + # Insert/update album + album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type) + if album_success: + total_processed_albums += 1 + + # Process all tracks in this album + for track in album_tracks: + if self.should_stop: + break + + try: + track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type) + if track_success: + total_processed_tracks += 1 + logger.debug(f"โœ… Processed new track: {track.title}") + except Exception as e: + logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") + except Exception as e: + logger.warning(f"Failed to process album {album_id}: {e}") + + # Emit progress for this artist + artist_albums = len(artist_album_ids) + artist_tracks = sum(len(tracks_by_album[aid]) for aid in artist_album_ids if aid in tracks_by_album) + self.artist_processed.emit(artist_name, True, f"Processed {artist_albums} albums, {artist_tracks} tracks", artist_albums, artist_tracks) + + except Exception as e: + logger.error(f"Error processing artist '{getattr(artist, 'title', 'Unknown')}': {e}") + self.artist_processed.emit(getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0) + + # Update totals + with self.thread_lock: + self.processed_artists += total_processed_artists + self.processed_albums += total_processed_albums + self.processed_tracks += total_processed_tracks + self.successful_operations += total_processed_artists # Count successful artists + + logger.info(f"๐Ÿš€ FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks") + + # Clean up + delattr(self, '_jellyfin_new_tracks') + + except Exception as e: + logger.error(f"Error in fast Jellyfin track processing: {e}") + + def _check_for_metadata_changes(self, media_tracks) -> bool: """Check if any tracks in the list have metadata changes compared to database""" try: - if not self.database or not plex_tracks: + if not self.database or not media_tracks: return False changes_detected = 0 - for track in plex_tracks: + for track in media_tracks: try: - track_id = int(track.ratingKey) + # Handle both Plex (integer) and Jellyfin (string GUID) IDs + track_id = str(track.ratingKey) # Get current data from database db_track = self.database.get_track_by_id(track_id) @@ -442,6 +631,102 @@ class DatabaseUpdateWorker(QThread): logger.debug(f"Error checking for metadata changes: {e}") return False # Assume no changes if we can't check + def _get_recent_albums_for_server(self) -> List: + """Get recently added albums using server-specific methods""" + try: + if self.server_type == "plex": + return self._get_recent_albums_plex() + elif self.server_type == "jellyfin": + return self._get_recent_albums_jellyfin() + else: + logger.error(f"Unknown server type: {self.server_type}") + return [] + except Exception as e: + logger.error(f"Error getting recent albums for {self.server_type}: {e}") + return [] + + def _get_recent_albums_plex(self) -> List: + """Get recently added and updated albums from Plex""" + all_recent_content = [] + + try: + # Get recently added albums (up to 400 to catch more recent content) + try: + recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400) + all_recent_content.extend(recently_added) + logger.info(f"Found {len(recently_added)} recently added albums") + except: + # Fallback to general recently added + recently_added = self.media_client.music_library.recentlyAdded(maxresults=400) + all_recent_content.extend(recently_added) + logger.info(f"Found {len(recently_added)} recently added items (mixed types)") + + # Get recently updated albums (catches metadata corrections) + try: + recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400) + # Remove duplicates (items that are both recently added and updated) + added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content} + unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys] + all_recent_content.extend(unique_updated) + logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)") + except Exception as e: + logger.warning(f"Could not get recently updated content: {e}") + + # Filter to only get Album objects and convert Artist objects to their albums + recent_albums = [] + artist_count = 0 + album_count = 0 + + for item in all_recent_content: + try: + if hasattr(item, 'tracks') and hasattr(item, 'artist'): + # This is an Album - add directly + recent_albums.append(item) + album_count += 1 + elif hasattr(item, 'albums'): + # This is an Artist - get their albums + try: + artist_albums = list(item.albums()) + if artist_albums: + recent_albums.extend(artist_albums) + artist_count += 1 + except Exception as albums_error: + logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}") + except Exception as e: + logger.warning(f"Error processing recently added item: {e}") + continue + + logger.info(f"Processed {artist_count} artists โ†’ albums, {album_count} direct albums") + return recent_albums + + except Exception as e: + logger.error(f"Error getting recent Plex albums: {e}") + return [] + + def _get_recent_albums_jellyfin(self) -> List: + """Get recently added and updated albums from Jellyfin""" + try: + all_recent_albums = [] + + # Get recently added albums + recently_added = self.media_client.get_recently_added_albums(400) + all_recent_albums.extend(recently_added) + logger.info(f"Found {len(recently_added)} recently added albums") + + # Get recently updated albums + recently_updated = self.media_client.get_recently_updated_albums(400) + # Remove duplicates + added_ids = {album.ratingKey for album in all_recent_albums} + unique_updated = [album for album in recently_updated if album.ratingKey not in added_ids] + all_recent_albums.extend(unique_updated) + logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)") + + return all_recent_albums + + except Exception as e: + logger.error(f"Error getting recent Jellyfin albums: {e}") + return [] + def _process_all_artists(self, artists: List): """Process all artists and their albums/tracks using thread pool""" total_artists = len(artists) @@ -505,21 +790,21 @@ class DatabaseUpdateWorker(QThread): # Emit progress signal self.artist_processed.emit(artist_name, success, details, album_count, track_count) - def _process_artist_with_content(self, plex_artist) -> tuple[bool, str, int, int]: - """Process an artist and all their albums and tracks""" + def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]: + """Process an artist and all their albums and tracks with optimized API usage""" try: - artist_name = getattr(plex_artist, 'title', 'Unknown Artist') + artist_name = getattr(media_artist, 'title', 'Unknown Artist') - # 1. Insert/update the artist - artist_success = self.database.insert_or_update_artist(plex_artist) + # 1. Insert/update the artist using server-agnostic method + artist_success = self.database.insert_or_update_media_artist(media_artist, server_source=self.server_type) if not artist_success: return False, "Failed to update artist data", 0, 0 - artist_id = int(plex_artist.ratingKey) + artist_id = str(media_artist.ratingKey) - # 2. Get all albums for this artist + # 2. Get all albums for this artist (cached from aggressive pre-population) try: - albums = list(plex_artist.albums()) + albums = list(media_artist.albums()) except Exception as e: logger.warning(f"Could not get albums for artist '{artist_name}': {e}") return True, "Artist updated (no albums accessible)", 0, 0 @@ -527,44 +812,56 @@ class DatabaseUpdateWorker(QThread): album_count = 0 track_count = 0 - # 3. Process each album - for album in albums: + # 3. Process albums in smaller batches to reduce memory usage + batch_size = 10 # Process 10 albums at a time + for i in range(0, len(albums), batch_size): if self.should_stop: break - - try: - # Insert/update album - album_success = self.database.insert_or_update_album(album, artist_id) - if album_success: - album_count += 1 - album_id = int(album.ratingKey) - - # 4. Process tracks in this album - try: - tracks = list(album.tracks()) - - for track in tracks: - if self.should_stop: - break - - try: - track_success = self.database.insert_or_update_track(track, album_id, artist_id) - if track_success: - track_count += 1 - except Exception as e: - logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") - - except Exception as e: - logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}") - except Exception as e: - logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}") + album_batch = albums[i:i + batch_size] + + for album in album_batch: + if self.should_stop: + break + + try: + # Insert/update album using server-agnostic method + album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type) + if album_success: + album_count += 1 + album_id = str(album.ratingKey) + + # 4. Process tracks in this album (cached from aggressive pre-population) + try: + tracks = list(album.tracks()) + + # Batch insert tracks for better database performance + track_batch = [] + for track in tracks: + if self.should_stop: + break + track_batch.append((track, album_id, artist_id)) + + # Process track batch + for track, alb_id, art_id in track_batch: + try: + track_success = self.database.insert_or_update_media_track(track, alb_id, art_id, server_source=self.server_type) + if track_success: + track_count += 1 + except Exception as e: + logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") + + except Exception as e: + logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}") + + except Exception as e: + logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}") details = f"Updated with {album_count} albums, {track_count} tracks" return True, details, album_count, track_count except Exception as e: - logger.error(f"Error processing artist '{getattr(plex_artist, 'title', 'Unknown')}': {e}") + logger.error(f"Error processing artist '{getattr(media_artist, 'title', 'Unknown')}': {e}") return False, f"Processing error: {str(e)}", 0, 0 class DatabaseStatsWorker(QThread): @@ -591,18 +888,23 @@ class DatabaseStatsWorker(QThread): if self.should_stop: return - # Get full database info (includes last_full_refresh) - info = database.get_database_info() + # Get database info for active server (server-aware statistics) + info = database.get_database_info_for_server() if not self.should_stop: self.stats_updated.emit(info) except Exception as e: logger.error(f"Error getting database stats: {e}") if not self.should_stop: + # Import here to avoid circular imports + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + self.stats_updated.emit({ 'artists': 0, 'albums': 0, 'tracks': 0, 'database_size_mb': 0.0, 'last_update': None, - 'last_full_refresh': None + 'last_full_refresh': None, + 'server_source': active_server }) \ No newline at end of file diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py new file mode 100644 index 00000000..e6f1a887 --- /dev/null +++ b/core/jellyfin_client.py @@ -0,0 +1,1078 @@ +import requests +from typing import List, Optional, Dict, Any +from dataclasses import dataclass +from datetime import datetime +import json +from utils.logging_config import get_logger +from config.settings import config_manager + +logger = get_logger("jellyfin_client") + +@dataclass +class JellyfinTrackInfo: + id: str + title: str + artist: str + album: str + duration: int + track_number: Optional[int] = None + year: Optional[int] = None + rating: Optional[float] = None + +@dataclass +class JellyfinPlaylistInfo: + id: str + title: str + description: Optional[str] + duration: int + leaf_count: int + tracks: List[JellyfinTrackInfo] + +class JellyfinArtist: + """Wrapper class to mimic Plex artist object interface""" + def __init__(self, jellyfin_data: Dict[str, Any], client: 'JellyfinClient'): + self._data = jellyfin_data + self._client = client + self.ratingKey = jellyfin_data.get('Id', '') + self.title = jellyfin_data.get('Name', 'Unknown Artist') + self.addedAt = self._parse_date(jellyfin_data.get('DateCreated')) + + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + """Parse Jellyfin date string to datetime""" + if not date_str: + return None + try: + # Jellyfin uses ISO format: 2023-12-01T10:30:00.000Z + return datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except: + return None + + def albums(self) -> List['JellyfinAlbum']: + """Get all albums for this artist""" + return self._client.get_albums_for_artist(self.ratingKey) + +class JellyfinAlbum: + """Wrapper class to mimic Plex album object interface""" + def __init__(self, jellyfin_data: Dict[str, Any], client: 'JellyfinClient'): + self._data = jellyfin_data + self._client = client + self.ratingKey = jellyfin_data.get('Id', '') + self.title = jellyfin_data.get('Name', 'Unknown Album') + self.addedAt = self._parse_date(jellyfin_data.get('DateCreated')) + self._artist_id = jellyfin_data.get('AlbumArtists', [{}])[0].get('Id', '') if jellyfin_data.get('AlbumArtists') else '' + + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + if not date_str: + return None + try: + return datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except: + return None + + def artist(self) -> Optional[JellyfinArtist]: + """Get the album artist""" + if self._artist_id: + return self._client.get_artist_by_id(self._artist_id) + return None + + def tracks(self) -> List['JellyfinTrack']: + """Get all tracks for this album""" + return self._client.get_tracks_for_album(self.ratingKey) + +class JellyfinTrack: + """Wrapper class to mimic Plex track object interface""" + def __init__(self, jellyfin_data: Dict[str, Any], client: 'JellyfinClient'): + self._data = jellyfin_data + self._client = client + self.ratingKey = jellyfin_data.get('Id', '') + self.title = jellyfin_data.get('Name', 'Unknown Track') + self.duration = jellyfin_data.get('RunTimeTicks', 0) // 10000 # Convert from ticks to milliseconds + self.trackNumber = jellyfin_data.get('IndexNumber') + self.year = jellyfin_data.get('ProductionYear') + self.userRating = jellyfin_data.get('UserData', {}).get('Rating') + self.addedAt = self._parse_date(jellyfin_data.get('DateCreated')) + + self._album_id = jellyfin_data.get('AlbumId', '') + self._artist_ids = [artist.get('Id', '') for artist in jellyfin_data.get('ArtistItems', [])] + + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + if not date_str: + return None + try: + return datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except: + return None + + def artist(self) -> Optional[JellyfinArtist]: + """Get the primary track artist""" + if self._artist_ids: + return self._client.get_artist_by_id(self._artist_ids[0]) + return None + + def album(self) -> Optional[JellyfinAlbum]: + """Get the track's album""" + if self._album_id: + return self._client.get_album_by_id(self._album_id) + return None + +class JellyfinClient: + def __init__(self): + self.base_url: Optional[str] = None + self.api_key: Optional[str] = None + self.user_id: Optional[str] = None + self.music_library_id: Optional[str] = None + self._connection_attempted = False + self._is_connecting = False + + # Performance optimization: comprehensive caches + self._album_cache = {} + self._track_cache = {} + self._artist_cache = {} + self._all_albums_cache = None + self._all_tracks_cache = None + self._cache_populated = False + + # Progress callback for UI updates during caching + self._progress_callback = None + + def set_progress_callback(self, callback): + """Set callback function for cache progress updates: callback(message)""" + self._progress_callback = callback + + def ensure_connection(self) -> bool: + """Ensure connection to Jellyfin server with lazy initialization.""" + if self._connection_attempted: + return self.base_url is not None and self.api_key is not None + + if self._is_connecting: + return False + + self._is_connecting = True + try: + self._setup_client() + return self.base_url is not None and self.api_key is not None + finally: + self._is_connecting = False + self._connection_attempted = True + + def _setup_client(self): + """Setup Jellyfin client configuration""" + config = config_manager.get_jellyfin_config() + + if not config.get('base_url'): + logger.warning("Jellyfin server URL not configured") + return + + if not config.get('api_key'): + logger.warning("Jellyfin API key not configured") + return + + self.base_url = config['base_url'].rstrip('/') + self.api_key = config['api_key'] + + try: + # Test connection and get system info + response = self._make_request('/System/Info') + if response: + server_name = response.get('ServerName', 'Unknown') + logger.info(f"Successfully connected to Jellyfin server: {server_name}") + + # Get the first user (admin user typically) + users_response = self._make_request('/Users') + if users_response and len(users_response) > 0: + self.user_id = users_response[0]['Id'] + logger.info(f"Using user: {users_response[0].get('Name', 'Unknown')}") + + # Find music library + self._find_music_library() + else: + logger.error("No users found on Jellyfin server") + + except Exception as e: + logger.error(f"Failed to connect to Jellyfin server: {e}") + self.base_url = None + self.api_key = None + + def _find_music_library(self): + """Find the music library in Jellyfin""" + if not self.user_id: + return + + try: + views_response = self._make_request(f'/Users/{self.user_id}/Views') + if not views_response: + return + + for view in views_response.get('Items', []): + if view.get('CollectionType') == 'music': + self.music_library_id = view['Id'] + logger.info(f"Found music library: {view.get('Name', 'Music')}") + break + + if not self.music_library_id: + logger.warning("No music library found on Jellyfin server") + + except Exception as e: + logger.error(f"Error finding music library: {e}") + + def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Make authenticated request to Jellyfin API""" + if not self.base_url or not self.api_key: + return None + + url = f"{self.base_url}{endpoint}" + headers = { + 'X-Emby-Token': self.api_key, + 'Content-Type': 'application/json' + } + + # Use longer timeout for bulk operations (lots of data) + is_bulk_operation = params and params.get('Limit', 0) > 1000 + timeout = 30 if is_bulk_operation else 5 + + try: + response = requests.get(url, headers=headers, params=params, timeout=timeout) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Jellyfin API request failed: {e}") + return None + except json.JSONDecodeError as e: + logger.error(f"Failed to parse Jellyfin response: {e}") + return None + + def _populate_aggressive_cache(self): + """Aggressively pre-populate ALL caches to eliminate individual API calls""" + if self._cache_populated: + return + + logger.info("๐Ÿš€ Starting aggressive Jellyfin cache population to eliminate slow individual API calls...") + if self._progress_callback: + self._progress_callback("Fetching all tracks in bulk...") + + try: + # SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast) + logger.info("๐ŸŽต Fetching all tracks in bulk...") + all_tracks = [] + start_index = 0 + limit = 10000 + consecutive_failures = 0 + + while True: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True, + 'Fields': 'AlbumId,ArtistItems', + 'SortBy': 'AlbumId,IndexNumber', + 'SortOrder': 'Ascending', + 'StartIndex': start_index, + 'Limit': limit + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + + if not response: + consecutive_failures += 1 + if consecutive_failures >= 3: + logger.warning("๐Ÿšจ Multiple track fetch failures - stopping") + break + + if limit > 1000: + limit = limit // 2 + logger.warning(f"โš ๏ธ Track fetch timeout - reducing batch size to {limit}") + continue + else: + break + + consecutive_failures = 0 + batch_tracks = response.get('Items', []) + if not batch_tracks: + break + + all_tracks.extend(batch_tracks) + + if len(batch_tracks) < limit: + break + + start_index += limit + progress_msg = f"Fetched {len(all_tracks)} tracks so far..." + logger.info(f" ๐ŸŽต {progress_msg} (batch size: {limit})") + if self._progress_callback: + self._progress_callback(progress_msg) + + # Group tracks by album ID for instant lookup + self._track_cache = {} + for track_data in all_tracks: + album_id = track_data.get('AlbumId') + if album_id: + if album_id not in self._track_cache: + self._track_cache[album_id] = [] + self._track_cache[album_id].append(JellyfinTrack(track_data, self)) + + logger.info(f"โœ… Cached {len(all_tracks)} tracks for {len(self._track_cache)} albums") + if self._progress_callback: + self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...") + + # STEP 2: Fetch all albums in bulk (same proven pattern) + logger.info("๐Ÿ“€ Fetching all albums in bulk...") + all_albums = [] + start_index = 0 + limit = 10000 + consecutive_failures = 0 + + while True: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'MusicAlbum', + 'Recursive': True, + 'Fields': 'AlbumArtists,Artists', + 'SortBy': 'SortName', + 'SortOrder': 'Ascending', + 'StartIndex': start_index, + 'Limit': limit + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + + if not response: + consecutive_failures += 1 + if consecutive_failures >= 3: + logger.warning("๐Ÿšจ Multiple album fetch failures - stopping") + break + + if limit > 1000: + limit = limit // 2 + logger.warning(f"โš ๏ธ Album fetch timeout - reducing batch size to {limit}") + continue + else: + break + + consecutive_failures = 0 + batch_albums = response.get('Items', []) + if not batch_albums: + break + + all_albums.extend(batch_albums) + + if len(batch_albums) < limit: + break + + start_index += limit + progress_msg = f"Fetched {len(all_albums)} albums so far..." + logger.info(f" ๐Ÿ“€ {progress_msg} (batch size: {limit})") + if self._progress_callback: + self._progress_callback(progress_msg) + + # Group albums by artist ID for instant lookup + self._album_cache = {} + for album_data in all_albums: + album_artists = album_data.get('AlbumArtists', []) + for artist in album_artists: + artist_id = artist.get('Id') + if artist_id: + if artist_id not in self._album_cache: + self._album_cache[artist_id] = [] + self._album_cache[artist_id].append(JellyfinAlbum(album_data, self)) + + logger.info(f"โœ… Cached {len(all_albums)} albums for {len(self._album_cache)} artists") + + self._cache_populated = True + logger.info("๐ŸŽฏ AGGRESSIVE CACHE COMPLETE! All subsequent album/track lookups will be INSTANT!") + if self._progress_callback: + self._progress_callback("Cache complete! Now processing artists...") + + except Exception as e: + logger.error(f"Error in aggressive cache population: {e}") + # Don't set cache_populated to True on error so we can retry + + def _populate_targeted_cache_for_albums(self, albums: List['JellyfinAlbum']): + """Populate cache only for tracks in specific albums - much faster for incremental updates""" + if not albums: + return + + logger.info(f"๐ŸŽฏ Starting targeted Jellyfin cache for {len(albums)} recent albums...") + if self._progress_callback: + self._progress_callback(f"Caching tracks for {len(albums)} recent albums...") + + try: + album_ids = [album.ratingKey for album in albums] + cached_tracks = 0 + + # Process albums individually - Jellyfin API requires ParentId per album + for i, album_id in enumerate(album_ids): + try: + # Fetch tracks for this specific album + params = { + 'ParentId': album_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True, + 'Fields': 'AlbumId,ArtistItems', + 'SortBy': 'IndexNumber', + 'SortOrder': 'Ascending', + 'Limit': 200 # Most albums won't have more than 200 tracks + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if response: + album_tracks = response.get('Items', []) + + # Cache tracks for this album + if album_tracks: + self._track_cache[album_id] = [] + for track_data in album_tracks: + self._track_cache[album_id].append(JellyfinTrack(track_data, self)) + cached_tracks += 1 + + except Exception as e: + logger.debug(f"Error caching tracks for album {album_id}: {e}") + continue + + # Progress update every 50 albums + if (i + 1) % 50 == 0 or i == len(album_ids) - 1: + progress_msg = f"Cached {cached_tracks} tracks from {i + 1} albums..." + logger.info(f" ๐ŸŽฏ {progress_msg}") + if self._progress_callback: + self._progress_callback(progress_msg) + + logger.info(f"โœ… Targeted cache complete: {cached_tracks} tracks cached for {len(self._track_cache)} albums") + if self._progress_callback: + self._progress_callback("Targeted cache complete! Now checking for new tracks...") + + except Exception as e: + logger.error(f"Error in targeted cache population: {e}") + + def is_connected(self) -> bool: + """Check if connected to Jellyfin server""" + if not self._connection_attempted: + if not self._is_connecting: + self.ensure_connection() + return (self.base_url is not None and + self.api_key is not None and + self.user_id is not None and + self.music_library_id is not None) + + def get_all_artists(self) -> List[JellyfinArtist]: + """Get all artists from the music library - matches Plex interface""" + if not self.ensure_connection() or not self.music_library_id: + logger.error("Not connected to Jellyfin server or no music library") + return [] + + # PERFORMANCE OPTIMIZATION: Pre-populate ALL caches upfront for massive speedup + self._populate_aggressive_cache() + + try: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'MusicArtist', + 'Recursive': True, + 'SortBy': 'SortName', + 'SortOrder': 'Ascending' + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + artists = [] + for item in response.get('Items', []): + artist = JellyfinArtist(item, self) + # Cache the artist for quick lookup + self._artist_cache[artist.ratingKey] = artist + artists.append(artist) + + logger.info(f"Retrieved {len(artists)} artists from Jellyfin (with aggressive caching)") + return artists + + except Exception as e: + logger.error(f"Error getting artists from Jellyfin: {e}") + return [] + + def get_albums_for_artist(self, artist_id: str) -> List[JellyfinAlbum]: + """Get all albums for a specific artist""" + # Use cache if available + if artist_id in self._album_cache: + return self._album_cache[artist_id] + + if not self.ensure_connection(): + return [] + + try: + # Use smaller, faster API call + params = { + 'ArtistIds': artist_id, + 'IncludeItemTypes': 'MusicAlbum', + 'Recursive': True, + 'SortBy': 'ProductionYear,SortName', + 'SortOrder': 'Ascending', + 'Limit': 200 # Reasonable limit for most artists + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + albums = [] + for item in response.get('Items', []): + albums.append(JellyfinAlbum(item, self)) + + # Cache the result + self._album_cache[artist_id] = albums + + return albums + + except Exception as e: + logger.error(f"Error getting albums for artist {artist_id}: {e}") + return [] + + def get_tracks_for_album(self, album_id: str) -> List[JellyfinTrack]: + """Get all tracks for a specific album""" + # Use cache if available + if album_id in self._track_cache: + return self._track_cache[album_id] + + if not self.ensure_connection(): + return [] + + try: + # Most albums have < 30 tracks, so this is reasonable + params = { + 'ParentId': album_id, + 'IncludeItemTypes': 'Audio', + 'SortBy': 'IndexNumber', + 'SortOrder': 'Ascending', + 'Limit': 100 # Most albums won't hit this limit + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + tracks = [] + for item in response.get('Items', []): + tracks.append(JellyfinTrack(item, self)) + + # Cache the result + self._track_cache[album_id] = tracks + + return tracks + + except Exception as e: + logger.error(f"Error getting tracks for album {album_id}: {e}") + return [] + + def get_artist_by_id(self, artist_id: str) -> Optional[JellyfinArtist]: + """Get a specific artist by ID""" + # Check cache first + if artist_id in self._artist_cache: + return self._artist_cache[artist_id] + + if not self.ensure_connection(): + return None + + try: + response = self._make_request(f'/Users/{self.user_id}/Items/{artist_id}') + if response: + artist = JellyfinArtist(response, self) + # Cache for future use + self._artist_cache[artist_id] = artist + return artist + return None + + except Exception as e: + logger.error(f"Error getting artist {artist_id}: {e}") + return None + + def get_album_by_id(self, album_id: str) -> Optional[JellyfinAlbum]: + """Get a specific album by ID""" + # Check if we can find this album in any artist's cache + for artist_albums in self._album_cache.values(): + for album in artist_albums: + if album.ratingKey == album_id: + return album + + if not self.ensure_connection(): + return None + + try: + response = self._make_request(f'/Users/{self.user_id}/Items/{album_id}') + if response: + return JellyfinAlbum(response, self) + return None + + except Exception as e: + logger.error(f"Error getting album {album_id}: {e}") + return None + + def get_recently_added_albums(self, max_results: int = 400) -> List[JellyfinAlbum]: + """Get recently added albums - used for incremental updates""" + if not self.ensure_connection() or not self.music_library_id: + return [] + + try: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'MusicAlbum', + 'Recursive': True, + 'SortBy': 'DateCreated', + 'SortOrder': 'Descending', + 'Limit': max_results + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + albums = [] + for item in response.get('Items', []): + albums.append(JellyfinAlbum(item, self)) + + logger.info(f"Retrieved {len(albums)} recently added albums from Jellyfin") + return albums + + except Exception as e: + logger.error(f"Error getting recently added albums: {e}") + return [] + + def get_recently_updated_albums(self, max_results: int = 400) -> List[JellyfinAlbum]: + """Get recently updated albums - used for incremental updates""" + if not self.ensure_connection() or not self.music_library_id: + return [] + + try: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'MusicAlbum', + 'Recursive': True, + 'SortBy': 'DateLastMediaAdded', + 'SortOrder': 'Descending', + 'Limit': max_results + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + albums = [] + for item in response.get('Items', []): + albums.append(JellyfinAlbum(item, self)) + + logger.info(f"Retrieved {len(albums)} recently updated albums from Jellyfin") + return albums + + except Exception as e: + logger.error(f"Error getting recently updated albums: {e}") + return [] + + def get_recently_added_tracks(self, max_results: int = 5000) -> List[JellyfinTrack]: + """Get recently added tracks directly - much faster for incremental updates""" + if not self.ensure_connection() or not self.music_library_id: + return [] + + try: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True, + 'SortBy': 'DateCreated', + 'SortOrder': 'Descending', + 'Fields': 'AlbumId,ArtistItems', + 'Limit': max_results + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + tracks = [] + for item in response.get('Items', []): + tracks.append(JellyfinTrack(item, self)) + + logger.info(f"Retrieved {len(tracks)} recently added tracks from Jellyfin") + return tracks + + except Exception as e: + logger.error(f"Error getting recently added tracks: {e}") + return [] + + def get_recently_updated_tracks(self, max_results: int = 5000) -> List[JellyfinTrack]: + """Get recently updated tracks directly - much faster for incremental updates""" + if not self.ensure_connection() or not self.music_library_id: + return [] + + try: + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True, + 'SortBy': 'DateLastSaved', # When track metadata was last saved + 'SortOrder': 'Descending', + 'Fields': 'AlbumId,ArtistItems', + 'Limit': max_results + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + tracks = [] + for item in response.get('Items', []): + tracks.append(JellyfinTrack(item, self)) + + logger.info(f"Retrieved {len(tracks)} recently updated tracks from Jellyfin") + return tracks + + except Exception as e: + logger.error(f"Error getting recently updated tracks: {e}") + return [] + + def get_library_stats(self) -> Dict[str, int]: + """Get library statistics - matches Plex interface""" + if not self.ensure_connection() or not self.music_library_id: + return {} + + try: + stats = {} + + # Get artist count + artists_params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'MusicArtist', + 'Recursive': True + } + artists_response = self._make_request(f'/Users/{self.user_id}/Items', artists_params) + stats['artists'] = artists_response.get('TotalRecordCount', 0) if artists_response else 0 + + # Get album count + albums_params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'MusicAlbum', + 'Recursive': True + } + albums_response = self._make_request(f'/Users/{self.user_id}/Items', albums_params) + stats['albums'] = albums_response.get('TotalRecordCount', 0) if albums_response else 0 + + # Get track count + tracks_params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True + } + tracks_response = self._make_request(f'/Users/{self.user_id}/Items', tracks_params) + stats['tracks'] = tracks_response.get('TotalRecordCount', 0) if tracks_response else 0 + + return stats + + except Exception as e: + logger.error(f"Error getting library stats: {e}") + return {} + + def clear_cache(self): + """Clear all caches to force fresh data on next request""" + self._album_cache.clear() + self._track_cache.clear() + self._artist_cache.clear() + self._all_albums_cache = None + self._all_tracks_cache = None + self._cache_populated = False + logger.info("Jellyfin client cache cleared") + + def get_cache_stats(self) -> Dict[str, int]: + """Get statistics about cached data for performance monitoring""" + stats = { + 'cached_artists': len(self._artist_cache), + 'cached_artist_albums': len(self._album_cache), + 'cached_album_tracks': len(self._track_cache), + 'cache_populated': self._cache_populated + } + + if self._all_albums_cache: + stats['bulk_albums_cached'] = len(self._all_albums_cache) + if self._all_tracks_cache: + stats['bulk_tracks_cached'] = len(self._all_tracks_cache) + + return stats + + def get_all_playlists(self) -> List[JellyfinPlaylistInfo]: + """Get all playlists from Jellyfin server""" + if not self.ensure_connection(): + return [] + + try: + params = { + 'IncludeItemTypes': 'Playlist', + 'Recursive': True + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + playlists = [] + for item in response.get('Items', []): + playlist_info = JellyfinPlaylistInfo( + id=item.get('Id', ''), + title=item.get('Name', 'Unknown Playlist'), + description=item.get('Overview'), + duration=item.get('RunTimeTicks', 0) // 10000, + leaf_count=item.get('ChildCount', 0), + tracks=[] # Will be populated when needed + ) + playlists.append(playlist_info) + + logger.info(f"Retrieved {len(playlists)} playlists from Jellyfin") + return playlists + + except Exception as e: + logger.error(f"Error getting playlists from Jellyfin: {e}") + return [] + + def get_playlist_by_name(self, name: str) -> Optional[JellyfinPlaylistInfo]: + """Get a specific playlist by name""" + playlists = self.get_all_playlists() + for playlist in playlists: + if playlist.title.lower() == name.lower(): + return playlist + return None + + def create_playlist(self, name: str, tracks) -> bool: + """Create a new playlist with given tracks""" + if not self.ensure_connection(): + return False + + try: + # Convert tracks to Jellyfin track IDs + track_ids = [] + for track in tracks: + if hasattr(track, 'ratingKey'): + track_ids.append(str(track.ratingKey)) + elif hasattr(track, 'id'): + track_ids.append(str(track.id)) + + if not track_ids: + logger.warning(f"No valid tracks provided for playlist '{name}'") + return False + + logger.info(f"Creating Jellyfin playlist '{name}' with {len(track_ids)} tracks") + + # For large playlists, create empty playlist first then add tracks in batches + if len(track_ids) > 500: + return self._create_large_playlist(name, track_ids) + + # Create playlist using POST request for smaller playlists + import requests + url = f"{self.base_url}/Playlists" + headers = { + 'X-Emby-Token': self.api_key, + 'Content-Type': 'application/json' + } + data = { + 'Name': name, + 'UserId': self.user_id, + 'MediaType': 'Audio', + 'Ids': track_ids + } + + response = requests.post(url, json=data, headers=headers, timeout=30) + + # Log response details for debugging + logger.debug(f"Jellyfin playlist creation response: Status {response.status_code}") + if response.status_code >= 400: + logger.error(f"Jellyfin API error: {response.status_code} - {response.text}") + + response.raise_for_status() + + result = response.json() + if result and 'Id' in result: + logger.info(f"โœ… Created Jellyfin playlist '{name}' with {len(track_ids)} tracks") + return True + else: + logger.error(f"Failed to create Jellyfin playlist '{name}': No playlist ID returned") + return False + + except Exception as e: + logger.error(f"Error creating Jellyfin playlist '{name}': {e}") + return False + + def _create_large_playlist(self, name: str, track_ids: List[str]) -> bool: + """Create a large playlist by first creating empty playlist, then adding tracks in batches""" + try: + import requests + + # Step 1: Create empty playlist + url = f"{self.base_url}/Playlists" + headers = { + 'X-Emby-Token': self.api_key, + 'Content-Type': 'application/json' + } + data = { + 'Name': name, + 'UserId': self.user_id, + 'MediaType': 'Audio', + 'Ids': [] # Empty playlist + } + + response = requests.post(url, json=data, headers=headers, timeout=10) + response.raise_for_status() + + result = response.json() + if not result or 'Id' not in result: + logger.error(f"Failed to create empty Jellyfin playlist '{name}'") + return False + + playlist_id = result['Id'] + logger.info(f"Created empty Jellyfin playlist '{name}' (ID: {playlist_id})") + + # Step 2: Add tracks in batches of 100 + batch_size = 100 + total_batches = (len(track_ids) + batch_size - 1) // batch_size + + for i in range(0, len(track_ids), batch_size): + batch = track_ids[i:i + batch_size] + batch_num = (i // batch_size) + 1 + + logger.info(f"Adding batch {batch_num}/{total_batches} ({len(batch)} tracks) to playlist '{name}'") + + # Add tracks to playlist using POST to /Playlists/{id}/Items + add_url = f"{self.base_url}/Playlists/{playlist_id}/Items" + add_params = { + 'Ids': ','.join(batch), + 'UserId': self.user_id + } + + add_response = requests.post(add_url, params=add_params, headers={'X-Emby-Token': self.api_key}, timeout=30) + + if add_response.status_code not in [200, 204]: + logger.warning(f"Failed to add batch {batch_num} to playlist: {add_response.status_code} - {add_response.text}") + # Continue with other batches even if one fails + + logger.info(f"โœ… Created large Jellyfin playlist '{name}' with {len(track_ids)} tracks in {total_batches} batches") + return True + + except Exception as e: + logger.error(f"Error creating large Jellyfin playlist '{name}': {e}") + return False + + def copy_playlist(self, source_name: str, target_name: str) -> bool: + """Copy a playlist to create a backup""" + if not self.ensure_connection(): + return False + + try: + # Get the source playlist + source_playlist = self.get_playlist_by_name(source_name) + if not source_playlist: + logger.error(f"Source playlist '{source_name}' not found") + return False + + # Get tracks from source playlist + source_tracks = self.get_playlist_tracks(source_playlist.id) + logger.debug(f"Retrieved {len(source_tracks) if source_tracks else 0} tracks from source playlist") + + # Validate tracks + if not source_tracks: + logger.warning(f"Source playlist '{source_name}' has no tracks to copy") + return False + + # Delete target playlist if it exists (for overwriting backup) + try: + target_playlist = self.get_playlist_by_name(target_name) + if target_playlist: + import requests + url = f"{self.base_url}/Items/{target_playlist.id}" + headers = {'X-Emby-Token': self.api_key} + + response = requests.delete(url, headers=headers, timeout=10) + if response.status_code in [200, 204]: + logger.info(f"Deleted existing backup playlist '{target_name}'") + except Exception: + pass # Target doesn't exist, which is fine + + # Create new playlist with copied tracks + try: + success = self.create_playlist(target_name, source_tracks) + if success: + logger.info(f"โœ… Created backup playlist '{target_name}' with {len(source_tracks)} tracks") + return True + else: + logger.error(f"Failed to create backup playlist '{target_name}'") + return False + except Exception as create_error: + logger.error(f"Failed to create backup playlist: {create_error}") + return False + + except Exception as e: + logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}") + return False + + def get_playlist_tracks(self, playlist_id: str) -> List: + """Get all tracks from a specific playlist""" + if not self.ensure_connection(): + return [] + + try: + params = { + 'ParentId': playlist_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True, + 'Fields': 'AlbumId,ArtistItems', + 'SortBy': 'SortName', + 'SortOrder': 'Ascending' + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + tracks = [] + for item in response.get('Items', []): + tracks.append(JellyfinTrack(item, self)) + + logger.debug(f"Retrieved {len(tracks)} tracks from playlist {playlist_id}") + return tracks + + except Exception as e: + logger.error(f"Error getting tracks for playlist {playlist_id}: {e}") + return [] + + def update_playlist(self, playlist_name: str, tracks) -> bool: + """Update an existing playlist or create it if it doesn't exist""" + if not self.ensure_connection(): + return False + + try: + existing_playlist = self.get_playlist_by_name(playlist_name) + + # Check if backup is enabled in config + from config.settings import config_manager + create_backup = config_manager.get('playlist_sync.create_backup', True) + + if existing_playlist and create_backup: + backup_name = f"{playlist_name} Backup" + logger.info(f"๐Ÿ›ก๏ธ Creating backup playlist '{backup_name}' before sync") + + if self.copy_playlist(playlist_name, backup_name): + logger.info(f"โœ… Backup created successfully") + else: + logger.warning(f"โš ๏ธ Failed to create backup, continuing with sync") + + if existing_playlist: + # Delete existing playlist using DELETE request + import requests + url = f"{self.base_url}/Items/{existing_playlist.id}" + headers = { + 'X-Emby-Token': self.api_key + } + + response = requests.delete(url, headers=headers, timeout=10) + if response.status_code in [200, 204]: + logger.info(f"Deleted existing Jellyfin playlist '{playlist_name}'") + else: + logger.warning(f"Could not delete existing playlist '{playlist_name}' (status: {response.status_code}), creating anyway") + + # Create new playlist with tracks + return self.create_playlist(playlist_name, tracks) + + except Exception as e: + logger.error(f"Error updating Jellyfin playlist '{playlist_name}': {e}") + return False \ No newline at end of file diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 3a0a5767..6f52da0f 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -362,8 +362,10 @@ class WatchlistScanner: for artist_name in artists_to_search: for query_title in unique_title_variations: - # Use same database check as modals - db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7) + # Use same database check as modals with server awareness + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server) if db_track and confidence >= 0.7: logger.debug(f"โœ”๏ธ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})") diff --git a/database/music_database.py b/database/music_database.py index 0f80eee7..df37655f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6,6 +6,7 @@ import logging import os import re import threading +import time from datetime import datetime from typing import List, Optional, Dict, Any, Tuple from dataclasses import dataclass @@ -207,6 +208,12 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)") + # Add server_source columns for multi-server support (migration) + self._add_server_source_columns(cursor) + + # Migrate ID columns to support both integer (Plex) and string (Jellyfin) IDs + self._migrate_id_columns_to_text(cursor) + conn.commit() logger.info("Database initialized successfully") @@ -214,13 +221,168 @@ class MusicDatabase: logger.error(f"Error initializing database: {e}") raise + def _add_server_source_columns(self, cursor): + """Add server_source columns to existing tables for multi-server support""" + try: + # Check if server_source column exists in artists table + cursor.execute("PRAGMA table_info(artists)") + artists_columns = [column[1] for column in cursor.fetchall()] + + if 'server_source' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN server_source TEXT DEFAULT 'plex'") + logger.info("Added server_source column to artists table") + + # Check if server_source column exists in albums table + cursor.execute("PRAGMA table_info(albums)") + albums_columns = [column[1] for column in cursor.fetchall()] + + if 'server_source' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN server_source TEXT DEFAULT 'plex'") + logger.info("Added server_source column to albums table") + + # Check if server_source column exists in tracks table + cursor.execute("PRAGMA table_info(tracks)") + tracks_columns = [column[1] for column in cursor.fetchall()] + + if 'server_source' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'") + logger.info("Added server_source column to tracks table") + + # Create indexes for server_source columns for performance + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)") + + except Exception as e: + logger.error(f"Error adding server_source columns: {e}") + # Don't raise - this is a migration, database can still function without it + + def _migrate_id_columns_to_text(self, cursor): + """Migrate ID columns from INTEGER to TEXT to support both Plex (int) and Jellyfin (GUID) IDs""" + try: + # Check if migration has already been applied by looking for a specific marker + cursor.execute("SELECT value FROM metadata WHERE key = 'id_columns_migrated' LIMIT 1") + migration_done = cursor.fetchone() + + if migration_done: + logger.debug("ID columns migration already applied") + return + + logger.info("Migrating ID columns to support both integer and string IDs...") + + # SQLite doesn't support changing column types directly, so we need to recreate tables + # This is a complex migration - let's do it safely + + # Step 1: Create new tables with TEXT IDs + cursor.execute(""" + CREATE TABLE IF NOT EXISTS artists_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + thumb_url TEXT, + genres TEXT, + summary TEXT, + server_source TEXT DEFAULT 'plex', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS albums_new ( + id TEXT PRIMARY KEY, + artist_id TEXT NOT NULL, + title TEXT NOT NULL, + year INTEGER, + thumb_url TEXT, + genres TEXT, + track_count INTEGER, + duration INTEGER, + server_source TEXT DEFAULT 'plex', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tracks_new ( + id TEXT PRIMARY KEY, + album_id TEXT NOT NULL, + artist_id TEXT NOT NULL, + title TEXT NOT NULL, + track_number INTEGER, + duration INTEGER, + file_path TEXT, + bitrate INTEGER, + server_source TEXT DEFAULT 'plex', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (album_id) REFERENCES albums_new (id) ON DELETE CASCADE, + FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE + ) + """) + + # Step 2: Copy existing data (converting INTEGER IDs to TEXT) + cursor.execute(""" + INSERT INTO artists_new (id, name, thumb_url, genres, summary, server_source, created_at, updated_at) + SELECT CAST(id AS TEXT), name, thumb_url, genres, summary, + COALESCE(server_source, 'plex'), created_at, updated_at + FROM artists + """) + + cursor.execute(""" + INSERT INTO albums_new (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source, created_at, updated_at) + SELECT CAST(id AS TEXT), CAST(artist_id AS TEXT), title, year, thumb_url, genres, track_count, duration, + COALESCE(server_source, 'plex'), created_at, updated_at + FROM albums + """) + + cursor.execute(""" + INSERT INTO tracks_new (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, created_at, updated_at) + SELECT CAST(id AS TEXT), CAST(album_id AS TEXT), CAST(artist_id AS TEXT), title, track_number, duration, file_path, bitrate, + COALESCE(server_source, 'plex'), created_at, updated_at + FROM tracks + """) + + # Step 3: Drop old tables and rename new ones + cursor.execute("DROP TABLE IF EXISTS tracks") + cursor.execute("DROP TABLE IF EXISTS albums") + cursor.execute("DROP TABLE IF EXISTS artists") + + cursor.execute("ALTER TABLE artists_new RENAME TO artists") + cursor.execute("ALTER TABLE albums_new RENAME TO albums") + cursor.execute("ALTER TABLE tracks_new RENAME TO tracks") + + # Step 4: Recreate indexes + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)") + + # Step 5: Mark migration as complete + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('id_columns_migrated', 'true', CURRENT_TIMESTAMP) + """) + + logger.info("ID columns migration completed successfully") + + except Exception as e: + logger.error(f"Error migrating ID columns: {e}") + # Don't raise - this is a migration, database can still function + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here pass def get_statistics(self) -> Dict[str, int]: - """Get database statistics""" + """Get database statistics for all servers (legacy method)""" try: with self._get_connection() as conn: cursor = conn.cursor() @@ -243,8 +405,44 @@ class MusicDatabase: logger.error(f"Error getting database statistics: {e}") return {'artists': 0, 'albums': 0, 'tracks': 0} + def get_statistics_for_server(self, server_source: str = None) -> Dict[str, int]: + """Get database statistics filtered by server source""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + if server_source: + # Get counts for specific server + cursor.execute("SELECT COUNT(*) FROM artists WHERE server_source = ?", (server_source,)) + artist_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM albums WHERE server_source = ?", (server_source,)) + album_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM tracks WHERE server_source = ?", (server_source,)) + track_count = cursor.fetchone()[0] + else: + # Get total counts (all servers) + cursor.execute("SELECT COUNT(*) FROM artists") + artist_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM albums") + album_count = cursor.fetchone()[0] + + cursor.execute("SELECT COUNT(*) FROM tracks") + track_count = cursor.fetchone()[0] + + return { + 'artists': artist_count, + 'albums': album_count, + 'tracks': track_count + } + except Exception as e: + logger.error(f"Error getting database statistics for {server_source}: {e}") + return {'artists': 0, 'albums': 0, 'tracks': 0} + def clear_all_data(self): - """Clear all data from database (for full refresh)""" + """Clear all data from database (for full refresh) - DEPRECATED: Use clear_server_data instead""" try: with self._get_connection() as conn: cursor = conn.cursor() @@ -265,6 +463,38 @@ class MusicDatabase: logger.error(f"Error clearing database: {e}") raise + def clear_server_data(self, server_source: str): + """Clear data for specific server only (server-aware full refresh)""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Delete only data from the specified server + # Order matters: tracks -> albums -> artists (foreign key constraints) + cursor.execute("DELETE FROM tracks WHERE server_source = ?", (server_source,)) + tracks_deleted = cursor.rowcount + + cursor.execute("DELETE FROM albums WHERE server_source = ?", (server_source,)) + albums_deleted = cursor.rowcount + + cursor.execute("DELETE FROM artists WHERE server_source = ?", (server_source,)) + artists_deleted = cursor.rowcount + + conn.commit() + + # Only VACUUM if we deleted a significant amount of data + if tracks_deleted > 1000 or albums_deleted > 100: + logger.info("Vacuuming database to reclaim disk space...") + cursor.execute("VACUUM") + + logger.info(f"Cleared {server_source} data: {artists_deleted} artists, {albums_deleted} albums, {tracks_deleted} tracks") + + # Note: Watchlist and wishlist are preserved as they are server-agnostic + + except Exception as e: + logger.error(f"Error clearing {server_source} database data: {e}") + raise + def cleanup_orphaned_records(self) -> Dict[str, int]: """Remove artists and albums that have no associated tracks""" try: @@ -314,26 +544,31 @@ class MusicDatabase: # Artist operations def insert_or_update_artist(self, plex_artist) -> bool: - """Insert or update artist from Plex artist object""" + """Insert or update artist from Plex artist object - DEPRECATED: Use insert_or_update_media_artist instead""" + return self.insert_or_update_media_artist(plex_artist, server_source='plex') + + def insert_or_update_media_artist(self, artist_obj, server_source: str = 'plex') -> bool: + """Insert or update artist from media server artist object (Plex or Jellyfin)""" try: with self._get_connection() as conn: cursor = conn.cursor() - artist_id = int(plex_artist.ratingKey) - name = plex_artist.title - thumb_url = getattr(plex_artist, 'thumb', None) - summary = getattr(plex_artist, 'summary', None) + # Convert artist ID to string (handles both Plex integer IDs and Jellyfin GUIDs) + artist_id = str(artist_obj.ratingKey) + name = artist_obj.title + thumb_url = getattr(artist_obj, 'thumb', None) + summary = getattr(artist_obj, 'summary', None) - # Get genres + # Get genres (handle both Plex and Jellyfin formats) genres = [] - if hasattr(plex_artist, 'genres') and plex_artist.genres: + if hasattr(artist_obj, 'genres') and artist_obj.genres: genres = [genre.tag if hasattr(genre, 'tag') else str(genre) - for genre in plex_artist.genres] + for genre in artist_obj.genres] genres_json = json.dumps(genres) if genres else None - # Check if artist exists - cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,)) + # Check if artist exists with this ID and server source + cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = ?", (artist_id, server_source)) exists = cursor.fetchone() if exists: @@ -341,20 +576,20 @@ class MusicDatabase: cursor.execute(""" UPDATE artists SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, (name, thumb_url, genres_json, summary, artist_id)) + WHERE id = ? AND server_source = ? + """, (name, thumb_url, genres_json, summary, artist_id, server_source)) else: # Insert new artist cursor.execute(""" - INSERT INTO artists (id, name, thumb_url, genres, summary) - VALUES (?, ?, ?, ?, ?) - """, (artist_id, name, thumb_url, genres_json, summary)) + INSERT INTO artists (id, name, thumb_url, genres, summary, server_source) + VALUES (?, ?, ?, ?, ?, ?) + """, (artist_id, name, thumb_url, genres_json, summary, server_source)) conn.commit() return True except Exception as e: - logger.error(f"Error inserting/updating artist {getattr(plex_artist, 'title', 'Unknown')}: {e}") + logger.error(f"Error inserting/updating {server_source} artist {getattr(artist_obj, 'title', 'Unknown')}: {e}") return False def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]: @@ -385,30 +620,35 @@ class MusicDatabase: # Album operations def insert_or_update_album(self, plex_album, artist_id: int) -> bool: - """Insert or update album from Plex album object""" + """Insert or update album from Plex album object - DEPRECATED: Use insert_or_update_media_album instead""" + return self.insert_or_update_media_album(plex_album, artist_id, server_source='plex') + + def insert_or_update_media_album(self, album_obj, artist_id: str, server_source: str = 'plex') -> bool: + """Insert or update album from media server album object (Plex or Jellyfin)""" try: conn = self._get_connection() cursor = conn.cursor() - album_id = int(plex_album.ratingKey) - title = plex_album.title - year = getattr(plex_album, 'year', None) - thumb_url = getattr(plex_album, 'thumb', None) + # Convert album ID to string (handles both Plex integer IDs and Jellyfin GUIDs) + album_id = str(album_obj.ratingKey) + title = album_obj.title + year = getattr(album_obj, 'year', None) + thumb_url = getattr(album_obj, 'thumb', None) - # Get track count and duration - track_count = getattr(plex_album, 'leafCount', None) - duration = getattr(plex_album, 'duration', None) + # Get track count and duration (handle different server attributes) + track_count = getattr(album_obj, 'leafCount', None) or getattr(album_obj, 'childCount', None) + duration = getattr(album_obj, 'duration', None) - # Get genres + # Get genres (handle both Plex and Jellyfin formats) genres = [] - if hasattr(plex_album, 'genres') and plex_album.genres: + if hasattr(album_obj, 'genres') and album_obj.genres: genres = [genre.tag if hasattr(genre, 'tag') else str(genre) - for genre in plex_album.genres] + for genre in album_obj.genres] genres_json = json.dumps(genres) if genres else None - # Check if album exists - cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,)) + # Check if album exists with this ID and server source + cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = ?", (album_id, server_source)) exists = cursor.fetchone() if exists: @@ -417,20 +657,20 @@ class MusicDatabase: UPDATE albums SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?, track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id)) + WHERE id = ? AND server_source = ? + """, (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id, server_source)) else: # Insert new album cursor.execute(""" - INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration)) + INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source)) conn.commit() return True except Exception as e: - logger.error(f"Error inserting/updating album {getattr(plex_album, 'title', 'Unknown')}: {e}") + logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}") return False def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]: @@ -466,60 +706,70 @@ class MusicDatabase: # Track operations def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool: - """Insert or update track from Plex track object""" - try: - conn = self._get_connection() - cursor = conn.cursor() - - track_id = int(plex_track.ratingKey) - title = plex_track.title - track_number = getattr(plex_track, 'trackNumber', None) - duration = getattr(plex_track, 'duration', None) - - # Get file path and media info - file_path = None - bitrate = None - if hasattr(plex_track, 'media') and plex_track.media: - media = plex_track.media[0] if plex_track.media else None - if media: - if hasattr(media, 'parts') and media.parts: - part = media.parts[0] - file_path = getattr(part, 'file', None) - bitrate = getattr(media, 'bitrate', None) - - # Check if track exists - cursor.execute("SELECT id FROM tracks WHERE id = ?", (track_id,)) - exists = cursor.fetchone() - - if exists: - # Update existing track - cursor.execute(""" - UPDATE tracks - SET album_id = ?, artist_id = ?, title = ?, track_number = ?, - duration = ?, file_path = ?, bitrate = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, track_id)) - else: - # Insert new track - cursor.execute(""" - INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate)) - - conn.commit() - return True - - except Exception as e: - logger.error(f"Error inserting/updating track {getattr(plex_track, 'title', 'Unknown')}: {e}") - return False + """Insert or update track from Plex track object - DEPRECATED: Use insert_or_update_media_track instead""" + return self.insert_or_update_media_track(plex_track, album_id, artist_id, server_source='plex') - def track_exists(self, track_id: int) -> bool: - """Check if a track exists in the database by Plex ID""" + def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, server_source: str = 'plex') -> bool: + """Insert or update track from media server track object (Plex or Jellyfin) with retry logic""" + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Set shorter timeout to prevent long locks + cursor.execute("PRAGMA busy_timeout = 10000") # 10 second timeout + + # Convert track ID to string (handles both Plex integer IDs and Jellyfin GUIDs) + track_id = str(track_obj.ratingKey) + title = track_obj.title + track_number = getattr(track_obj, 'trackNumber', None) + duration = getattr(track_obj, 'duration', None) + + # Get file path and media info (Plex-specific, Jellyfin may not have these) + file_path = None + bitrate = None + if hasattr(track_obj, 'media') and track_obj.media: + media = track_obj.media[0] if track_obj.media else None + if media: + if hasattr(media, 'parts') and media.parts: + part = media.parts[0] + file_path = getattr(part, 'file', None) + bitrate = getattr(media, 'bitrate', None) + + # Use INSERT OR REPLACE to handle duplicate IDs gracefully + cursor.execute(""" + INSERT OR REPLACE INTO tracks + (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source)) + + conn.commit() + return True + + except Exception as e: + retry_count += 1 + if "database is locked" in str(e).lower() and retry_count < max_retries: + logger.warning(f"Database locked on track '{getattr(track_obj, 'title', 'Unknown')}', retrying {retry_count}/{max_retries}...") + time.sleep(0.1 * retry_count) # Exponential backoff + continue + else: + logger.error(f"Error inserting/updating {server_source} track {getattr(track_obj, 'title', 'Unknown')}: {e}") + return False + + return False + + def track_exists(self, track_id) -> bool: + """Check if a track exists in the database by ID (supports both int and string IDs)""" try: conn = self._get_connection() cursor = conn.cursor() - cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,)) + # Convert to string to handle both Plex integers and Jellyfin GUIDs + track_id_str = str(track_id) + cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id_str,)) result = cursor.fetchone() return result is not None @@ -528,12 +778,31 @@ class MusicDatabase: logger.error(f"Error checking if track {track_id} exists: {e}") return False - def get_track_by_id(self, track_id: int) -> Optional[DatabaseTrackWithMetadata]: - """Get a track with artist and album names by Plex ID""" + def track_exists_by_server(self, track_id, server_source: str) -> bool: + """Check if a track exists in the database by ID and server source""" try: conn = self._get_connection() cursor = conn.cursor() + # Convert to string to handle both Plex integers and Jellyfin GUIDs + track_id_str = str(track_id) + cursor.execute("SELECT 1 FROM tracks WHERE id = ? AND server_source = ? LIMIT 1", (track_id_str, server_source)) + result = cursor.fetchone() + + return result is not None + + except Exception as e: + logger.error(f"Error checking if track {track_id} exists for server {server_source}: {e}") + return False + + def get_track_by_id(self, track_id) -> Optional[DatabaseTrackWithMetadata]: + """Get a track with artist and album names by ID (supports both int and string IDs)""" + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Convert to string to handle both Plex integers and Jellyfin GUIDs + track_id_str = str(track_id) cursor.execute(""" SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number, t.duration, t.created_at, t.updated_at, @@ -542,7 +811,7 @@ class MusicDatabase: JOIN artists a ON t.artist_id = a.id JOIN albums al ON t.album_id = al.id WHERE t.id = ? - """, (track_id,)) + """, (track_id_str,)) row = cursor.fetchone() if row: @@ -628,7 +897,7 @@ class MusicDatabase: logger.error(f"Error searching artists with query '{query}': {e}") return [] - def search_tracks(self, title: str = "", artist: str = "", limit: int = 50) -> List[DatabaseTrack]: + def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]: """Search tracks by title and/or artist name with Unicode-aware fuzzy matching""" try: if not title and not artist: @@ -638,7 +907,7 @@ class MusicDatabase: cursor = conn.cursor() # STRATEGY 1: Try basic SQL LIKE search first (fastest) - basic_results = self._search_tracks_basic(cursor, title, artist, limit) + basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source) if basic_results: logger.debug(f"๐Ÿ” Basic search found {len(basic_results)} results") @@ -652,7 +921,7 @@ class MusicDatabase: unicode_support = False if unicode_support: - normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit) + normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit, server_source) if normalized_results: logger.debug(f"๐Ÿ” Unicode fallback search found {len(normalized_results)} results") return normalized_results @@ -668,7 +937,7 @@ class MusicDatabase: logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}") return [] - def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]: + def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]: """Basic SQL LIKE search - fastest method""" where_conditions = [] params = [] @@ -681,6 +950,11 @@ class MusicDatabase: where_conditions.append("artists.name LIKE ?") params.append(f"%{artist}%") + # Add server filter if specified + if server_source: + where_conditions.append("tracks.server_source = ?") + params.append(server_source) + if not where_conditions: return [] @@ -699,7 +973,7 @@ class MusicDatabase: return self._rows_to_tracks(cursor.fetchall()) - def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]: + def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]: """Unicode-aware fallback search - tries normalized versions""" from unidecode import unidecode @@ -719,6 +993,11 @@ class MusicDatabase: where_conditions.append("LOWER(artists.name) LIKE ?") params.append(f"%{artist_norm}%") + # Add server filter if specified + if server_source: + where_conditions.append("tracks.server_source = ?") + params.append(server_source) + if not where_conditions: return [] @@ -918,7 +1197,7 @@ class MusicDatabase: return list(set(variations)) - def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseTrack], float]: + def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None) -> Tuple[Optional[DatabaseTrack], float]: """ Check if a track exists in the database with enhanced fuzzy matching and confidence scoring. Now uses the same sophisticated matching approach as album checking for consistency. @@ -941,7 +1220,7 @@ class MusicDatabase: potential_matches = [] artist_variations = self._get_artist_variations(artist) for artist_variation in artist_variations: - potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20)) + potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source)) if not potential_matches: continue @@ -1809,7 +2088,7 @@ class MusicDatabase: return 0 def get_database_info(self) -> Dict[str, Any]: - """Get comprehensive database information""" + """Get comprehensive database information for all servers (legacy method)""" try: stats = self.get_statistics() @@ -1857,6 +2136,65 @@ class MusicDatabase: 'last_update': None, 'last_full_refresh': None } + + def get_database_info_for_server(self, server_source: str = None) -> Dict[str, Any]: + """Get comprehensive database information filtered by server source""" + try: + # Import here to avoid circular imports + from config.settings import config_manager + + # If no server specified, use active server + if server_source is None: + server_source = config_manager.get_active_media_server() + + stats = self.get_statistics_for_server(server_source) + + # Get database file size (always total, not server-specific) + db_size = self.database_path.stat().st_size if self.database_path.exists() else 0 + db_size_mb = db_size / (1024 * 1024) + + # Get last update time for this server + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT MAX(updated_at) as last_update + FROM ( + SELECT updated_at FROM artists WHERE server_source = ? + UNION ALL + SELECT updated_at FROM albums WHERE server_source = ? + UNION ALL + SELECT updated_at FROM tracks WHERE server_source = ? + ) + """, (server_source, server_source, server_source)) + + result = cursor.fetchone() + last_update = result['last_update'] if result and result['last_update'] else None + + # Get last full refresh (global setting, not server-specific) + last_full_refresh = self.get_last_full_refresh() + + return { + **stats, + 'database_size_mb': round(db_size_mb, 2), + 'database_path': str(self.database_path), + 'last_update': last_update, + 'last_full_refresh': last_full_refresh, + 'server_source': server_source + } + + except Exception as e: + logger.error(f"Error getting database info for {server_source}: {e}") + return { + 'artists': 0, + 'albums': 0, + 'tracks': 0, + 'database_size_mb': 0.0, + 'database_path': str(self.database_path), + 'last_update': None, + 'last_full_refresh': None, + 'server_source': server_source + } # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance diff --git a/main.py b/main.py index 31882030..40b1a8ce 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from config.settings import config_manager from utils.logging_config import setup_logging, get_logger from core.spotify_client import SpotifyClient from core.plex_client import PlexClient +from core.jellyfin_client import JellyfinClient from core.soulseek_client import SoulseekClient from ui.sidebar import ModernSidebar @@ -27,10 +28,11 @@ logger = get_logger("main") class ServiceStatusThread(QThread): status_updated = pyqtSignal(str, bool) - def __init__(self, spotify_client, plex_client, soulseek_client): + def __init__(self, spotify_client, plex_client, jellyfin_client, soulseek_client): super().__init__() self.spotify_client = spotify_client self.plex_client = plex_client + self.jellyfin_client = jellyfin_client self.soulseek_client = soulseek_client self.running = True @@ -51,20 +53,8 @@ class ServiceStatusThread(QThread): server_status = self.plex_client.is_connected() self.status_updated.emit("plex", server_status) elif active_server == "jellyfin": - # For now, do a basic config check for Jellyfin until JellyfinClient is implemented - jellyfin_config = self.config_manager.get_jellyfin_config() - jellyfin_status = bool(jellyfin_config.get('base_url')) and bool(jellyfin_config.get('api_key')) - if jellyfin_status: - # Do a quick HTTP test to verify connection - try: - import requests - base_url = jellyfin_config.get('base_url', '').rstrip('/') - api_key = jellyfin_config.get('api_key', '') - headers = {'X-Emby-Token': api_key} if api_key else {} - response = requests.get(f"{base_url}/System/Info", headers=headers, timeout=3) - jellyfin_status = response.status_code == 200 - except: - jellyfin_status = False + # Use the JellyfinClient for status checking + jellyfin_status = self.jellyfin_client.is_connected() self.status_updated.emit("jellyfin", jellyfin_status) # Check Soulseek connection (simplified check to avoid event loop issues) @@ -91,6 +81,7 @@ class MainWindow(QMainWindow): self.spotify_client = SpotifyClient() self.plex_client = PlexClient() + self.jellyfin_client = JellyfinClient() self.soulseek_client = SoulseekClient() self.status_thread = None @@ -183,7 +174,13 @@ class MainWindow(QMainWindow): # Create and add pages self.dashboard_page = DashboardPage() self.downloads_page = DownloadsPage(self.soulseek_client) - self.sync_page = SyncPage(self.spotify_client, self.plex_client, self.soulseek_client, self.downloads_page) + self.sync_page = SyncPage( + spotify_client=self.spotify_client, + plex_client=self.plex_client, + soulseek_client=self.soulseek_client, + downloads_page=self.downloads_page, + jellyfin_client=self.jellyfin_client + ) self.artists_page = ArtistsPage(downloads_page=self.downloads_page) self.settings_page = SettingsPage() @@ -194,7 +191,7 @@ class MainWindow(QMainWindow): self.settings_page.set_toast_manager(self.toast_manager) # Configure dashboard with service clients and page references - self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.soulseek_client) + self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.soulseek_client) self.dashboard_page.set_page_references(self.downloads_page, self.sync_page) self.dashboard_page.set_app_start_time(self.app_start_time) self.dashboard_page.set_toast_manager(self.toast_manager) @@ -241,6 +238,7 @@ class MainWindow(QMainWindow): self.status_thread = ServiceStatusThread( self.spotify_client, self.plex_client, + self.jellyfin_client, self.soulseek_client ) self.status_thread.status_updated.connect(self.update_service_status) diff --git a/services/sync_service.py b/services/sync_service.py index 5a33c641..0a66bc7a 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -5,6 +5,7 @@ from datetime import datetime from utils.logging_config import get_logger from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack from core.plex_client import PlexClient, PlexTrackInfo +from core.jellyfin_client import JellyfinClient from core.soulseek_client import SoulseekClient from core.matching_engine import MusicMatchingEngine, MatchResult @@ -40,15 +41,33 @@ class SyncProgress: failed_tracks: int = 0 class PlaylistSyncService: - def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient): + def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None): self.spotify_client = spotify_client self.plex_client = plex_client + self.jellyfin_client = jellyfin_client self.soulseek_client = soulseek_client self.progress_callbacks = {} # Playlist-specific progress callbacks self.syncing_playlists = set() # Track multiple syncing playlists self._cancelled = False self.matching_engine = MusicMatchingEngine() + def _get_active_media_client(self): + """Get the active media client based on config settings""" + try: + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + + if active_server == "jellyfin": + if not self.jellyfin_client: + logger.error("Jellyfin client not provided to sync service") + return None, "jellyfin" + return self.jellyfin_client, "jellyfin" + else: # Default to Plex + return self.plex_client, "plex" + except Exception as e: + logger.error(f"Error determining active media server: {e}") + return self.plex_client, "plex" # Fallback to Plex + @property def is_syncing(self): """Check if any playlist is currently syncing""" @@ -145,7 +164,7 @@ class PlaylistSyncService: failed_tracks=len([r for r in match_results if not r.is_match])) # Use the robust search approach - plex_match, confidence = await self._find_track_in_plex(track) + plex_match, confidence = await self._find_track_in_media_server(track) match_result = MatchResult( spotify_track=track, @@ -204,7 +223,14 @@ class PlaylistSyncService: logger.info(f"Playlist validation: {len(valid_tracks)}/{len(plex_tracks)} tracks are valid Plex objects with ratingKeys") plex_tracks = valid_tracks - sync_success = self.plex_client.update_playlist(playlist.name, plex_tracks) + # Use active media server for playlist sync + media_client, server_type = self._get_active_media_client() + if not media_client: + logger.error(f"No active media client available for playlist sync") + sync_success = False + else: + logger.info(f"Syncing playlist '{playlist.name}' to {server_type.upper()} server") + sync_success = media_client.update_playlist(playlist.name, plex_tracks) synced_tracks = len(plex_tracks) if sync_success else 0 failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks @@ -239,11 +265,13 @@ class PlaylistSyncService: self.clear_progress_callback(playlist.name) self._cancelled = False - async def _find_track_in_plex(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]: + async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]: """Find a track using the same improved database matching as Download Missing Tracks modal""" try: - if not self.plex_client or not self.plex_client.is_connected(): - logger.warning("Plex client not connected") + # Check active media server connection + media_client, server_type = self._get_active_media_client() + if not media_client or not media_client.is_connected(): + logger.warning(f"{server_type.upper()} client not connected") return None, 0.0 # Use the SAME improved database matching as PlaylistTrackAnalysisWorker @@ -258,25 +286,40 @@ class PlaylistSyncService: artist_name = artist if isinstance(artist, str) else artist - # Use the improved database check_track_exists method + # Use the improved database check_track_exists method with server awareness try: + from config.settings import config_manager + active_server = config_manager.get_active_media_server() db = MusicDatabase() - db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7) + db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server) if db_track and confidence >= 0.7: logger.debug(f"โœ”๏ธ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}") - # Fetch the actual Plex track object using the database track ID + # Fetch the actual track object from active media server using the database track ID try: - actual_plex_track = self.plex_client.server.fetchItem(db_track.id) - if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'): - logger.debug(f"โœ”๏ธ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})") - return actual_plex_track, confidence + if server_type == "jellyfin": + # For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem) + class JellyfinTrackFromDB: + def __init__(self, db_track): + self.ratingKey = db_track.id + self.title = db_track.title + self.id = db_track.id + + actual_track = JellyfinTrackFromDB(db_track) + logger.debug(f"โœ”๏ธ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})") + return actual_track, confidence else: - logger.warning(f"โŒ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute") + # For Plex, use the original fetchItem approach + actual_plex_track = media_client.server.fetchItem(db_track.id) + if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'): + logger.debug(f"โœ”๏ธ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})") + return actual_plex_track, confidence + else: + logger.warning(f"โŒ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute") except Exception as fetch_error: - logger.error(f"โŒ Failed to fetch actual Plex track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}") + logger.error(f"โŒ Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}") # Continue to try other artists rather than fail completely continue diff --git a/ui/pages/artists.py b/ui/pages/artists.py index b22282cb..1a4d3a19 100644 --- a/ui/pages/artists.py +++ b/ui/pages/artists.py @@ -544,8 +544,10 @@ class SinglesEPsLibraryWorker(QThread): print(f" ๐Ÿ” Searching for single track: '{track_name}' by '{artist_name}'") - # Search for the track anywhere in the library - db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7) + # Search for the track anywhere in the library (active server only) + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server) if db_track and confidence >= 0.7: print(f" โœ… Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})") @@ -645,8 +647,10 @@ class SinglesEPsLibraryWorker(QThread): track_name = track['name'] artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "") - # Search for this track - db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7) + # Search for this track (active server only) + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server) if db_track and confidence >= 0.7: owned_tracks += 1 diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index 7f2af1bd..1fdc9646 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -119,7 +119,10 @@ class PlaylistTrackAnalysisWorker(QRunnable): for query_title in unique_title_variations: if self._cancelled: return None, 0.0 - db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7) + # Use server-aware database query to check only active server + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server) if db_track and confidence >= 0.7: class MockPlexTrack: @@ -1935,10 +1938,11 @@ class DashboardDataProvider(QObject): self.system_stats_timer.timeout.connect(self.update_system_stats) self.system_stats_timer.start(10000) # Update every 10 seconds - def set_service_clients(self, spotify_client, plex_client, soulseek_client): + def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client): self.service_clients = { 'spotify_client': spotify_client, - 'plex_client': plex_client, + 'plex_client': plex_client, + 'jellyfin_client': jellyfin_client, 'soulseek_client': soulseek_client } @@ -2344,8 +2348,11 @@ class MetadataUpdaterWidget(QFrame): layout.setContentsMargins(20, 15, 20, 15) layout.setSpacing(12) - # Header - header_label = QLabel("Plex Metadata Updater") + # Header - Make it dynamic based on active server + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + server_display = active_server.title() + header_label = QLabel(f"{server_display} Metadata Updater") header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) header_label.setStyleSheet("color: #ffffff;") @@ -2683,14 +2690,15 @@ class DashboardPage(QWidget): self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished) return True - def set_service_clients(self, spotify_client, plex_client, soulseek_client, downloads_page=None): + def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client, downloads_page=None): """Called from main window to provide service client references""" - self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client) + self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, soulseek_client) # Store service clients for wishlist modal self.service_clients = { 'spotify_client': spotify_client, 'plex_client': plex_client, + 'jellyfin_client': jellyfin_client, 'soulseek_client': soulseek_client, 'downloads_page': downloads_page } @@ -2767,11 +2775,24 @@ class DashboardPage(QWidget): return # Create worker for incremental update only - plex_client = self.service_clients.get('plex_client') + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + + # Get the appropriate client + if active_server == "plex": + media_client = self.service_clients.get('plex_client') + elif active_server == "jellyfin": + from core.jellyfin_client import JellyfinClient + media_client = JellyfinClient() + else: + logger.error(f"Unknown active server for auto-update: {active_server}") + return + self._auto_database_worker = DatabaseUpdateWorker( - plex_client, + media_client, "database/music_library.db", - full_refresh=False # Always incremental for automatic updates + full_refresh=False, # Always incremental for automatic updates + server_type=active_server ) # Connect completion signal to log result @@ -3147,9 +3168,24 @@ class DashboardPage(QWidget): logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}") logger.debug(f"Plex client: {self.data_provider.service_clients.get('plex')}") - if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'): + # Check that we have a data provider + if not hasattr(self, 'data_provider'): + self.add_activity_item("โŒ", "Database Update", "Service clients not available", "Now") + return + + # Get the active media server and check if client is available + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + + if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'): self.add_activity_item("โŒ", "Database Update", "Plex client not available", "Now") return + elif active_server == "jellyfin": + # Jellyfin client will be created on-demand, just verify config exists + jellyfin_config = config_manager.get_jellyfin_config() + if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'): + self.add_activity_item("โŒ", "Database Update", "Jellyfin not configured", "Now") + return try: # Get update type from dropdown @@ -3172,11 +3208,28 @@ class DashboardPage(QWidget): logger.debug("Full refresh cancelled by user") return # Cancel the operation + # Get the active media server + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + + # Get the appropriate client + if active_server == "plex": + media_client = self.data_provider.service_clients['plex_client'] + elif active_server == "jellyfin": + # Import and get Jellyfin client + from core.jellyfin_client import JellyfinClient + media_client = JellyfinClient() + else: + logger.error(f"Unknown active server: {active_server}") + self.add_activity_item("โŒ", "Database Update", f"Unknown server type: {active_server}", "Now") + return + # Start the database update worker self.database_worker = DatabaseUpdateWorker( - self.data_provider.service_clients['plex_client'], + media_client, "database/music_library.db", - full_refresh + full_refresh, + server_type=active_server ) # Connect signals @@ -3189,7 +3242,8 @@ class DashboardPage(QWidget): # Update UI and start self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0) update_type = "Full refresh" if full_refresh else "Incremental update" - self.add_activity_item("๐Ÿ—„๏ธ", "Database Update", f"Starting {update_type.lower()}...", "Now") + server_display = active_server.title() # "Plex" or "Jellyfin" + self.add_activity_item("๐Ÿ—„๏ธ", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now") self.database_worker.start() @@ -3349,6 +3403,15 @@ class DashboardPage(QWidget): if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'): logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}") + # Check active server and client availability + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + + # Currently metadata updater only supports Plex + if active_server != "plex": + self.add_activity_item("โŒ", "Metadata Update", f"Metadata updater only supports Plex (active: {active_server.title()})", "Now") + return + if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'): self.add_activity_item("โŒ", "Metadata Update", "Plex client not available", "Now") return diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 59f8e6f5..6b1b18f4 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -624,8 +624,10 @@ class PlaylistTrackAnalysisWorker(QRunnable): for query_title in unique_title_variations: if self._cancelled: return None, 0.0 - # Use database check_track_exists method with consistent thresholds - db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7) + # Use database check_track_exists method with consistent thresholds and active server filter + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server) if db_track and confidence >= 0.7: print(f"โœ”๏ธ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}") @@ -1633,7 +1635,8 @@ class PlaylistDetailsModal(QDialog): self.parent_page.sync_service = PlaylistSyncService( self.parent_page.spotify_client, self.parent_page.plex_client, - self.parent_page.soulseek_client + self.parent_page.soulseek_client, + getattr(self.parent_page, 'jellyfin_client', None) ) # Start sync @@ -2673,10 +2676,11 @@ class SyncPage(QWidget): sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time database_updated_externally = pyqtSignal() - def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, parent=None): + def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, parent=None): super().__init__(parent) self.spotify_client = spotify_client self.plex_client = plex_client + self.jellyfin_client = jellyfin_client self.soulseek_client = soulseek_client self.downloads_page = downloads_page self.sync_statuses = load_sync_status() @@ -2866,7 +2870,8 @@ class SyncPage(QWidget): self.sync_service = PlaylistSyncService( self.spotify_client, self.plex_client, - self.soulseek_client + self.soulseek_client, + getattr(self, 'jellyfin_client', None) ) # Create sync worker @@ -2919,7 +2924,8 @@ class SyncPage(QWidget): self.sync_service = PlaylistSyncService( self.spotify_client, self.plex_client, - self.soulseek_client + self.soulseek_client, + getattr(self, 'jellyfin_client', None) ) # Create sync worker for sequential sync