diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index b7db09da..a7ac70bc 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -37,6 +37,13 @@ class JellyfinArtist: self.title = jellyfin_data.get('Name', 'Unknown Artist') self.addedAt = self._parse_date(jellyfin_data.get('DateCreated')) + # Create genres property from Jellyfin data (empty list for now since data structure needs investigation) + self.genres = [] + # TODO: Map Jellyfin genre data to match Plex format + + # Create summary property from Jellyfin data (used for timestamp storage) + self.summary = jellyfin_data.get('Overview', '') or '' + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: """Parse Jellyfin date string to datetime""" if not date_str: @@ -128,6 +135,9 @@ class JellyfinClient: self._album_cache = {} self._track_cache = {} self._artist_cache = {} + + # Metadata-only mode flag for performance optimization + self._metadata_only_mode = False self._all_albums_cache = None self._all_tracks_cache = None self._cache_populated = False @@ -245,6 +255,12 @@ class JellyfinClient: """Aggressively pre-populate ALL caches to eliminate individual API calls""" if self._cache_populated: return + + # Check if we're in metadata-only mode and skip expensive operations + if self._metadata_only_mode: + logger.info("đŸŽ¯ Skipping cache population for metadata-only operation") + self._cache_populated = True + return logger.info("🚀 Starting aggressive Jellyfin cache population to eliminate slow individual API calls...") if self._progress_callback: @@ -1155,4 +1171,183 @@ class JellyfinClient: except Exception as e: logger.debug(f"Error checking if Jellyfin library is scanning: {e}") + return False + + # Metadata update methods for compatibility with metadata updater + def update_artist_genres(self, artist, genres: List[str]): + """Update artist genres - currently not implemented for Jellyfin""" + try: + # TODO: Implement Jellyfin genre update API + # For now, just log and return success to continue processing + logger.debug(f"Genre update not yet implemented for Jellyfin artist: {artist.title}") + return True + except Exception as e: + logger.error(f"Error updating genres for {artist.title}: {e}") + return False + + def update_artist_poster(self, artist, image_data: bytes): + """Update artist poster image using Jellyfin API""" + try: + artist_id = artist.ratingKey + if not artist_id: + return False + + import requests + + url = f"{self.base_url}/Items/{artist_id}/Images/Primary" + headers = { + 'X-Emby-Token': self.api_key + } + + # Try multiple approaches to find what works with Jellyfin + + # Method 1: Try with different field names that Jellyfin might expect + method1_files = {'data': ('poster.jpg', image_data, 'image/jpeg')} + try: + response = requests.post(url, files=method1_files, headers=headers, timeout=30) + response.raise_for_status() + logger.info(f"Updated poster for {artist.title} (method 1)") + return True + except Exception as e1: + logger.debug(f"Method 1 failed for {artist.title}: {e1}") + + # Method 2: Try with raw data and proper content-type + try: + headers_raw = { + 'X-Emby-Token': self.api_key, + 'Content-Type': 'image/jpeg' + } + response = requests.post(url, data=image_data, headers=headers_raw, timeout=30) + response.raise_for_status() + logger.info(f"Updated poster for {artist.title} (method 2)") + return True + except Exception as e2: + logger.debug(f"Method 2 failed for {artist.title}: {e2}") + + # Method 3: Try with different endpoint structure + try: + alt_url = f"{self.base_url}/Items/{artist_id}/Images/Primary/0" + response = requests.post(alt_url, data=image_data, headers=headers_raw, timeout=30) + response.raise_for_status() + logger.info(f"Updated poster for {artist.title} (method 3)") + return True + except Exception as e3: + logger.debug(f"Method 3 failed for {artist.title}: {e3}") + + # All methods failed + logger.error(f"All image upload methods failed for {artist.title}") + return False + + except Exception as e: + logger.error(f"Error updating poster for {artist.title}: {e}") + return False + + def update_album_poster(self, album, image_data: bytes): + """Update album poster image using Jellyfin API""" + try: + album_id = album.ratingKey + if not album_id: + return False + + import requests + + url = f"{self.base_url}/Items/{album_id}/Images/Primary" + headers = { + 'X-Emby-Token': self.api_key + } + + # Try multiple approaches to find what works with Jellyfin + + # Method 1: Try with different field names that Jellyfin might expect + method1_files = {'data': ('poster.jpg', image_data, 'image/jpeg')} + try: + response = requests.post(url, files=method1_files, headers=headers, timeout=30) + response.raise_for_status() + logger.info(f"Updated poster for album '{album.title}' (method 1)") + return True + except Exception as e1: + logger.debug(f"Method 1 failed for album '{album.title}': {e1}") + + # Method 2: Try with raw data and proper content-type + try: + headers_raw = { + 'X-Emby-Token': self.api_key, + 'Content-Type': 'image/jpeg' + } + response = requests.post(url, data=image_data, headers=headers_raw, timeout=30) + response.raise_for_status() + logger.info(f"Updated poster for album '{album.title}' (method 2)") + return True + except Exception as e2: + logger.debug(f"Method 2 failed for album '{album.title}': {e2}") + + # Method 3: Try with different endpoint structure + try: + alt_url = f"{self.base_url}/Items/{album_id}/Images/Primary/0" + response = requests.post(alt_url, data=image_data, headers=headers_raw, timeout=30) + response.raise_for_status() + logger.info(f"Updated poster for album '{album.title}' (method 3)") + return True + except Exception as e3: + logger.debug(f"Method 3 failed for album '{album.title}': {e3}") + + # All methods failed + logger.error(f"All image upload methods failed for album '{album.title}'") + return False + + except Exception as e: + logger.error(f"Error updating poster for album '{album.title}': {e}") + return False + + def update_artist_biography(self, artist) -> bool: + """Update artist overview/biography - currently not implemented for Jellyfin""" + try: + # TODO: Implement Jellyfin biography update API + # For now, just log and return success to continue processing + logger.debug(f"Biography update not yet implemented for Jellyfin artist: {artist.title}") + return True + except Exception as e: + logger.error(f"Error updating biography for {artist.title}: {e}") + return False + + def needs_update_by_age(self, artist, refresh_interval_days: int) -> bool: + """Check if artist needs updating based on age threshold - simplified for Jellyfin""" + try: + # For now, just return True for all artists since we don't have timestamp tracking yet + # TODO: Implement timestamp tracking in Jellyfin artist metadata + return True + except Exception as e: + logger.debug(f"Error checking update age for {artist.title}: {e}") + return True + + def is_artist_ignored(self, artist) -> bool: + """Check if artist is manually marked to be ignored - simplified for Jellyfin""" + try: + # For now, no artists are ignored + # TODO: Implement ignore flag tracking in Jellyfin artist metadata + return False + except Exception as e: + logger.debug(f"Error checking ignore status for {artist.title}: {e}") + return False + + def parse_update_timestamp(self, artist) -> Optional[datetime]: + """Parse the last update timestamp from artist summary - not implemented for Jellyfin""" + try: + # TODO: Implement timestamp parsing from Jellyfin artist overview + return None + except Exception as e: + logger.debug(f"Error parsing timestamp for {artist.title}: {e}") + return None + + def set_metadata_only_mode(self, enabled: bool = True): + """Enable metadata-only mode to skip expensive track caching""" + try: + self._metadata_only_mode = enabled + if enabled: + logger.info("đŸŽ¯ Metadata-only mode enabled - will skip expensive track caching") + else: + logger.info("đŸŽ¯ Metadata-only mode disabled") + return True + except Exception as e: + logger.error(f"Error setting metadata-only mode: {e}") return False \ No newline at end of file diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index bd6fa1f1..b94f37e2 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -1404,18 +1404,19 @@ class SimpleWishlistDownloadWorker(QRunnable): class MetadataUpdateWorker(QThread): - """Worker thread for updating Plex artist metadata using Spotify data""" + """Worker thread for updating artist metadata using Spotify data (supports both Plex and Jellyfin)""" progress_updated = pyqtSignal(str, int, int, float) # current_artist, processed, total, percentage artist_updated = pyqtSignal(str, bool, str) # artist_name, success, details finished = pyqtSignal(int, int, int) # total_processed, successful, failed error = pyqtSignal(str) # error_message artists_loaded = pyqtSignal(int, int) # total_artists, artists_to_process - def __init__(self, artists, plex_client, spotify_client, refresh_interval_days=30): + def __init__(self, artists, media_client, spotify_client, server_type, refresh_interval_days=30): super().__init__() self.artists = artists - self.plex_client = plex_client + self.media_client = media_client # Can be plex_client or jellyfin_client self.spotify_client = spotify_client + self.server_type = server_type # "plex" or "jellyfin" self.matching_engine = MusicMatchingEngine() self.refresh_interval_days = refresh_interval_days self.should_stop = False @@ -1428,14 +1429,23 @@ class MetadataUpdateWorker(QThread): def stop(self): self.should_stop = True + def get_artist_name(self, artist): + """Get artist name consistently across Plex and Jellyfin""" + # Both Plex and Jellyfin wrapper objects have .title attribute + return getattr(artist, 'title', 'Unknown Artist') + def run(self): """Process all artists one by one""" try: # Load artists in background if not provided if self.artists is None: - all_artists = self.plex_client.get_all_artists() + # Enable lightweight mode for Jellyfin to skip track caching + if self.server_type == "jellyfin": + self.media_client.set_metadata_only_mode(True) + + all_artists = self.media_client.get_all_artists() if not all_artists: - self.error.emit("No artists found in Plex library") + self.error.emit(f"No artists found in {self.server_type.title()} library") return # Filter artists that need processing @@ -1460,7 +1470,7 @@ class MetadataUpdateWorker(QThread): artist_name = getattr(artist, 'title', 'Unknown Artist') # Double-check ignore flag right before processing (in case it was added after loading) - if self.plex_client.is_artist_ignored(artist): + if self.media_client.is_artist_ignored(artist): return (artist_name, True, "Skipped (ignored)") try: @@ -1505,9 +1515,12 @@ class MetadataUpdateWorker(QThread): def artist_needs_processing(self, artist): """Check if an artist needs metadata processing using age-based detection""" try: - # Use PlexClient's age-based checking with configured interval - # This also handles the ignore flag check internally - return self.plex_client.needs_update_by_age(artist, self.refresh_interval_days) + # Check if artist is manually ignored + if self.media_client.is_artist_ignored(artist): + return False + + # Use media client's age-based checking with configured interval + return self.media_client.needs_update_by_age(artist, self.refresh_interval_days) except Exception as e: print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}") @@ -1557,14 +1570,18 @@ class MetadataUpdateWorker(QThread): if genres_updated: changes_made.append("genres") - # Update album artwork - albums_updated = self.update_album_artwork(artist, spotify_artist) - if albums_updated > 0: - changes_made.append(f"{albums_updated} album art") + # Update album artwork (only for Plex, skip for Jellyfin due to API issues) + if self.server_type == "plex": + albums_updated = self.update_album_artwork(artist, spotify_artist) + if albums_updated > 0: + changes_made.append(f"{albums_updated} album art") + else: + # Skip album artwork for Jellyfin until API issues are resolved + logger.debug(f"Skipping album artwork updates for Jellyfin artist: {artist.title}") if changes_made: # Update artist biography with timestamp to track last update - biography_updated = self.plex_client.update_artist_biography(artist) + biography_updated = self.media_client.update_artist_biography(artist) if biography_updated: changes_made.append("timestamp") @@ -1572,7 +1589,7 @@ class MetadataUpdateWorker(QThread): return True, details else: # Even if no metadata changes, update biography to record we checked this artist - self.plex_client.update_artist_biography(artist) + self.media_client.update_artist_biography(artist) return True, "Already up to date" except Exception as e: @@ -1642,8 +1659,8 @@ class MetadataUpdateWorker(QThread): print(f"[DEBUG] Updating genres for '{artist.title}' to: {genre_list}") - # Use Plex API to update genres - success = self.plex_client.update_artist_genres(artist, genre_list) + # Use media client API to update genres + success = self.media_client.update_artist_genres(artist, genre_list) if success: print(f"[DEBUG] Successfully updated genres for '{artist.title}'") return True @@ -1801,8 +1818,8 @@ class MetadataUpdateWorker(QThread): print(f"Invalid image data for album '{album_title}'") return False - # Upload to Plex using our new method - success = self.plex_client.update_album_poster(album, image_data) + # Upload using media client + success = self.media_client.update_album_poster(album, image_data) if success: print(f"✅ Updated artwork for album '{album_title}'") else: @@ -1852,26 +1869,31 @@ class MetadataUpdateWorker(QThread): return None def upload_artist_poster(self, artist, image_data): - """Upload poster to Plex""" + """Upload poster using media client""" try: - # Use Plex client's update method if available - if hasattr(self.plex_client, 'update_artist_poster'): - return self.plex_client.update_artist_poster(artist, image_data) - - # Fallback: direct Plex API call - server = self.plex_client.server - upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters" - headers = { - 'X-Plex-Token': server._token, - 'Content-Type': 'image/jpeg' - } - - response = requests.post(upload_url, data=image_data, headers=headers) - response.raise_for_status() - - # Refresh artist to see changes - artist.refresh() - return True + # Use media client's update method if available + if hasattr(self.media_client, 'update_artist_poster'): + return self.media_client.update_artist_poster(artist, image_data) + + # Fallback for Plex: direct API call + if self.server_type == "plex": + import requests + server = self.media_client.server + upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters" + headers = { + 'X-Plex-Token': server._token, + 'Content-Type': 'image/jpeg' + } + + response = requests.post(upload_url, data=image_data, headers=headers) + response.raise_for_status() + + # Refresh artist to see changes + artist.refresh() + return True + else: + # For other server types, return False since we only have fallback for Plex + return False except Exception as e: print(f"Error uploading poster: {e}") @@ -3092,13 +3114,20 @@ class DashboardPage(QWidget): self.database_widget = DatabaseUpdaterWidget() self.database_widget.start_button.clicked.connect(self.toggle_database_update) - # Metadata updater widget (SECOND) - self.metadata_widget = MetadataUpdaterWidget() - self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update) + # Metadata updater widget (SECOND) - only show for Plex + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + + if active_server == "plex": + self.metadata_widget = MetadataUpdaterWidget() + self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update) + else: + self.metadata_widget = None # Hide for Jellyfin layout.addWidget(header_label) layout.addWidget(self.database_widget) - layout.addWidget(self.metadata_widget) + if self.metadata_widget: # Only add if it exists + layout.addWidget(self.metadata_widget) return section @@ -3399,6 +3428,9 @@ class DashboardPage(QWidget): def toggle_metadata_update(self): """Toggle metadata update process""" + if not self.metadata_widget: + return # Metadata widget not available (Jellyfin server) + current_text = self.metadata_widget.start_button.text() if "Begin" in current_text: # Start metadata update @@ -3418,13 +3450,17 @@ class DashboardPage(QWidget): 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 + # Check if we have the active media server client + if active_server == "jellyfin": + media_client = self.data_provider.service_clients.get('jellyfin_client') + if not media_client: + self.add_activity_item("❌", "Metadata Update", "Jellyfin client not available", "Now") + return + else: + media_client = self.data_provider.service_clients.get('plex_client') + if not media_client: + self.add_activity_item("❌", "Metadata Update", "Plex client not available", "Now") + return if not self.data_provider.service_clients.get('spotify_client'): self.add_activity_item("❌", "Metadata Update", "Spotify client not available", "Now") @@ -3432,13 +3468,14 @@ class DashboardPage(QWidget): try: # Get refresh interval from dropdown - refresh_interval_days = self.metadata_widget.get_refresh_interval_days() + refresh_interval_days = self.metadata_widget.get_refresh_interval_days() if self.metadata_widget else 30 # Start the metadata update worker (it will handle artist retrieval in background) self.metadata_worker = MetadataUpdateWorker( None, # Artists will be loaded in the worker thread - self.data_provider.service_clients['plex_client'], + media_client, self.data_provider.service_clients['spotify_client'], + active_server, refresh_interval_days ) @@ -3450,8 +3487,9 @@ class DashboardPage(QWidget): self.metadata_worker.artists_loaded.connect(self.on_artists_loaded) # Update UI and start - self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0) - self.add_activity_item("đŸŽĩ", "Metadata Update", "Loading artists from Plex library...", "Now") + if self.metadata_widget: + self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0) + self.add_activity_item("đŸŽĩ", "Metadata Update", "Loading artists from library...", "Now") self.metadata_worker.start() @@ -3473,7 +3511,8 @@ class DashboardPage(QWidget): if self.metadata_worker.isRunning(): self.metadata_worker.terminate() - self.metadata_widget.update_progress(False, "", 0, 0, 0.0) + if self.metadata_widget: + self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.add_activity_item("âšī¸", "Metadata Update", "Stopped metadata update process", "Now") def artist_needs_processing(self, artist): @@ -3512,7 +3551,8 @@ class DashboardPage(QWidget): def on_metadata_progress(self, current_artist, processed, total, percentage): """Handle metadata update progress""" - self.metadata_widget.update_progress(True, current_artist, processed, total, percentage) + if self.metadata_widget: + self.metadata_widget.update_progress(True, current_artist, processed, total, percentage) def on_artist_updated(self, artist_name, success, details): """Handle individual artist update completion""" @@ -3523,13 +3563,15 @@ class DashboardPage(QWidget): def on_metadata_finished(self, total_processed, successful, failed): """Handle metadata update completion""" - self.metadata_widget.update_progress(False, "", 0, 0, 0.0) + if self.metadata_widget: + self.metadata_widget.update_progress(False, "", 0, 0, 0.0) summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed" self.add_activity_item("đŸŽĩ", "Metadata Complete", summary, "Now") def on_metadata_error(self, error_message): """Handle metadata update error""" - self.metadata_widget.update_progress(False, "", 0, 0, 0.0) + if self.metadata_widget: + self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.add_activity_item("❌", "Metadata Error", error_message, "Now") def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str): @@ -3569,7 +3611,8 @@ class DashboardPage(QWidget): def on_metadata_progress_updated(self, is_running: bool, current_artist: str, processed: int, total: int, percentage: float): """Handle metadata update progress""" - self.metadata_widget.update_progress(is_running, current_artist, processed, total, percentage) + if self.metadata_widget: + self.metadata_widget.update_progress(is_running, current_artist, processed, total, percentage) def on_sync_progress_updated(self, current_playlist: str, active_syncs: int): """Handle sync progress updates"""