better
This commit is contained in:
parent
86d0028446
commit
620c78766b
9 changed files with 2148 additions and 314 deletions
|
|
@ -14,7 +14,7 @@ from config.settings import config_manager
|
|||
logger = get_logger("database_update_worker")
|
||||
|
||||
class DatabaseUpdateWorker(QThread):
|
||||
"""Worker thread for updating SoulSync database with Plex library data"""
|
||||
"""Worker thread for updating SoulSync database with media server library data (Plex or Jellyfin)"""
|
||||
|
||||
# Signals for progress reporting
|
||||
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
|
||||
|
|
@ -23,9 +23,20 @@ class DatabaseUpdateWorker(QThread):
|
|||
error = pyqtSignal(str) # error_message
|
||||
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
||||
|
||||
def __init__(self, plex_client, database_path: str = "database/music_library.db", full_refresh: bool = False):
|
||||
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"):
|
||||
super().__init__()
|
||||
self.plex_client = plex_client
|
||||
# Support both old plex_client parameter and new media_client parameter for backward compatibility
|
||||
if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower():
|
||||
self.media_client = media_client
|
||||
self.server_type = "plex"
|
||||
# Keep old attribute for backward compatibility
|
||||
self.plex_client = media_client
|
||||
else:
|
||||
self.media_client = media_client
|
||||
self.server_type = server_type
|
||||
# Keep old attribute for backward compatibility with existing code that expects it
|
||||
self.plex_client = media_client if server_type == "plex" else None
|
||||
|
||||
self.database_path = database_path
|
||||
self.full_refresh = full_refresh
|
||||
self.should_stop = False
|
||||
|
|
@ -39,8 +50,19 @@ class DatabaseUpdateWorker(QThread):
|
|||
|
||||
# Threading control - get from config or default to 5
|
||||
database_config = config_manager.get('database', {})
|
||||
self.max_workers = database_config.get('max_workers', 5)
|
||||
logger.info(f"Using {self.max_workers} worker threads for database update")
|
||||
base_max_workers = database_config.get('max_workers', 5)
|
||||
|
||||
# Optimize worker count - reduce for database concurrency safety
|
||||
if self.server_type == "jellyfin":
|
||||
# Reduce workers to prevent database lock issues with bulk inserts
|
||||
self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety
|
||||
if base_max_workers > 3:
|
||||
logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety")
|
||||
else:
|
||||
# Plex uses standard worker count
|
||||
self.max_workers = base_max_workers
|
||||
|
||||
logger.info(f"Using {self.max_workers} worker threads for {self.server_type} database update")
|
||||
self.thread_lock = threading.Lock()
|
||||
|
||||
# Database instance
|
||||
|
|
@ -49,6 +71,15 @@ class DatabaseUpdateWorker(QThread):
|
|||
def stop(self):
|
||||
"""Stop the database update process"""
|
||||
self.should_stop = True
|
||||
|
||||
# Clear Jellyfin cache when user stops scan to free memory
|
||||
if self.server_type == "jellyfin" and hasattr(self, 'media_client'):
|
||||
try:
|
||||
cache_stats = self.media_client.get_cache_stats()
|
||||
self.media_client.clear_cache()
|
||||
logger.info(f"🧹 Cleared Jellyfin cache after user stop - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clear Jellyfin cache on stop: {e}")
|
||||
|
||||
def run(self):
|
||||
"""Main worker thread execution"""
|
||||
|
|
@ -57,14 +88,22 @@ class DatabaseUpdateWorker(QThread):
|
|||
self.database = get_database(self.database_path)
|
||||
|
||||
if self.full_refresh:
|
||||
logger.info("Performing full database refresh - clearing existing data")
|
||||
self.database.clear_all_data()
|
||||
# For full refresh, use the old method (all artists)
|
||||
logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data")
|
||||
self.database.clear_server_data(self.server_type)
|
||||
|
||||
# Show cache preparation phase for Jellyfin and set up progress callback
|
||||
if self.server_type == "jellyfin":
|
||||
self.phase_changed.emit("Preparing Jellyfin cache for fast processing...")
|
||||
# Connect Jellyfin client progress to UI
|
||||
if hasattr(self.media_client, 'set_progress_callback'):
|
||||
self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(msg))
|
||||
|
||||
# For full refresh, get all artists
|
||||
artists_to_process = self._get_all_artists()
|
||||
if not artists_to_process:
|
||||
self.error.emit("No artists found in Plex library or connection failed")
|
||||
self.error.emit(f"No artists found in {self.server_type} library or connection failed")
|
||||
return
|
||||
logger.info(f"Full refresh: Found {len(artists_to_process)} artists in Plex library")
|
||||
logger.info(f"Full refresh: Found {len(artists_to_process)} artists in {self.server_type} library")
|
||||
else:
|
||||
logger.info("Performing smart incremental update - checking recently added content")
|
||||
# For incremental, use smart recent-first approach
|
||||
|
|
@ -78,7 +117,13 @@ class DatabaseUpdateWorker(QThread):
|
|||
|
||||
# Phase 2: Process artists and their albums/tracks
|
||||
self.phase_changed.emit("Processing artists, albums, and tracks...")
|
||||
self._process_all_artists(artists_to_process)
|
||||
|
||||
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
|
||||
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
|
||||
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
||||
else:
|
||||
# Standard artist processing for Plex or full refresh
|
||||
self._process_all_artists(artists_to_process)
|
||||
|
||||
# Record full refresh completion for tracking purposes
|
||||
if self.full_refresh and self.database:
|
||||
|
|
@ -88,6 +133,15 @@ class DatabaseUpdateWorker(QThread):
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not record full refresh completion: {e}")
|
||||
|
||||
# Clear Jellyfin cache after full refresh to free memory
|
||||
if self.full_refresh and self.server_type == "jellyfin":
|
||||
try:
|
||||
cache_stats = self.media_client.get_cache_stats()
|
||||
self.media_client.clear_cache()
|
||||
logger.info(f"🧹 Cleared Jellyfin cache after full refresh - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clear Jellyfin cache: {e}")
|
||||
|
||||
# Cleanup orphaned records after incremental updates (catches fixed matches)
|
||||
if not self.full_refresh and self.database:
|
||||
try:
|
||||
|
|
@ -121,33 +175,38 @@ class DatabaseUpdateWorker(QThread):
|
|||
self.error.emit(f"Database update failed: {str(e)}")
|
||||
|
||||
def _get_all_artists(self) -> List:
|
||||
"""Get all artists from Plex library"""
|
||||
"""Get all artists from media server library"""
|
||||
try:
|
||||
if not self.plex_client.ensure_connection():
|
||||
logger.error("Could not connect to Plex server")
|
||||
if not self.media_client.ensure_connection():
|
||||
logger.error(f"Could not connect to {self.server_type} server")
|
||||
return []
|
||||
|
||||
artists = self.plex_client.get_all_artists()
|
||||
artists = self.media_client.get_all_artists()
|
||||
return artists
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting artists from Plex: {e}")
|
||||
logger.error(f"Error getting artists from {self.server_type}: {e}")
|
||||
return []
|
||||
|
||||
def _get_artists_for_incremental_update(self) -> List:
|
||||
"""Get artists that need processing for incremental update using smart early-stopping logic"""
|
||||
try:
|
||||
if not self.plex_client.ensure_connection():
|
||||
logger.error("Could not connect to Plex server")
|
||||
if not self.media_client.ensure_connection():
|
||||
logger.error(f"Could not connect to {self.server_type} server")
|
||||
return []
|
||||
|
||||
if not self.plex_client.music_library:
|
||||
# Check for music library (Plex-specific check)
|
||||
if self.server_type == "plex" and not self.media_client.music_library:
|
||||
logger.error("No music library found in Plex")
|
||||
return []
|
||||
|
||||
# Check if database has enough content for incremental updates
|
||||
# Check if database has enough content for incremental updates (server-specific)
|
||||
try:
|
||||
stats = self.database.get_database_info()
|
||||
# Get stats for the specific server we're updating
|
||||
if hasattr(self.database, 'get_database_info_for_server'):
|
||||
stats = self.database.get_database_info_for_server(self.server_type)
|
||||
else:
|
||||
stats = self.database.get_database_info()
|
||||
track_count = stats.get('tracks', 0)
|
||||
|
||||
if track_count < 100: # Minimum threshold for meaningful incremental updates
|
||||
|
|
@ -165,105 +224,34 @@ class DatabaseUpdateWorker(QThread):
|
|||
return self._get_all_artists()
|
||||
|
||||
# Enhanced Strategy: Get both recently added AND recently updated content
|
||||
# This catches both new content and metadata corrections done in Plex
|
||||
# This catches both new content and metadata corrections done on the server
|
||||
|
||||
logger.info("Getting recently added and recently updated content...")
|
||||
logger.info(f"Getting recently added and recently updated content from {self.server_type}...")
|
||||
|
||||
# Get both recently added and recently updated albums
|
||||
all_recent_content = []
|
||||
# For Jellyfin, we need to set up progress callback for potential cache population during incremental
|
||||
if self.server_type == "jellyfin":
|
||||
if hasattr(self.media_client, 'set_progress_callback'):
|
||||
self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(f"Incremental: {msg}"))
|
||||
|
||||
try:
|
||||
# Get recently added albums (up to 400 to catch more recent content)
|
||||
try:
|
||||
recently_added = self.plex_client.music_library.recentlyAdded(libtype='album', maxresults=400)
|
||||
all_recent_content.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added albums")
|
||||
except:
|
||||
# Fallback to general recently added
|
||||
recently_added = self.plex_client.music_library.recentlyAdded(maxresults=400)
|
||||
all_recent_content.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
|
||||
|
||||
# Get recently updated albums (catches metadata corrections) - increased limit
|
||||
try:
|
||||
recently_updated = self.plex_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
|
||||
# Remove duplicates (items that are both recently added and updated)
|
||||
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
|
||||
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
|
||||
all_recent_content.extend(unique_updated)
|
||||
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get recently updated content: {e}")
|
||||
|
||||
recent_content = all_recent_content
|
||||
logger.info(f"Combined total: {len(recent_content)} recent albums (added + updated)")
|
||||
|
||||
# Filter to only get Album objects and convert Artist objects to their albums
|
||||
recent_albums = []
|
||||
artist_count = 0
|
||||
album_count = 0
|
||||
|
||||
for item in recent_content:
|
||||
try:
|
||||
item_type = type(item).__name__
|
||||
logger.info(f"Processing recently added item: {item_type} - '{getattr(item, 'title', 'Unknown')}'")
|
||||
|
||||
if hasattr(item, 'tracks') and hasattr(item, 'artist'):
|
||||
# This is an Album - add directly
|
||||
recent_albums.append(item)
|
||||
album_count += 1
|
||||
logger.info(f"✅ Added album directly: '{item.title}'")
|
||||
elif hasattr(item, 'albums'):
|
||||
# This is an Artist - get their albums
|
||||
try:
|
||||
artist_albums = list(item.albums())
|
||||
if artist_albums:
|
||||
recent_albums.extend(artist_albums)
|
||||
artist_count += 1
|
||||
logger.info(f"✅ Added {len(artist_albums)} albums from artist '{item.title}'")
|
||||
else:
|
||||
logger.info(f"⚠️ Artist '{item.title}' has no albums")
|
||||
except Exception as albums_error:
|
||||
logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}")
|
||||
else:
|
||||
# Unknown type - skip
|
||||
logger.info(f"❌ Skipping unsupported type: {item_type} (has tracks: {hasattr(item, 'tracks')}, has albums: {hasattr(item, 'albums')}, has artist: {hasattr(item, 'artist')})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing recently added item: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Processed recently added content: {artist_count} artists → albums, {album_count} direct albums")
|
||||
|
||||
logger.info(f"Extracted {len(recent_albums)} albums from recently added content")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get recently added albums: {e}")
|
||||
# Fallback: get recently added tracks instead
|
||||
try:
|
||||
recent_tracks = self.plex_client.music_library.recentlyAdded(libtype='track', maxresults=1000)
|
||||
logger.info(f"Fallback: Found {len(recent_tracks)} recently added tracks")
|
||||
# Extract albums from tracks
|
||||
recent_albums = []
|
||||
seen_albums = set()
|
||||
for track in recent_tracks:
|
||||
try:
|
||||
album = track.album()
|
||||
if album and album.ratingKey not in seen_albums:
|
||||
recent_albums.append(album)
|
||||
seen_albums.add(album.ratingKey)
|
||||
except:
|
||||
continue
|
||||
logger.info(f"Extracted {len(recent_albums)} unique albums from tracks")
|
||||
except Exception as e2:
|
||||
logger.error(f"Could not get recently added content: {e2}")
|
||||
return []
|
||||
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
|
||||
if self.server_type == "jellyfin":
|
||||
return self._get_artists_for_jellyfin_track_incremental_update()
|
||||
|
||||
# Plex uses album-based approach (established and working)
|
||||
recent_albums = self._get_recent_albums_for_server()
|
||||
if not recent_albums:
|
||||
logger.info("No recently added albums found")
|
||||
return []
|
||||
|
||||
# Sort albums by added date (newest first)
|
||||
# Sort albums by added date (newest first) - handle None dates properly
|
||||
try:
|
||||
recent_albums.sort(key=lambda x: getattr(x, 'addedAt', 0), reverse=True)
|
||||
def get_sort_date(album):
|
||||
date_val = getattr(album, 'addedAt', None)
|
||||
if date_val is None:
|
||||
return 0 # Fallback for albums with no date
|
||||
return date_val
|
||||
|
||||
recent_albums.sort(key=get_sort_date, reverse=True)
|
||||
logger.info("Sorted albums by recently added date (newest first)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not sort albums by date: {e}")
|
||||
|
|
@ -315,10 +303,18 @@ class DatabaseUpdateWorker(QThread):
|
|||
for track in tracks:
|
||||
total_tracks_checked += 1
|
||||
try:
|
||||
track_id = int(track.ratingKey)
|
||||
# Handle both Plex (integer) and Jellyfin (string GUID) IDs
|
||||
track_id = str(track.ratingKey)
|
||||
track_title = getattr(track, 'title', 'Unknown Track')
|
||||
|
||||
if not self.database.track_exists(track_id):
|
||||
# Use server-aware track existence check
|
||||
if hasattr(self.database, 'track_exists_by_server'):
|
||||
track_exists = self.database.track_exists_by_server(track_id, self.server_type)
|
||||
else:
|
||||
# Fallback to generic check (works for string IDs)
|
||||
track_exists = self.database.track_exists(track_id)
|
||||
|
||||
if not track_exists:
|
||||
missing_tracks_count += 1
|
||||
album_has_new_tracks = True
|
||||
logger.debug(f"📀 Track '{track_title}' is new - album needs processing")
|
||||
|
|
@ -366,7 +362,8 @@ class DatabaseUpdateWorker(QThread):
|
|||
try:
|
||||
album_artist = album.artist()
|
||||
if album_artist:
|
||||
artist_id = int(album_artist.ratingKey)
|
||||
# Handle both Plex (integer) and Jellyfin (string GUID) artist IDs
|
||||
artist_id = str(album_artist.ratingKey)
|
||||
|
||||
# Skip if we've already queued this artist
|
||||
if artist_id not in processed_artist_ids:
|
||||
|
|
@ -398,16 +395,208 @@ class DatabaseUpdateWorker(QThread):
|
|||
# Fallback to empty list - user can try full refresh
|
||||
return []
|
||||
|
||||
def _check_for_metadata_changes(self, plex_tracks) -> bool:
|
||||
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
||||
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
||||
try:
|
||||
logger.info("🚀 FAST Jellyfin incremental: getting recent tracks directly...")
|
||||
|
||||
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
|
||||
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
|
||||
recent_updated_tracks = self.media_client.get_recently_updated_tracks(5000)
|
||||
|
||||
# Combine and deduplicate
|
||||
all_recent_tracks = recent_added_tracks[:]
|
||||
added_ids = {track.ratingKey for track in recent_added_tracks}
|
||||
unique_updated = [track for track in recent_updated_tracks if track.ratingKey not in added_ids]
|
||||
all_recent_tracks.extend(unique_updated)
|
||||
|
||||
logger.info(f"Found {len(recent_added_tracks)} recent + {len(unique_updated)} updated = {len(all_recent_tracks)} tracks to check")
|
||||
|
||||
if not all_recent_tracks:
|
||||
logger.info("No recent tracks found")
|
||||
return []
|
||||
|
||||
# Check which tracks are actually new (FAST - database lookups only)
|
||||
new_tracks = []
|
||||
consecutive_existing_tracks = 0
|
||||
processed_artists = set()
|
||||
|
||||
for i, track in enumerate(all_recent_tracks):
|
||||
try:
|
||||
track_id = str(track.ratingKey)
|
||||
|
||||
# Check if track exists in database
|
||||
if hasattr(self.database, 'track_exists_by_server'):
|
||||
track_exists = self.database.track_exists_by_server(track_id, self.server_type)
|
||||
else:
|
||||
track_exists = self.database.track_exists(track_id)
|
||||
|
||||
if not track_exists:
|
||||
new_tracks.append(track)
|
||||
consecutive_existing_tracks = 0 # Reset counter
|
||||
logger.debug(f"🎵 New track: {track.title}")
|
||||
else:
|
||||
consecutive_existing_tracks += 1
|
||||
logger.debug(f"✅ Track exists: {track.title}")
|
||||
|
||||
# Early stopping: if we find 100 consecutive existing tracks, we're done
|
||||
if consecutive_existing_tracks >= 100:
|
||||
logger.info(f"🛑 Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking track {getattr(track, 'title', 'Unknown')}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(new_tracks)} genuinely new tracks (early stopped after {consecutive_existing_tracks} consecutive existing)")
|
||||
|
||||
if not new_tracks:
|
||||
logger.info("All recent tracks already exist - database is up to date")
|
||||
return []
|
||||
|
||||
# Store new tracks for direct processing (avoid slow artist->album->track lookups)
|
||||
self._jellyfin_new_tracks = new_tracks
|
||||
|
||||
# Extract unique artists from new tracks (FAST - no additional API calls needed)
|
||||
artists_to_process = []
|
||||
for track in new_tracks:
|
||||
try:
|
||||
# Track already has artist info from the API call
|
||||
track_artist = track.artist() # This will make an API call, but only for new tracks
|
||||
if track_artist:
|
||||
artist_id = str(track_artist.ratingKey)
|
||||
if artist_id not in processed_artists:
|
||||
processed_artists.add(artist_id)
|
||||
artists_to_process.append(track_artist)
|
||||
logger.info(f"✅ Added artist '{track_artist.title}' (from new track '{track.title}')")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"🚀 FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
|
||||
return artists_to_process
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fast Jellyfin incremental update: {e}")
|
||||
return []
|
||||
|
||||
def _process_jellyfin_new_tracks_directly(self, artists_to_process):
|
||||
"""Process new Jellyfin tracks directly without slow artist->album->track lookups"""
|
||||
try:
|
||||
new_tracks = getattr(self, '_jellyfin_new_tracks', [])
|
||||
if not new_tracks:
|
||||
logger.warning("No new tracks to process directly")
|
||||
return
|
||||
|
||||
logger.info(f"🚀 FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
|
||||
|
||||
# Group tracks by album and artist for efficient processing
|
||||
tracks_by_album = {}
|
||||
albums_by_artist = {}
|
||||
|
||||
for track in new_tracks:
|
||||
try:
|
||||
# Track already has album and artist IDs from API response
|
||||
album_id = str(track._album_id) if track._album_id else "unknown"
|
||||
artist_id = str(track._artist_ids[0]) if track._artist_ids else "unknown"
|
||||
|
||||
if album_id not in tracks_by_album:
|
||||
tracks_by_album[album_id] = []
|
||||
tracks_by_album[album_id].append(track)
|
||||
|
||||
if artist_id not in albums_by_artist:
|
||||
albums_by_artist[artist_id] = set()
|
||||
albums_by_artist[artist_id].add(album_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error grouping track {getattr(track, 'title', 'Unknown')}: {e}")
|
||||
continue
|
||||
|
||||
total_processed_tracks = 0
|
||||
total_processed_albums = 0
|
||||
total_processed_artists = 0
|
||||
|
||||
# Process each artist
|
||||
for artist in artists_to_process:
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
try:
|
||||
artist_id = str(artist.ratingKey)
|
||||
artist_name = getattr(artist, 'title', 'Unknown Artist')
|
||||
|
||||
# Insert/update the artist
|
||||
artist_success = self.database.insert_or_update_media_artist(artist, server_source=self.server_type)
|
||||
if artist_success:
|
||||
total_processed_artists += 1
|
||||
|
||||
# Process albums for this artist
|
||||
artist_album_ids = albums_by_artist.get(artist_id, set())
|
||||
for album_id in artist_album_ids:
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
try:
|
||||
# Get album from the first track (they all have the same album)
|
||||
album_tracks = tracks_by_album[album_id]
|
||||
if album_tracks:
|
||||
album = album_tracks[0].album() # Get album object
|
||||
if album:
|
||||
# Insert/update album
|
||||
album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type)
|
||||
if album_success:
|
||||
total_processed_albums += 1
|
||||
|
||||
# Process all tracks in this album
|
||||
for track in album_tracks:
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
try:
|
||||
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||
if track_success:
|
||||
total_processed_tracks += 1
|
||||
logger.debug(f"✅ Processed new track: {track.title}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process album {album_id}: {e}")
|
||||
|
||||
# Emit progress for this artist
|
||||
artist_albums = len(artist_album_ids)
|
||||
artist_tracks = sum(len(tracks_by_album[aid]) for aid in artist_album_ids if aid in tracks_by_album)
|
||||
self.artist_processed.emit(artist_name, True, f"Processed {artist_albums} albums, {artist_tracks} tracks", artist_albums, artist_tracks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing artist '{getattr(artist, 'title', 'Unknown')}': {e}")
|
||||
self.artist_processed.emit(getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
||||
|
||||
# Update totals
|
||||
with self.thread_lock:
|
||||
self.processed_artists += total_processed_artists
|
||||
self.processed_albums += total_processed_albums
|
||||
self.processed_tracks += total_processed_tracks
|
||||
self.successful_operations += total_processed_artists # Count successful artists
|
||||
|
||||
logger.info(f"🚀 FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
|
||||
|
||||
# Clean up
|
||||
delattr(self, '_jellyfin_new_tracks')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fast Jellyfin track processing: {e}")
|
||||
|
||||
def _check_for_metadata_changes(self, media_tracks) -> bool:
|
||||
"""Check if any tracks in the list have metadata changes compared to database"""
|
||||
try:
|
||||
if not self.database or not plex_tracks:
|
||||
if not self.database or not media_tracks:
|
||||
return False
|
||||
|
||||
changes_detected = 0
|
||||
for track in plex_tracks:
|
||||
for track in media_tracks:
|
||||
try:
|
||||
track_id = int(track.ratingKey)
|
||||
# Handle both Plex (integer) and Jellyfin (string GUID) IDs
|
||||
track_id = str(track.ratingKey)
|
||||
|
||||
# Get current data from database
|
||||
db_track = self.database.get_track_by_id(track_id)
|
||||
|
|
@ -442,6 +631,102 @@ class DatabaseUpdateWorker(QThread):
|
|||
logger.debug(f"Error checking for metadata changes: {e}")
|
||||
return False # Assume no changes if we can't check
|
||||
|
||||
def _get_recent_albums_for_server(self) -> List:
|
||||
"""Get recently added albums using server-specific methods"""
|
||||
try:
|
||||
if self.server_type == "plex":
|
||||
return self._get_recent_albums_plex()
|
||||
elif self.server_type == "jellyfin":
|
||||
return self._get_recent_albums_jellyfin()
|
||||
else:
|
||||
logger.error(f"Unknown server type: {self.server_type}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recent albums for {self.server_type}: {e}")
|
||||
return []
|
||||
|
||||
def _get_recent_albums_plex(self) -> List:
|
||||
"""Get recently added and updated albums from Plex"""
|
||||
all_recent_content = []
|
||||
|
||||
try:
|
||||
# Get recently added albums (up to 400 to catch more recent content)
|
||||
try:
|
||||
recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400)
|
||||
all_recent_content.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added albums")
|
||||
except:
|
||||
# Fallback to general recently added
|
||||
recently_added = self.media_client.music_library.recentlyAdded(maxresults=400)
|
||||
all_recent_content.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
|
||||
|
||||
# Get recently updated albums (catches metadata corrections)
|
||||
try:
|
||||
recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
|
||||
# Remove duplicates (items that are both recently added and updated)
|
||||
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
|
||||
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
|
||||
all_recent_content.extend(unique_updated)
|
||||
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get recently updated content: {e}")
|
||||
|
||||
# Filter to only get Album objects and convert Artist objects to their albums
|
||||
recent_albums = []
|
||||
artist_count = 0
|
||||
album_count = 0
|
||||
|
||||
for item in all_recent_content:
|
||||
try:
|
||||
if hasattr(item, 'tracks') and hasattr(item, 'artist'):
|
||||
# This is an Album - add directly
|
||||
recent_albums.append(item)
|
||||
album_count += 1
|
||||
elif hasattr(item, 'albums'):
|
||||
# This is an Artist - get their albums
|
||||
try:
|
||||
artist_albums = list(item.albums())
|
||||
if artist_albums:
|
||||
recent_albums.extend(artist_albums)
|
||||
artist_count += 1
|
||||
except Exception as albums_error:
|
||||
logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing recently added item: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Processed {artist_count} artists → albums, {album_count} direct albums")
|
||||
return recent_albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recent Plex albums: {e}")
|
||||
return []
|
||||
|
||||
def _get_recent_albums_jellyfin(self) -> List:
|
||||
"""Get recently added and updated albums from Jellyfin"""
|
||||
try:
|
||||
all_recent_albums = []
|
||||
|
||||
# Get recently added albums
|
||||
recently_added = self.media_client.get_recently_added_albums(400)
|
||||
all_recent_albums.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added albums")
|
||||
|
||||
# Get recently updated albums
|
||||
recently_updated = self.media_client.get_recently_updated_albums(400)
|
||||
# Remove duplicates
|
||||
added_ids = {album.ratingKey for album in all_recent_albums}
|
||||
unique_updated = [album for album in recently_updated if album.ratingKey not in added_ids]
|
||||
all_recent_albums.extend(unique_updated)
|
||||
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
|
||||
|
||||
return all_recent_albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recent Jellyfin albums: {e}")
|
||||
return []
|
||||
|
||||
def _process_all_artists(self, artists: List):
|
||||
"""Process all artists and their albums/tracks using thread pool"""
|
||||
total_artists = len(artists)
|
||||
|
|
@ -505,21 +790,21 @@ class DatabaseUpdateWorker(QThread):
|
|||
# Emit progress signal
|
||||
self.artist_processed.emit(artist_name, success, details, album_count, track_count)
|
||||
|
||||
def _process_artist_with_content(self, plex_artist) -> tuple[bool, str, int, int]:
|
||||
"""Process an artist and all their albums and tracks"""
|
||||
def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
|
||||
"""Process an artist and all their albums and tracks with optimized API usage"""
|
||||
try:
|
||||
artist_name = getattr(plex_artist, 'title', 'Unknown Artist')
|
||||
artist_name = getattr(media_artist, 'title', 'Unknown Artist')
|
||||
|
||||
# 1. Insert/update the artist
|
||||
artist_success = self.database.insert_or_update_artist(plex_artist)
|
||||
# 1. Insert/update the artist using server-agnostic method
|
||||
artist_success = self.database.insert_or_update_media_artist(media_artist, server_source=self.server_type)
|
||||
if not artist_success:
|
||||
return False, "Failed to update artist data", 0, 0
|
||||
|
||||
artist_id = int(plex_artist.ratingKey)
|
||||
artist_id = str(media_artist.ratingKey)
|
||||
|
||||
# 2. Get all albums for this artist
|
||||
# 2. Get all albums for this artist (cached from aggressive pre-population)
|
||||
try:
|
||||
albums = list(plex_artist.albums())
|
||||
albums = list(media_artist.albums())
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get albums for artist '{artist_name}': {e}")
|
||||
return True, "Artist updated (no albums accessible)", 0, 0
|
||||
|
|
@ -527,44 +812,56 @@ class DatabaseUpdateWorker(QThread):
|
|||
album_count = 0
|
||||
track_count = 0
|
||||
|
||||
# 3. Process each album
|
||||
for album in albums:
|
||||
# 3. Process albums in smaller batches to reduce memory usage
|
||||
batch_size = 10 # Process 10 albums at a time
|
||||
for i in range(0, len(albums), batch_size):
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
try:
|
||||
# Insert/update album
|
||||
album_success = self.database.insert_or_update_album(album, artist_id)
|
||||
if album_success:
|
||||
album_count += 1
|
||||
album_id = int(album.ratingKey)
|
||||
|
||||
# 4. Process tracks in this album
|
||||
try:
|
||||
tracks = list(album.tracks())
|
||||
|
||||
for track in tracks:
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
try:
|
||||
track_success = self.database.insert_or_update_track(track, album_id, artist_id)
|
||||
if track_success:
|
||||
track_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
|
||||
album_batch = albums[i:i + batch_size]
|
||||
|
||||
for album in album_batch:
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
try:
|
||||
# Insert/update album using server-agnostic method
|
||||
album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type)
|
||||
if album_success:
|
||||
album_count += 1
|
||||
album_id = str(album.ratingKey)
|
||||
|
||||
# 4. Process tracks in this album (cached from aggressive pre-population)
|
||||
try:
|
||||
tracks = list(album.tracks())
|
||||
|
||||
# Batch insert tracks for better database performance
|
||||
track_batch = []
|
||||
for track in tracks:
|
||||
if self.should_stop:
|
||||
break
|
||||
track_batch.append((track, album_id, artist_id))
|
||||
|
||||
# Process track batch
|
||||
for track, alb_id, art_id in track_batch:
|
||||
try:
|
||||
track_success = self.database.insert_or_update_media_track(track, alb_id, art_id, server_source=self.server_type)
|
||||
if track_success:
|
||||
track_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
|
||||
|
||||
details = f"Updated with {album_count} albums, {track_count} tracks"
|
||||
return True, details, album_count, track_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing artist '{getattr(plex_artist, 'title', 'Unknown')}': {e}")
|
||||
logger.error(f"Error processing artist '{getattr(media_artist, 'title', 'Unknown')}': {e}")
|
||||
return False, f"Processing error: {str(e)}", 0, 0
|
||||
|
||||
class DatabaseStatsWorker(QThread):
|
||||
|
|
@ -591,18 +888,23 @@ class DatabaseStatsWorker(QThread):
|
|||
if self.should_stop:
|
||||
return
|
||||
|
||||
# Get full database info (includes last_full_refresh)
|
||||
info = database.get_database_info()
|
||||
# Get database info for active server (server-aware statistics)
|
||||
info = database.get_database_info_for_server()
|
||||
if not self.should_stop:
|
||||
self.stats_updated.emit(info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database stats: {e}")
|
||||
if not self.should_stop:
|
||||
# Import here to avoid circular imports
|
||||
from config.settings import config_manager
|
||||
active_server = config_manager.get_active_media_server()
|
||||
|
||||
self.stats_updated.emit({
|
||||
'artists': 0,
|
||||
'albums': 0,
|
||||
'tracks': 0,
|
||||
'database_size_mb': 0.0,
|
||||
'last_update': None,
|
||||
'last_full_refresh': None
|
||||
'last_full_refresh': None,
|
||||
'server_source': active_server
|
||||
})
|
||||
1078
core/jellyfin_client.py
Normal file
1078
core/jellyfin_client.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -207,6 +208,12 @@ class MusicDatabase:
|
|||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
|
||||
|
||||
# Add server_source columns for multi-server support (migration)
|
||||
self._add_server_source_columns(cursor)
|
||||
|
||||
# Migrate ID columns to support both integer (Plex) and string (Jellyfin) IDs
|
||||
self._migrate_id_columns_to_text(cursor)
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
|
@ -214,13 +221,168 @@ class MusicDatabase:
|
|||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
def _add_server_source_columns(self, cursor):
|
||||
"""Add server_source columns to existing tables for multi-server support"""
|
||||
try:
|
||||
# Check if server_source column exists in artists table
|
||||
cursor.execute("PRAGMA table_info(artists)")
|
||||
artists_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'server_source' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN server_source TEXT DEFAULT 'plex'")
|
||||
logger.info("Added server_source column to artists table")
|
||||
|
||||
# Check if server_source column exists in albums table
|
||||
cursor.execute("PRAGMA table_info(albums)")
|
||||
albums_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'server_source' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN server_source TEXT DEFAULT 'plex'")
|
||||
logger.info("Added server_source column to albums table")
|
||||
|
||||
# Check if server_source column exists in tracks table
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
tracks_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'server_source' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'")
|
||||
logger.info("Added server_source column to tracks table")
|
||||
|
||||
# Create indexes for server_source columns for performance
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding server_source columns: {e}")
|
||||
# Don't raise - this is a migration, database can still function without it
|
||||
|
||||
def _migrate_id_columns_to_text(self, cursor):
|
||||
"""Migrate ID columns from INTEGER to TEXT to support both Plex (int) and Jellyfin (GUID) IDs"""
|
||||
try:
|
||||
# Check if migration has already been applied by looking for a specific marker
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'id_columns_migrated' LIMIT 1")
|
||||
migration_done = cursor.fetchone()
|
||||
|
||||
if migration_done:
|
||||
logger.debug("ID columns migration already applied")
|
||||
return
|
||||
|
||||
logger.info("Migrating ID columns to support both integer and string IDs...")
|
||||
|
||||
# SQLite doesn't support changing column types directly, so we need to recreate tables
|
||||
# This is a complex migration - let's do it safely
|
||||
|
||||
# Step 1: Create new tables with TEXT IDs
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS artists_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
thumb_url TEXT,
|
||||
genres TEXT,
|
||||
summary TEXT,
|
||||
server_source TEXT DEFAULT 'plex',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS albums_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
artist_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
thumb_url TEXT,
|
||||
genres TEXT,
|
||||
track_count INTEGER,
|
||||
duration INTEGER,
|
||||
server_source TEXT DEFAULT 'plex',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tracks_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
album_id TEXT NOT NULL,
|
||||
artist_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
track_number INTEGER,
|
||||
duration INTEGER,
|
||||
file_path TEXT,
|
||||
bitrate INTEGER,
|
||||
server_source TEXT DEFAULT 'plex',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (album_id) REFERENCES albums_new (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Step 2: Copy existing data (converting INTEGER IDs to TEXT)
|
||||
cursor.execute("""
|
||||
INSERT INTO artists_new (id, name, thumb_url, genres, summary, server_source, created_at, updated_at)
|
||||
SELECT CAST(id AS TEXT), name, thumb_url, genres, summary,
|
||||
COALESCE(server_source, 'plex'), created_at, updated_at
|
||||
FROM artists
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO albums_new (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source, created_at, updated_at)
|
||||
SELECT CAST(id AS TEXT), CAST(artist_id AS TEXT), title, year, thumb_url, genres, track_count, duration,
|
||||
COALESCE(server_source, 'plex'), created_at, updated_at
|
||||
FROM albums
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO tracks_new (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, created_at, updated_at)
|
||||
SELECT CAST(id AS TEXT), CAST(album_id AS TEXT), CAST(artist_id AS TEXT), title, track_number, duration, file_path, bitrate,
|
||||
COALESCE(server_source, 'plex'), created_at, updated_at
|
||||
FROM tracks
|
||||
""")
|
||||
|
||||
# Step 3: Drop old tables and rename new ones
|
||||
cursor.execute("DROP TABLE IF EXISTS tracks")
|
||||
cursor.execute("DROP TABLE IF EXISTS albums")
|
||||
cursor.execute("DROP TABLE IF EXISTS artists")
|
||||
|
||||
cursor.execute("ALTER TABLE artists_new RENAME TO artists")
|
||||
cursor.execute("ALTER TABLE albums_new RENAME TO albums")
|
||||
cursor.execute("ALTER TABLE tracks_new RENAME TO tracks")
|
||||
|
||||
# Step 4: Recreate indexes
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
|
||||
|
||||
# Step 5: Mark migration as complete
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO metadata (key, value, updated_at)
|
||||
VALUES ('id_columns_migrated', 'true', CURRENT_TIMESTAMP)
|
||||
""")
|
||||
|
||||
logger.info("ID columns migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error migrating ID columns: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
||||
def close(self):
|
||||
"""Close database connection (no-op since we create connections per operation)"""
|
||||
# Each operation creates and closes its own connection, so nothing to do here
|
||||
pass
|
||||
|
||||
def get_statistics(self) -> Dict[str, int]:
|
||||
"""Get database statistics"""
|
||||
"""Get database statistics for all servers (legacy method)"""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -243,8 +405,44 @@ class MusicDatabase:
|
|||
logger.error(f"Error getting database statistics: {e}")
|
||||
return {'artists': 0, 'albums': 0, 'tracks': 0}
|
||||
|
||||
def get_statistics_for_server(self, server_source: str = None) -> Dict[str, int]:
|
||||
"""Get database statistics filtered by server source"""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if server_source:
|
||||
# Get counts for specific server
|
||||
cursor.execute("SELECT COUNT(*) FROM artists WHERE server_source = ?", (server_source,))
|
||||
artist_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM albums WHERE server_source = ?", (server_source,))
|
||||
album_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM tracks WHERE server_source = ?", (server_source,))
|
||||
track_count = cursor.fetchone()[0]
|
||||
else:
|
||||
# Get total counts (all servers)
|
||||
cursor.execute("SELECT COUNT(*) FROM artists")
|
||||
artist_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM albums")
|
||||
album_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM tracks")
|
||||
track_count = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
'artists': artist_count,
|
||||
'albums': album_count,
|
||||
'tracks': track_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database statistics for {server_source}: {e}")
|
||||
return {'artists': 0, 'albums': 0, 'tracks': 0}
|
||||
|
||||
def clear_all_data(self):
|
||||
"""Clear all data from database (for full refresh)"""
|
||||
"""Clear all data from database (for full refresh) - DEPRECATED: Use clear_server_data instead"""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -265,6 +463,38 @@ class MusicDatabase:
|
|||
logger.error(f"Error clearing database: {e}")
|
||||
raise
|
||||
|
||||
def clear_server_data(self, server_source: str):
|
||||
"""Clear data for specific server only (server-aware full refresh)"""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Delete only data from the specified server
|
||||
# Order matters: tracks -> albums -> artists (foreign key constraints)
|
||||
cursor.execute("DELETE FROM tracks WHERE server_source = ?", (server_source,))
|
||||
tracks_deleted = cursor.rowcount
|
||||
|
||||
cursor.execute("DELETE FROM albums WHERE server_source = ?", (server_source,))
|
||||
albums_deleted = cursor.rowcount
|
||||
|
||||
cursor.execute("DELETE FROM artists WHERE server_source = ?", (server_source,))
|
||||
artists_deleted = cursor.rowcount
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Only VACUUM if we deleted a significant amount of data
|
||||
if tracks_deleted > 1000 or albums_deleted > 100:
|
||||
logger.info("Vacuuming database to reclaim disk space...")
|
||||
cursor.execute("VACUUM")
|
||||
|
||||
logger.info(f"Cleared {server_source} data: {artists_deleted} artists, {albums_deleted} albums, {tracks_deleted} tracks")
|
||||
|
||||
# Note: Watchlist and wishlist are preserved as they are server-agnostic
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing {server_source} database data: {e}")
|
||||
raise
|
||||
|
||||
def cleanup_orphaned_records(self) -> Dict[str, int]:
|
||||
"""Remove artists and albums that have no associated tracks"""
|
||||
try:
|
||||
|
|
@ -314,26 +544,31 @@ class MusicDatabase:
|
|||
|
||||
# Artist operations
|
||||
def insert_or_update_artist(self, plex_artist) -> bool:
|
||||
"""Insert or update artist from Plex artist object"""
|
||||
"""Insert or update artist from Plex artist object - DEPRECATED: Use insert_or_update_media_artist instead"""
|
||||
return self.insert_or_update_media_artist(plex_artist, server_source='plex')
|
||||
|
||||
def insert_or_update_media_artist(self, artist_obj, server_source: str = 'plex') -> bool:
|
||||
"""Insert or update artist from media server artist object (Plex or Jellyfin)"""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
artist_id = int(plex_artist.ratingKey)
|
||||
name = plex_artist.title
|
||||
thumb_url = getattr(plex_artist, 'thumb', None)
|
||||
summary = getattr(plex_artist, 'summary', None)
|
||||
# Convert artist ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
|
||||
artist_id = str(artist_obj.ratingKey)
|
||||
name = artist_obj.title
|
||||
thumb_url = getattr(artist_obj, 'thumb', None)
|
||||
summary = getattr(artist_obj, 'summary', None)
|
||||
|
||||
# Get genres
|
||||
# Get genres (handle both Plex and Jellyfin formats)
|
||||
genres = []
|
||||
if hasattr(plex_artist, 'genres') and plex_artist.genres:
|
||||
if hasattr(artist_obj, 'genres') and artist_obj.genres:
|
||||
genres = [genre.tag if hasattr(genre, 'tag') else str(genre)
|
||||
for genre in plex_artist.genres]
|
||||
for genre in artist_obj.genres]
|
||||
|
||||
genres_json = json.dumps(genres) if genres else None
|
||||
|
||||
# Check if artist exists
|
||||
cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,))
|
||||
# Check if artist exists with this ID and server source
|
||||
cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = ?", (artist_id, server_source))
|
||||
exists = cursor.fetchone()
|
||||
|
||||
if exists:
|
||||
|
|
@ -341,20 +576,20 @@ class MusicDatabase:
|
|||
cursor.execute("""
|
||||
UPDATE artists
|
||||
SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (name, thumb_url, genres_json, summary, artist_id))
|
||||
WHERE id = ? AND server_source = ?
|
||||
""", (name, thumb_url, genres_json, summary, artist_id, server_source))
|
||||
else:
|
||||
# Insert new artist
|
||||
cursor.execute("""
|
||||
INSERT INTO artists (id, name, thumb_url, genres, summary)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (artist_id, name, thumb_url, genres_json, summary))
|
||||
INSERT INTO artists (id, name, thumb_url, genres, summary, server_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (artist_id, name, thumb_url, genres_json, summary, server_source))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error inserting/updating artist {getattr(plex_artist, 'title', 'Unknown')}: {e}")
|
||||
logger.error(f"Error inserting/updating {server_source} artist {getattr(artist_obj, 'title', 'Unknown')}: {e}")
|
||||
return False
|
||||
|
||||
def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]:
|
||||
|
|
@ -385,30 +620,35 @@ class MusicDatabase:
|
|||
|
||||
# Album operations
|
||||
def insert_or_update_album(self, plex_album, artist_id: int) -> bool:
|
||||
"""Insert or update album from Plex album object"""
|
||||
"""Insert or update album from Plex album object - DEPRECATED: Use insert_or_update_media_album instead"""
|
||||
return self.insert_or_update_media_album(plex_album, artist_id, server_source='plex')
|
||||
|
||||
def insert_or_update_media_album(self, album_obj, artist_id: str, server_source: str = 'plex') -> bool:
|
||||
"""Insert or update album from media server album object (Plex or Jellyfin)"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
album_id = int(plex_album.ratingKey)
|
||||
title = plex_album.title
|
||||
year = getattr(plex_album, 'year', None)
|
||||
thumb_url = getattr(plex_album, 'thumb', None)
|
||||
# Convert album ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
|
||||
album_id = str(album_obj.ratingKey)
|
||||
title = album_obj.title
|
||||
year = getattr(album_obj, 'year', None)
|
||||
thumb_url = getattr(album_obj, 'thumb', None)
|
||||
|
||||
# Get track count and duration
|
||||
track_count = getattr(plex_album, 'leafCount', None)
|
||||
duration = getattr(plex_album, 'duration', None)
|
||||
# Get track count and duration (handle different server attributes)
|
||||
track_count = getattr(album_obj, 'leafCount', None) or getattr(album_obj, 'childCount', None)
|
||||
duration = getattr(album_obj, 'duration', None)
|
||||
|
||||
# Get genres
|
||||
# Get genres (handle both Plex and Jellyfin formats)
|
||||
genres = []
|
||||
if hasattr(plex_album, 'genres') and plex_album.genres:
|
||||
if hasattr(album_obj, 'genres') and album_obj.genres:
|
||||
genres = [genre.tag if hasattr(genre, 'tag') else str(genre)
|
||||
for genre in plex_album.genres]
|
||||
for genre in album_obj.genres]
|
||||
|
||||
genres_json = json.dumps(genres) if genres else None
|
||||
|
||||
# Check if album exists
|
||||
cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,))
|
||||
# Check if album exists with this ID and server source
|
||||
cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = ?", (album_id, server_source))
|
||||
exists = cursor.fetchone()
|
||||
|
||||
if exists:
|
||||
|
|
@ -417,20 +657,20 @@ class MusicDatabase:
|
|||
UPDATE albums
|
||||
SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
|
||||
track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id))
|
||||
WHERE id = ? AND server_source = ?
|
||||
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id, server_source))
|
||||
else:
|
||||
# Insert new album
|
||||
cursor.execute("""
|
||||
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration))
|
||||
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error inserting/updating album {getattr(plex_album, 'title', 'Unknown')}: {e}")
|
||||
logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
|
||||
return False
|
||||
|
||||
def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
|
||||
|
|
@ -466,60 +706,70 @@ class MusicDatabase:
|
|||
|
||||
# Track operations
|
||||
def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool:
|
||||
"""Insert or update track from Plex track object"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
track_id = int(plex_track.ratingKey)
|
||||
title = plex_track.title
|
||||
track_number = getattr(plex_track, 'trackNumber', None)
|
||||
duration = getattr(plex_track, 'duration', None)
|
||||
|
||||
# Get file path and media info
|
||||
file_path = None
|
||||
bitrate = None
|
||||
if hasattr(plex_track, 'media') and plex_track.media:
|
||||
media = plex_track.media[0] if plex_track.media else None
|
||||
if media:
|
||||
if hasattr(media, 'parts') and media.parts:
|
||||
part = media.parts[0]
|
||||
file_path = getattr(part, 'file', None)
|
||||
bitrate = getattr(media, 'bitrate', None)
|
||||
|
||||
# Check if track exists
|
||||
cursor.execute("SELECT id FROM tracks WHERE id = ?", (track_id,))
|
||||
exists = cursor.fetchone()
|
||||
|
||||
if exists:
|
||||
# Update existing track
|
||||
cursor.execute("""
|
||||
UPDATE tracks
|
||||
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
|
||||
duration = ?, file_path = ?, bitrate = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, track_id))
|
||||
else:
|
||||
# Insert new track
|
||||
cursor.execute("""
|
||||
INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error inserting/updating track {getattr(plex_track, 'title', 'Unknown')}: {e}")
|
||||
return False
|
||||
"""Insert or update track from Plex track object - DEPRECATED: Use insert_or_update_media_track instead"""
|
||||
return self.insert_or_update_media_track(plex_track, album_id, artist_id, server_source='plex')
|
||||
|
||||
def track_exists(self, track_id: int) -> bool:
|
||||
"""Check if a track exists in the database by Plex ID"""
|
||||
def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, server_source: str = 'plex') -> bool:
|
||||
"""Insert or update track from media server track object (Plex or Jellyfin) with retry logic"""
|
||||
max_retries = 3
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Set shorter timeout to prevent long locks
|
||||
cursor.execute("PRAGMA busy_timeout = 10000") # 10 second timeout
|
||||
|
||||
# Convert track ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
|
||||
track_id = str(track_obj.ratingKey)
|
||||
title = track_obj.title
|
||||
track_number = getattr(track_obj, 'trackNumber', None)
|
||||
duration = getattr(track_obj, 'duration', None)
|
||||
|
||||
# Get file path and media info (Plex-specific, Jellyfin may not have these)
|
||||
file_path = None
|
||||
bitrate = None
|
||||
if hasattr(track_obj, 'media') and track_obj.media:
|
||||
media = track_obj.media[0] if track_obj.media else None
|
||||
if media:
|
||||
if hasattr(media, 'parts') and media.parts:
|
||||
part = media.parts[0]
|
||||
file_path = getattr(part, 'file', None)
|
||||
bitrate = getattr(media, 'bitrate', None)
|
||||
|
||||
# Use INSERT OR REPLACE to handle duplicate IDs gracefully
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO tracks
|
||||
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
if "database is locked" in str(e).lower() and retry_count < max_retries:
|
||||
logger.warning(f"Database locked on track '{getattr(track_obj, 'title', 'Unknown')}', retrying {retry_count}/{max_retries}...")
|
||||
time.sleep(0.1 * retry_count) # Exponential backoff
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Error inserting/updating {server_source} track {getattr(track_obj, 'title', 'Unknown')}: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def track_exists(self, track_id) -> bool:
|
||||
"""Check if a track exists in the database by ID (supports both int and string IDs)"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
|
||||
# Convert to string to handle both Plex integers and Jellyfin GUIDs
|
||||
track_id_str = str(track_id)
|
||||
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id_str,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
return result is not None
|
||||
|
|
@ -528,12 +778,31 @@ class MusicDatabase:
|
|||
logger.error(f"Error checking if track {track_id} exists: {e}")
|
||||
return False
|
||||
|
||||
def get_track_by_id(self, track_id: int) -> Optional[DatabaseTrackWithMetadata]:
|
||||
"""Get a track with artist and album names by Plex ID"""
|
||||
def track_exists_by_server(self, track_id, server_source: str) -> bool:
|
||||
"""Check if a track exists in the database by ID and server source"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Convert to string to handle both Plex integers and Jellyfin GUIDs
|
||||
track_id_str = str(track_id)
|
||||
cursor.execute("SELECT 1 FROM tracks WHERE id = ? AND server_source = ? LIMIT 1", (track_id_str, server_source))
|
||||
result = cursor.fetchone()
|
||||
|
||||
return result is not None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if track {track_id} exists for server {server_source}: {e}")
|
||||
return False
|
||||
|
||||
def get_track_by_id(self, track_id) -> Optional[DatabaseTrackWithMetadata]:
|
||||
"""Get a track with artist and album names by ID (supports both int and string IDs)"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Convert to string to handle both Plex integers and Jellyfin GUIDs
|
||||
track_id_str = str(track_id)
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number,
|
||||
t.duration, t.created_at, t.updated_at,
|
||||
|
|
@ -542,7 +811,7 @@ class MusicDatabase:
|
|||
JOIN artists a ON t.artist_id = a.id
|
||||
JOIN albums al ON t.album_id = al.id
|
||||
WHERE t.id = ?
|
||||
""", (track_id,))
|
||||
""", (track_id_str,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
|
|
@ -628,7 +897,7 @@ class MusicDatabase:
|
|||
logger.error(f"Error searching artists with query '{query}': {e}")
|
||||
return []
|
||||
|
||||
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50) -> List[DatabaseTrack]:
|
||||
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]:
|
||||
"""Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
|
||||
try:
|
||||
if not title and not artist:
|
||||
|
|
@ -638,7 +907,7 @@ class MusicDatabase:
|
|||
cursor = conn.cursor()
|
||||
|
||||
# STRATEGY 1: Try basic SQL LIKE search first (fastest)
|
||||
basic_results = self._search_tracks_basic(cursor, title, artist, limit)
|
||||
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
|
||||
|
||||
if basic_results:
|
||||
logger.debug(f"🔍 Basic search found {len(basic_results)} results")
|
||||
|
|
@ -652,7 +921,7 @@ class MusicDatabase:
|
|||
unicode_support = False
|
||||
|
||||
if unicode_support:
|
||||
normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit)
|
||||
normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit, server_source)
|
||||
if normalized_results:
|
||||
logger.debug(f"🔍 Unicode fallback search found {len(normalized_results)} results")
|
||||
return normalized_results
|
||||
|
|
@ -668,7 +937,7 @@ class MusicDatabase:
|
|||
logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}")
|
||||
return []
|
||||
|
||||
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]:
|
||||
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
|
||||
"""Basic SQL LIKE search - fastest method"""
|
||||
where_conditions = []
|
||||
params = []
|
||||
|
|
@ -681,6 +950,11 @@ class MusicDatabase:
|
|||
where_conditions.append("artists.name LIKE ?")
|
||||
params.append(f"%{artist}%")
|
||||
|
||||
# Add server filter if specified
|
||||
if server_source:
|
||||
where_conditions.append("tracks.server_source = ?")
|
||||
params.append(server_source)
|
||||
|
||||
if not where_conditions:
|
||||
return []
|
||||
|
||||
|
|
@ -699,7 +973,7 @@ class MusicDatabase:
|
|||
|
||||
return self._rows_to_tracks(cursor.fetchall())
|
||||
|
||||
def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]:
|
||||
def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
|
||||
"""Unicode-aware fallback search - tries normalized versions"""
|
||||
from unidecode import unidecode
|
||||
|
||||
|
|
@ -719,6 +993,11 @@ class MusicDatabase:
|
|||
where_conditions.append("LOWER(artists.name) LIKE ?")
|
||||
params.append(f"%{artist_norm}%")
|
||||
|
||||
# Add server filter if specified
|
||||
if server_source:
|
||||
where_conditions.append("tracks.server_source = ?")
|
||||
params.append(server_source)
|
||||
|
||||
if not where_conditions:
|
||||
return []
|
||||
|
||||
|
|
@ -918,7 +1197,7 @@ class MusicDatabase:
|
|||
return list(set(variations))
|
||||
|
||||
|
||||
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseTrack], float]:
|
||||
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None) -> Tuple[Optional[DatabaseTrack], float]:
|
||||
"""
|
||||
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
|
||||
Now uses the same sophisticated matching approach as album checking for consistency.
|
||||
|
|
@ -941,7 +1220,7 @@ class MusicDatabase:
|
|||
potential_matches = []
|
||||
artist_variations = self._get_artist_variations(artist)
|
||||
for artist_variation in artist_variations:
|
||||
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20))
|
||||
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source))
|
||||
|
||||
if not potential_matches:
|
||||
continue
|
||||
|
|
@ -1809,7 +2088,7 @@ class MusicDatabase:
|
|||
return 0
|
||||
|
||||
def get_database_info(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive database information"""
|
||||
"""Get comprehensive database information for all servers (legacy method)"""
|
||||
try:
|
||||
stats = self.get_statistics()
|
||||
|
||||
|
|
@ -1857,6 +2136,65 @@ class MusicDatabase:
|
|||
'last_update': None,
|
||||
'last_full_refresh': None
|
||||
}
|
||||
|
||||
def get_database_info_for_server(self, server_source: str = None) -> Dict[str, Any]:
|
||||
"""Get comprehensive database information filtered by server source"""
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from config.settings import config_manager
|
||||
|
||||
# If no server specified, use active server
|
||||
if server_source is None:
|
||||
server_source = config_manager.get_active_media_server()
|
||||
|
||||
stats = self.get_statistics_for_server(server_source)
|
||||
|
||||
# Get database file size (always total, not server-specific)
|
||||
db_size = self.database_path.stat().st_size if self.database_path.exists() else 0
|
||||
db_size_mb = db_size / (1024 * 1024)
|
||||
|
||||
# Get last update time for this server
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT MAX(updated_at) as last_update
|
||||
FROM (
|
||||
SELECT updated_at FROM artists WHERE server_source = ?
|
||||
UNION ALL
|
||||
SELECT updated_at FROM albums WHERE server_source = ?
|
||||
UNION ALL
|
||||
SELECT updated_at FROM tracks WHERE server_source = ?
|
||||
)
|
||||
""", (server_source, server_source, server_source))
|
||||
|
||||
result = cursor.fetchone()
|
||||
last_update = result['last_update'] if result and result['last_update'] else None
|
||||
|
||||
# Get last full refresh (global setting, not server-specific)
|
||||
last_full_refresh = self.get_last_full_refresh()
|
||||
|
||||
return {
|
||||
**stats,
|
||||
'database_size_mb': round(db_size_mb, 2),
|
||||
'database_path': str(self.database_path),
|
||||
'last_update': last_update,
|
||||
'last_full_refresh': last_full_refresh,
|
||||
'server_source': server_source
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database info for {server_source}: {e}")
|
||||
return {
|
||||
'artists': 0,
|
||||
'albums': 0,
|
||||
'tracks': 0,
|
||||
'database_size_mb': 0.0,
|
||||
'database_path': str(self.database_path),
|
||||
'last_update': None,
|
||||
'last_full_refresh': None,
|
||||
'server_source': server_source
|
||||
}
|
||||
|
||||
# Thread-safe singleton pattern for database access
|
||||
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
||||
|
|
|
|||
32
main.py
32
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue