commit
4ffdf12460
18 changed files with 1914 additions and 150 deletions
|
|
@ -20,6 +20,12 @@
|
||||||
"api_key": "JELLYFIN_API_KEY",
|
"api_key": "JELLYFIN_API_KEY",
|
||||||
"auto_detect": true
|
"auto_detect": true
|
||||||
},
|
},
|
||||||
|
"navidrome": {
|
||||||
|
"base_url": "http://localhost:4533",
|
||||||
|
"username": "NAVIDROME_USERNAME",
|
||||||
|
"password": "NAVIDROME_PASSWORD",
|
||||||
|
"auto_detect": true
|
||||||
|
},
|
||||||
"soulseek": {
|
"soulseek": {
|
||||||
"slskd_url": "http://host.docker.internal:5030",
|
"slskd_url": "http://host.docker.internal:5030",
|
||||||
"api_key": "SoulseekAPIKey",
|
"api_key": "SoulseekAPIKey",
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,17 @@
|
||||||
"api_key": "JELLYFIN_API_KEY",
|
"api_key": "JELLYFIN_API_KEY",
|
||||||
"auto_detect": true
|
"auto_detect": true
|
||||||
},
|
},
|
||||||
|
"navidrome": {
|
||||||
|
"base_url": "http://localhost:4533",
|
||||||
|
"username": "NAVIDROME_USERNAME",
|
||||||
|
"password": "NAVIDROME_PASSWORD",
|
||||||
|
"auto_detect": true
|
||||||
|
},
|
||||||
"soulseek": {
|
"soulseek": {
|
||||||
"slskd_url": "http://localhost:5030",
|
"slskd_url": "http://host.docker.internal:5030",
|
||||||
"api_key": "SoulseekAPIKey",
|
"api_key": "SoulseekAPIKey",
|
||||||
"download_path": "./Downloads",
|
"download_path": "/app/downloads",
|
||||||
"transfer_path": "./Transfer"
|
"transfer_path": "/app/Transfer"
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"path": "logs/app.log",
|
"path": "logs/app.log",
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,10 @@ class ConfigManager:
|
||||||
|
|
||||||
def get_jellyfin_config(self) -> Dict[str, str]:
|
def get_jellyfin_config(self) -> Dict[str, str]:
|
||||||
return self.get('jellyfin', {})
|
return self.get('jellyfin', {})
|
||||||
|
|
||||||
|
def get_navidrome_config(self) -> Dict[str, str]:
|
||||||
|
return self.get('navidrome', {})
|
||||||
|
|
||||||
def get_soulseek_config(self) -> Dict[str, str]:
|
def get_soulseek_config(self) -> Dict[str, str]:
|
||||||
return self.get('soulseek', {})
|
return self.get('soulseek', {})
|
||||||
|
|
||||||
|
|
@ -83,8 +86,8 @@ class ConfigManager:
|
||||||
return self.get('active_media_server', 'plex')
|
return self.get('active_media_server', 'plex')
|
||||||
|
|
||||||
def set_active_media_server(self, server: str):
|
def set_active_media_server(self, server: str):
|
||||||
"""Set the active media server (plex or jellyfin)"""
|
"""Set the active media server (plex, jellyfin, or navidrome)"""
|
||||||
if server not in ['plex', 'jellyfin']:
|
if server not in ['plex', 'jellyfin', 'navidrome']:
|
||||||
raise ValueError(f"Invalid media server: {server}")
|
raise ValueError(f"Invalid media server: {server}")
|
||||||
self.set('active_media_server', server)
|
self.set('active_media_server', server)
|
||||||
|
|
||||||
|
|
@ -95,6 +98,8 @@ class ConfigManager:
|
||||||
return self.get_plex_config()
|
return self.get_plex_config()
|
||||||
elif active_server == 'jellyfin':
|
elif active_server == 'jellyfin':
|
||||||
return self.get_jellyfin_config()
|
return self.get_jellyfin_config()
|
||||||
|
elif active_server == 'navidrome':
|
||||||
|
return self.get_navidrome_config()
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
@ -111,6 +116,9 @@ class ConfigManager:
|
||||||
elif active_server == 'jellyfin':
|
elif active_server == 'jellyfin':
|
||||||
jellyfin = self.get_jellyfin_config()
|
jellyfin = self.get_jellyfin_config()
|
||||||
media_server_configured = bool(jellyfin.get('base_url')) and bool(jellyfin.get('api_key'))
|
media_server_configured = bool(jellyfin.get('base_url')) and bool(jellyfin.get('api_key'))
|
||||||
|
elif active_server == 'navidrome':
|
||||||
|
navidrome = self.get_navidrome_config()
|
||||||
|
media_server_configured = bool(navidrome.get('base_url')) and bool(navidrome.get('username')) and bool(navidrome.get('password'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
bool(spotify.get('client_id')) and
|
bool(spotify.get('client_id')) and
|
||||||
|
|
@ -127,9 +135,10 @@ class ConfigManager:
|
||||||
'soulseek': bool(self.get('soulseek.slskd_url'))
|
'soulseek': bool(self.get('soulseek.slskd_url'))
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate both server types but mark active one
|
# Validate all server types but mark active one
|
||||||
validation['plex'] = bool(self.get('plex.base_url')) and bool(self.get('plex.token'))
|
validation['plex'] = bool(self.get('plex.base_url')) and bool(self.get('plex.token'))
|
||||||
validation['jellyfin'] = bool(self.get('jellyfin.base_url')) and bool(self.get('jellyfin.api_key'))
|
validation['jellyfin'] = bool(self.get('jellyfin.base_url')) and bool(self.get('jellyfin.api_key'))
|
||||||
|
validation['navidrome'] = bool(self.get('navidrome.base_url')) and bool(self.get('navidrome.username')) and bool(self.get('navidrome.password'))
|
||||||
validation['active_media_server'] = active_server
|
validation['active_media_server'] = active_server
|
||||||
|
|
||||||
return validation
|
return validation
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,17 @@ class DatabaseUpdateWorker(QThread):
|
||||||
error = pyqtSignal(str) # error_message
|
error = pyqtSignal(str) # error_message
|
||||||
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
||||||
|
|
||||||
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"):
|
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex", force_sequential: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
# Force sequential processing for web server mode to avoid threading issues
|
||||||
|
self.force_sequential = force_sequential
|
||||||
|
|
||||||
# Initialize signal callbacks for headless mode
|
# Initialize signal callbacks for headless mode
|
||||||
if not QT_AVAILABLE:
|
if not QT_AVAILABLE:
|
||||||
self.callbacks = {
|
self.callbacks = {
|
||||||
'progress_updated': [],
|
'progress_updated': [],
|
||||||
'artist_processed': [],
|
'artist_processed': [],
|
||||||
'finished': [],
|
'finished': [],
|
||||||
'error': [],
|
'error': [],
|
||||||
'phase_changed': []
|
'phase_changed': []
|
||||||
|
|
@ -93,12 +96,15 @@ class DatabaseUpdateWorker(QThread):
|
||||||
database_config = config_manager.get('database', {})
|
database_config = config_manager.get('database', {})
|
||||||
base_max_workers = database_config.get('max_workers', 5)
|
base_max_workers = database_config.get('max_workers', 5)
|
||||||
|
|
||||||
# Optimize worker count - reduce for database concurrency safety
|
# Optimize worker count - reduce for database concurrency safety
|
||||||
if self.server_type == "jellyfin":
|
if self.server_type == "jellyfin":
|
||||||
# Reduce workers to prevent database lock issues with bulk inserts
|
# Reduce workers to prevent database lock issues with bulk inserts
|
||||||
self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety
|
self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety
|
||||||
if base_max_workers > 3:
|
if base_max_workers > 3:
|
||||||
logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety")
|
logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety")
|
||||||
|
elif self.server_type == "navidrome":
|
||||||
|
# Navidrome uses standard worker count like Plex
|
||||||
|
self.max_workers = base_max_workers
|
||||||
else:
|
else:
|
||||||
# Plex uses standard worker count
|
# Plex uses standard worker count
|
||||||
self.max_workers = base_max_workers
|
self.max_workers = base_max_workers
|
||||||
|
|
@ -128,32 +134,42 @@ class DatabaseUpdateWorker(QThread):
|
||||||
"""Stop the database update process"""
|
"""Stop the database update process"""
|
||||||
self.should_stop = True
|
self.should_stop = True
|
||||||
|
|
||||||
# Clear Jellyfin cache when user stops scan to free memory
|
# Clear media client cache when user stops scan to free memory
|
||||||
if self.server_type == "jellyfin" and hasattr(self, 'media_client'):
|
if self.server_type in ["jellyfin", "navidrome"] and hasattr(self, 'media_client'):
|
||||||
try:
|
try:
|
||||||
cache_stats = self.media_client.get_cache_stats()
|
if hasattr(self.media_client, 'get_cache_stats'):
|
||||||
|
cache_stats = self.media_client.get_cache_stats()
|
||||||
|
freed_items = cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)
|
||||||
|
else:
|
||||||
|
freed_items = "unknown"
|
||||||
self.media_client.clear_cache()
|
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")
|
logger.info(f"🧹 Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear Jellyfin cache on stop: {e}")
|
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Main worker thread execution"""
|
"""Main worker thread execution"""
|
||||||
try:
|
try:
|
||||||
# Initialize database
|
# Initialize database
|
||||||
self.database = get_database(self.database_path)
|
self.database = get_database(self.database_path)
|
||||||
|
|
||||||
if self.full_refresh:
|
if self.full_refresh:
|
||||||
logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data")
|
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)
|
self.database.clear_server_data(self.server_type)
|
||||||
|
|
||||||
# Show cache preparation phase for Jellyfin and set up progress callback
|
# Show cache preparation phase for Jellyfin and set up progress callback
|
||||||
if self.server_type == "jellyfin":
|
if self.server_type == "jellyfin":
|
||||||
self._emit_signal('phase_changed', "Preparing Jellyfin cache for fast processing...")
|
self._emit_signal('phase_changed', "Preparing Jellyfin cache for fast processing...")
|
||||||
# Connect Jellyfin client progress to UI
|
# Connect Jellyfin client progress to UI
|
||||||
if hasattr(self.media_client, 'set_progress_callback'):
|
if hasattr(self.media_client, 'set_progress_callback'):
|
||||||
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
||||||
|
elif self.server_type == "navidrome":
|
||||||
|
self._emit_signal('phase_changed', "Connecting to Navidrome server...")
|
||||||
|
# Connect Navidrome client progress to UI
|
||||||
|
if hasattr(self.media_client, 'set_progress_callback'):
|
||||||
|
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
||||||
|
logger.info("✅ Connected Navidrome progress callback")
|
||||||
|
|
||||||
# For full refresh, get all artists
|
# For full refresh, get all artists
|
||||||
artists_to_process = self._get_all_artists()
|
artists_to_process = self._get_all_artists()
|
||||||
if not artists_to_process:
|
if not artists_to_process:
|
||||||
|
|
@ -173,12 +189,13 @@ class DatabaseUpdateWorker(QThread):
|
||||||
|
|
||||||
# Phase 2: Process artists and their albums/tracks
|
# Phase 2: Process artists and their albums/tracks
|
||||||
self._emit_signal('phase_changed', "Processing artists, albums, and tracks...")
|
self._emit_signal('phase_changed', "Processing artists, albums, and tracks...")
|
||||||
|
|
||||||
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
|
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
|
||||||
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
|
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
|
||||||
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
self._process_jellyfin_new_tracks_directly(artists_to_process)
|
||||||
else:
|
else:
|
||||||
# Standard artist processing for Plex or full refresh
|
# Standard artist processing for Plex or full refresh
|
||||||
|
logger.info(f"🎯 About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
|
||||||
self._process_all_artists(artists_to_process)
|
self._process_all_artists(artists_to_process)
|
||||||
|
|
||||||
# Record full refresh completion for tracking purposes
|
# Record full refresh completion for tracking purposes
|
||||||
|
|
@ -189,14 +206,18 @@ class DatabaseUpdateWorker(QThread):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not record full refresh completion: {e}")
|
logger.warning(f"Could not record full refresh completion: {e}")
|
||||||
|
|
||||||
# Clear Jellyfin cache after full refresh to free memory
|
# Clear cache after full refresh to free memory
|
||||||
if self.full_refresh and self.server_type == "jellyfin":
|
if self.full_refresh and self.server_type in ["jellyfin", "navidrome"]:
|
||||||
try:
|
try:
|
||||||
cache_stats = self.media_client.get_cache_stats()
|
if hasattr(self.media_client, 'get_cache_stats'):
|
||||||
|
cache_stats = self.media_client.get_cache_stats()
|
||||||
|
freed_items = cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)
|
||||||
|
else:
|
||||||
|
freed_items = "cache data"
|
||||||
self.media_client.clear_cache()
|
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")
|
logger.info(f"🧹 Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear Jellyfin cache: {e}")
|
logger.warning(f"Could not clear {self.server_type} cache: {e}")
|
||||||
|
|
||||||
# Cleanup orphaned records after incremental updates (catches fixed matches)
|
# Cleanup orphaned records after incremental updates (catches fixed matches)
|
||||||
if not self.full_refresh and self.database:
|
if not self.full_refresh and self.database:
|
||||||
|
|
@ -236,10 +257,12 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if not self.media_client.ensure_connection():
|
if not self.media_client.ensure_connection():
|
||||||
logger.error(f"Could not connect to {self.server_type} server")
|
logger.error(f"Could not connect to {self.server_type} server")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
logger.info(f"🎯 _get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
|
||||||
artists = self.media_client.get_all_artists()
|
artists = self.media_client.get_all_artists()
|
||||||
|
logger.info(f"🎯 _get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting artists from {self.server_type}: {e}")
|
logger.error(f"Error getting artists from {self.server_type}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
@ -288,11 +311,17 @@ class DatabaseUpdateWorker(QThread):
|
||||||
if self.server_type == "jellyfin":
|
if self.server_type == "jellyfin":
|
||||||
if hasattr(self.media_client, 'set_progress_callback'):
|
if hasattr(self.media_client, 'set_progress_callback'):
|
||||||
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', f"Incremental: {msg}"))
|
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', f"Incremental: {msg}"))
|
||||||
|
elif self.server_type == "navidrome":
|
||||||
|
# Navidrome doesn't need cache preparation for incremental updates
|
||||||
|
logger.info("Navidrome incremental update: no caching needed")
|
||||||
|
|
||||||
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
|
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
|
||||||
if self.server_type == "jellyfin":
|
if self.server_type == "jellyfin":
|
||||||
return self._get_artists_for_jellyfin_track_incremental_update()
|
return self._get_artists_for_jellyfin_track_incremental_update()
|
||||||
|
elif self.server_type == "navidrome":
|
||||||
|
# Navidrome: simple approach - get all artists and check what's new in database
|
||||||
|
return self._get_artists_for_navidrome_incremental_update()
|
||||||
|
|
||||||
# Plex uses album-based approach (established and working)
|
# Plex uses album-based approach (established and working)
|
||||||
recent_albums = self._get_recent_albums_for_server()
|
recent_albums = self._get_recent_albums_for_server()
|
||||||
if not recent_albums:
|
if not recent_albums:
|
||||||
|
|
@ -450,6 +479,107 @@ class DatabaseUpdateWorker(QThread):
|
||||||
logger.error(f"Error in smart incremental update: {e}")
|
logger.error(f"Error in smart incremental update: {e}")
|
||||||
# Fallback to empty list - user can try full refresh
|
# Fallback to empty list - user can try full refresh
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _get_artists_for_navidrome_incremental_update(self) -> List:
|
||||||
|
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
|
||||||
|
try:
|
||||||
|
logger.info("🎵 Navidrome incremental: Getting recent albums and checking for new content...")
|
||||||
|
|
||||||
|
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
|
||||||
|
recent_albums = self._get_recent_albums_for_server()
|
||||||
|
if not recent_albums:
|
||||||
|
logger.info("No recent albums found - nothing to process")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Found {len(recent_albums)} recent albums to check")
|
||||||
|
|
||||||
|
# Sort albums by added date (newest first) - handle None dates properly
|
||||||
|
try:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Extract artists from recent albums with early stopping logic (same as Plex/Jellyfin)
|
||||||
|
artists_to_process = []
|
||||||
|
processed_artist_ids = set()
|
||||||
|
consecutive_complete_albums = 0
|
||||||
|
total_tracks_checked = 0
|
||||||
|
|
||||||
|
logger.info("Checking artists from recent albums (with early stopping)...")
|
||||||
|
|
||||||
|
for i, album in enumerate(recent_albums):
|
||||||
|
if self.should_stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure this is actually an album object
|
||||||
|
if not hasattr(album, 'tracks'):
|
||||||
|
logger.warning(f"Skipping invalid album object at index {i}: {type(album).__name__}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
album_title = getattr(album, 'title', f'Album_{i}')
|
||||||
|
album_has_new_tracks = False
|
||||||
|
|
||||||
|
# Check if album's tracks are already in database
|
||||||
|
try:
|
||||||
|
album_tracks = album.tracks()
|
||||||
|
total_tracks_checked += len(album_tracks)
|
||||||
|
|
||||||
|
for track in album_tracks:
|
||||||
|
if not self.database.track_exists(track.ratingKey, self.server_type):
|
||||||
|
album_has_new_tracks = True
|
||||||
|
consecutive_complete_albums = 0 # Reset counter
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no new tracks found, increment consecutive complete counter
|
||||||
|
if not album_has_new_tracks:
|
||||||
|
consecutive_complete_albums += 1
|
||||||
|
logger.debug(f"✅ Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
|
||||||
|
|
||||||
|
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
|
||||||
|
if consecutive_complete_albums >= 25:
|
||||||
|
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as tracks_error:
|
||||||
|
logger.warning(f"Error getting tracks for album '{album_title}': {tracks_error}")
|
||||||
|
# Assume album needs processing if we can't check tracks
|
||||||
|
album_has_new_tracks = True
|
||||||
|
consecutive_complete_albums = 0
|
||||||
|
|
||||||
|
# If album has new tracks, queue its artist for processing
|
||||||
|
if album_has_new_tracks:
|
||||||
|
try:
|
||||||
|
album_artist = album.artist()
|
||||||
|
if album_artist:
|
||||||
|
artist_id = str(album_artist.ratingKey)
|
||||||
|
|
||||||
|
# Skip if we've already queued this artist
|
||||||
|
if artist_id not in processed_artist_ids:
|
||||||
|
processed_artist_ids.add(artist_id)
|
||||||
|
artists_to_process.append(album_artist)
|
||||||
|
logger.info(f"✅ Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
|
||||||
|
except Exception as artist_error:
|
||||||
|
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error processing album at index {i}: {e}")
|
||||||
|
consecutive_complete_albums = 0 # Reset on error
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"🎵 Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
|
||||||
|
return artists_to_process
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in Navidrome incremental update: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
|
||||||
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
|
||||||
|
|
@ -694,6 +824,8 @@ class DatabaseUpdateWorker(QThread):
|
||||||
return self._get_recent_albums_plex()
|
return self._get_recent_albums_plex()
|
||||||
elif self.server_type == "jellyfin":
|
elif self.server_type == "jellyfin":
|
||||||
return self._get_recent_albums_jellyfin()
|
return self._get_recent_albums_jellyfin()
|
||||||
|
elif self.server_type == "navidrome":
|
||||||
|
return self._get_recent_albums_navidrome()
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unknown server type: {self.server_type}")
|
logger.error(f"Unknown server type: {self.server_type}")
|
||||||
return []
|
return []
|
||||||
|
|
@ -782,10 +914,62 @@ class DatabaseUpdateWorker(QThread):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting recent Jellyfin albums: {e}")
|
logger.error(f"Error getting recent Jellyfin albums: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _get_recent_albums_navidrome(self) -> List:
|
||||||
|
"""Get recently added albums from Navidrome using all albums sorted by date"""
|
||||||
|
try:
|
||||||
|
logger.info("Getting recent albums from Navidrome...")
|
||||||
|
|
||||||
|
# Navidrome doesn't have a direct "recent albums" API like Plex/Jellyfin
|
||||||
|
# So we need to get all albums and sort them by date
|
||||||
|
all_artists = self.media_client.get_all_artists()
|
||||||
|
if not all_artists:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_albums = []
|
||||||
|
# Get albums from a subset of artists to avoid too much data
|
||||||
|
# Take first 200 artists to get a reasonable sample of recent albums
|
||||||
|
sample_artists = all_artists[:200]
|
||||||
|
|
||||||
|
for artist in sample_artists:
|
||||||
|
try:
|
||||||
|
artist_albums = self.media_client.get_albums_for_artist(artist.ratingKey)
|
||||||
|
all_albums.extend(artist_albums)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error getting albums for artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not all_albums:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by addedAt date (newest first) and take recent ones
|
||||||
|
try:
|
||||||
|
def get_sort_date(album):
|
||||||
|
date_val = getattr(album, 'addedAt', None)
|
||||||
|
if date_val is None:
|
||||||
|
return 0
|
||||||
|
return date_val
|
||||||
|
|
||||||
|
all_albums.sort(key=get_sort_date, reverse=True)
|
||||||
|
# Take the most recent 400 albums for incremental checking
|
||||||
|
recent_albums = all_albums[:400]
|
||||||
|
|
||||||
|
logger.info(f"Found {len(recent_albums)} recent albums from Navidrome (from {len(all_albums)} total)")
|
||||||
|
return recent_albums
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error sorting Navidrome albums by date: {e}")
|
||||||
|
# If sorting fails, just return the first 400 albums
|
||||||
|
return all_albums[:400]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting recent Navidrome albums: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def _process_all_artists(self, artists: List):
|
def _process_all_artists(self, artists: List):
|
||||||
"""Process all artists and their albums/tracks using thread pool"""
|
"""Process all artists and their albums/tracks using thread pool"""
|
||||||
total_artists = len(artists)
|
total_artists = len(artists)
|
||||||
|
logger.info(f"🎯 Processing {total_artists} artists with progress tracking")
|
||||||
|
|
||||||
def process_single_artist(artist):
|
def process_single_artist(artist):
|
||||||
"""Process a single artist and return results"""
|
"""Process a single artist and return results"""
|
||||||
|
|
@ -806,6 +990,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
total_artists,
|
total_artists,
|
||||||
progress_percent
|
progress_percent
|
||||||
)
|
)
|
||||||
|
logger.debug(f"🔄 Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
|
||||||
|
|
||||||
# Process the artist
|
# Process the artist
|
||||||
success, details, album_count, track_count = self._process_artist_with_content(artist)
|
success, details, album_count, track_count = self._process_artist_with_content(artist)
|
||||||
|
|
@ -826,25 +1011,41 @@ class DatabaseUpdateWorker(QThread):
|
||||||
logger.error(f"Error processing artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
logger.error(f"Error processing artist {getattr(artist, 'title', 'Unknown')}: {e}")
|
||||||
return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
||||||
|
|
||||||
# Process artists in parallel using ThreadPoolExecutor
|
# Process artists - use sequential processing in web server mode to avoid threading issues
|
||||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
if not QT_AVAILABLE or self.force_sequential:
|
||||||
# Submit all tasks
|
# Sequential processing for web server mode
|
||||||
future_to_artist = {executor.submit(process_single_artist, artist): artist
|
for i, artist in enumerate(artists):
|
||||||
for artist in artists}
|
|
||||||
|
|
||||||
# Process completed tasks as they finish
|
|
||||||
for future in as_completed(future_to_artist):
|
|
||||||
if self.should_stop:
|
if self.should_stop:
|
||||||
break
|
break
|
||||||
|
|
||||||
result = future.result()
|
result = process_single_artist(artist)
|
||||||
if result is None: # Task was cancelled
|
if result is None: # Task was cancelled
|
||||||
continue
|
continue
|
||||||
|
|
||||||
artist_name, success, details, album_count, track_count = result
|
artist_name, success, details, album_count, track_count = result
|
||||||
|
|
||||||
# Emit progress signal
|
# Emit progress signal
|
||||||
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
||||||
|
else:
|
||||||
|
# Process artists in parallel using ThreadPoolExecutor (Qt mode only)
|
||||||
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||||
|
# Submit all tasks
|
||||||
|
future_to_artist = {executor.submit(process_single_artist, artist): artist
|
||||||
|
for artist in artists}
|
||||||
|
|
||||||
|
# Process completed tasks as they finish
|
||||||
|
for future in as_completed(future_to_artist):
|
||||||
|
if self.should_stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
result = future.result()
|
||||||
|
if result is None: # Task was cancelled
|
||||||
|
continue
|
||||||
|
|
||||||
|
artist_name, success, details, album_count, track_count = result
|
||||||
|
|
||||||
|
# Emit progress signal
|
||||||
|
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
||||||
|
|
||||||
def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
|
def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
|
||||||
"""Process an artist and all their albums and tracks with optimized API usage"""
|
"""Process an artist and all their albums and tracks with optimized API usage"""
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ class MediaScanManager:
|
||||||
# Try to find the main window from top-level widgets
|
# Try to find the main window from top-level widgets
|
||||||
main_window = None
|
main_window = None
|
||||||
for widget in app.topLevelWidgets():
|
for widget in app.topLevelWidgets():
|
||||||
if hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client'):
|
if (hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client') and
|
||||||
|
hasattr(widget, 'navidrome_client')):
|
||||||
main_window = widget
|
main_window = widget
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -70,7 +71,13 @@ class MediaScanManager:
|
||||||
return client, "jellyfin"
|
return client, "jellyfin"
|
||||||
else:
|
else:
|
||||||
logger.warning("Jellyfin client not connected, falling back to Plex")
|
logger.warning("Jellyfin client not connected, falling back to Plex")
|
||||||
|
elif active_server == "navidrome":
|
||||||
|
client = getattr(main_window, 'navidrome_client', None)
|
||||||
|
if client and client.is_connected():
|
||||||
|
return client, "navidrome"
|
||||||
|
else:
|
||||||
|
logger.warning("Navidrome client not connected, falling back to Plex")
|
||||||
|
|
||||||
# Default to Plex or fallback
|
# Default to Plex or fallback
|
||||||
client = getattr(main_window, 'plex_client', None)
|
client = getattr(main_window, 'plex_client', None)
|
||||||
if client and client.is_connected():
|
if client and client.is_connected():
|
||||||
|
|
@ -88,12 +95,17 @@ class MediaScanManager:
|
||||||
# Headless mode - try to get clients from global instances
|
# Headless mode - try to get clients from global instances
|
||||||
import sys
|
import sys
|
||||||
for module_name, module in sys.modules.items():
|
for module_name, module in sys.modules.items():
|
||||||
if hasattr(module, 'plex_client') and hasattr(module, 'jellyfin_client'):
|
if (hasattr(module, 'plex_client') and hasattr(module, 'jellyfin_client') and
|
||||||
|
hasattr(module, 'navidrome_client')):
|
||||||
if active_server == "jellyfin":
|
if active_server == "jellyfin":
|
||||||
client = getattr(module, 'jellyfin_client', None)
|
client = getattr(module, 'jellyfin_client', None)
|
||||||
if client and hasattr(client, 'is_connected') and client.is_connected():
|
if client and hasattr(client, 'is_connected') and client.is_connected():
|
||||||
return client, "jellyfin"
|
return client, "jellyfin"
|
||||||
|
elif active_server == "navidrome":
|
||||||
|
client = getattr(module, 'navidrome_client', None)
|
||||||
|
if client and hasattr(client, 'is_connected') and client.is_connected():
|
||||||
|
return client, "navidrome"
|
||||||
|
|
||||||
client = getattr(module, 'plex_client', None)
|
client = getattr(module, 'plex_client', None)
|
||||||
if client and hasattr(client, 'is_connected') and client.is_connected():
|
if client and hasattr(client, 'is_connected') and client.is_connected():
|
||||||
return client, "plex"
|
return client, "plex"
|
||||||
|
|
|
||||||
774
core/navidrome_client.py
Normal file
774
core/navidrome_client.py
Normal file
|
|
@ -0,0 +1,774 @@
|
||||||
|
import requests
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
logger = get_logger("navidrome_client")
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NavidromeTrackInfo:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
album: str
|
||||||
|
duration: int
|
||||||
|
track_number: Optional[int] = None
|
||||||
|
year: Optional[int] = None
|
||||||
|
rating: Optional[float] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NavidromePlaylistInfo:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
duration: int
|
||||||
|
leaf_count: int
|
||||||
|
tracks: List[NavidromeTrackInfo]
|
||||||
|
|
||||||
|
class NavidromeArtist:
|
||||||
|
"""Wrapper class to mimic Plex artist object interface"""
|
||||||
|
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
|
||||||
|
self._data = navidrome_data
|
||||||
|
self._client = client
|
||||||
|
self.ratingKey = navidrome_data.get('id', '')
|
||||||
|
self.title = navidrome_data.get('name', 'Unknown Artist')
|
||||||
|
self.addedAt = self._parse_date(navidrome_data.get('dateAdded'))
|
||||||
|
|
||||||
|
# Create genres property from Navidrome data
|
||||||
|
self.genres = []
|
||||||
|
# TODO: Map Navidrome genre data to match Plex format
|
||||||
|
|
||||||
|
# Create summary property (used for timestamp storage)
|
||||||
|
self.summary = navidrome_data.get('biography', '') or ''
|
||||||
|
|
||||||
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||||
|
"""Parse Navidrome date string to datetime"""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Navidrome uses ISO format
|
||||||
|
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def albums(self) -> List['NavidromeAlbum']:
|
||||||
|
"""Get all albums for this artist"""
|
||||||
|
return self._client.get_albums_for_artist(self.ratingKey)
|
||||||
|
|
||||||
|
class NavidromeAlbum:
|
||||||
|
"""Wrapper class to mimic Plex album object interface"""
|
||||||
|
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
|
||||||
|
self._data = navidrome_data
|
||||||
|
self._client = client
|
||||||
|
self.ratingKey = navidrome_data.get('id', '')
|
||||||
|
self.title = navidrome_data.get('name', 'Unknown Album')
|
||||||
|
self.year = navidrome_data.get('year')
|
||||||
|
self.addedAt = self._parse_date(navidrome_data.get('created'))
|
||||||
|
self._artist_id = navidrome_data.get('artistId', '')
|
||||||
|
|
||||||
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def artist(self) -> Optional[NavidromeArtist]:
|
||||||
|
"""Get the album artist"""
|
||||||
|
if self._artist_id:
|
||||||
|
return self._client.get_artist_by_id(self._artist_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tracks(self) -> List['NavidromeTrack']:
|
||||||
|
"""Get all tracks for this album"""
|
||||||
|
return self._client.get_tracks_for_album(self.ratingKey)
|
||||||
|
|
||||||
|
class NavidromeTrack:
|
||||||
|
"""Wrapper class to mimic Plex track object interface"""
|
||||||
|
def __init__(self, navidrome_data: Dict[str, Any], client: 'NavidromeClient'):
|
||||||
|
self._data = navidrome_data
|
||||||
|
self._client = client
|
||||||
|
self.ratingKey = navidrome_data.get('id', '')
|
||||||
|
self.title = navidrome_data.get('title', 'Unknown Track')
|
||||||
|
self.duration = navidrome_data.get('duration', 0) * 1000 # Convert to milliseconds
|
||||||
|
self.trackNumber = navidrome_data.get('track')
|
||||||
|
self.year = navidrome_data.get('year')
|
||||||
|
self.userRating = navidrome_data.get('userRating')
|
||||||
|
self.addedAt = self._parse_date(navidrome_data.get('created'))
|
||||||
|
|
||||||
|
self._album_id = navidrome_data.get('albumId', '')
|
||||||
|
self._artist_id = navidrome_data.get('artistId', '')
|
||||||
|
|
||||||
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def artist(self) -> Optional[NavidromeArtist]:
|
||||||
|
"""Get the track artist"""
|
||||||
|
if self._artist_id:
|
||||||
|
return self._client.get_artist_by_id(self._artist_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def album(self) -> Optional[NavidromeAlbum]:
|
||||||
|
"""Get the track's album"""
|
||||||
|
if self._album_id:
|
||||||
|
return self._client.get_album_by_id(self._album_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class NavidromeClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url: Optional[str] = None
|
||||||
|
self.username: Optional[str] = None
|
||||||
|
self.password: Optional[str] = None
|
||||||
|
self._connection_attempted = False
|
||||||
|
self._is_connecting = False
|
||||||
|
|
||||||
|
# Cache for performance
|
||||||
|
self._artist_cache = {}
|
||||||
|
self._album_cache = {}
|
||||||
|
self._track_cache = {}
|
||||||
|
|
||||||
|
# Progress callback for UI updates
|
||||||
|
self._progress_callback = None
|
||||||
|
|
||||||
|
def set_progress_callback(self, callback):
|
||||||
|
"""Set callback function for progress updates"""
|
||||||
|
self._progress_callback = callback
|
||||||
|
|
||||||
|
def ensure_connection(self) -> bool:
|
||||||
|
"""Ensure connection to Navidrome server with lazy initialization."""
|
||||||
|
if self._connection_attempted:
|
||||||
|
return self.base_url is not None and self.username is not None
|
||||||
|
|
||||||
|
if self._is_connecting:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._is_connecting = True
|
||||||
|
try:
|
||||||
|
self._setup_client()
|
||||||
|
return self.base_url is not None and self.username is not None
|
||||||
|
finally:
|
||||||
|
self._is_connecting = False
|
||||||
|
self._connection_attempted = True
|
||||||
|
|
||||||
|
def _setup_client(self):
|
||||||
|
"""Setup Navidrome client configuration"""
|
||||||
|
config = config_manager.get_navidrome_config()
|
||||||
|
|
||||||
|
if not config.get('base_url'):
|
||||||
|
logger.warning("Navidrome server URL not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not config.get('username') or not config.get('password'):
|
||||||
|
logger.warning("Navidrome username/password not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.base_url = config['base_url'].rstrip('/')
|
||||||
|
self.username = config['username']
|
||||||
|
self.password = config['password']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test connection with ping
|
||||||
|
response = self._make_request('ping')
|
||||||
|
if response and response.get('status') == 'ok':
|
||||||
|
server_version = response.get('version', 'Unknown')
|
||||||
|
logger.info(f"Successfully connected to Navidrome server version: {server_version}")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to connect to Navidrome server")
|
||||||
|
self.base_url = None
|
||||||
|
self.username = None
|
||||||
|
self.password = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to Navidrome server: {e}")
|
||||||
|
self.base_url = None
|
||||||
|
self.username = None
|
||||||
|
self.password = None
|
||||||
|
|
||||||
|
def _generate_auth_params(self) -> Dict[str, str]:
|
||||||
|
"""Generate authentication parameters for Subsonic API"""
|
||||||
|
if not self.username or not self.password:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Generate random salt (at least 6 characters)
|
||||||
|
salt = secrets.token_hex(8)
|
||||||
|
# Calculate token: md5(password + salt)
|
||||||
|
token = hashlib.md5((self.password + salt).encode()).hexdigest()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'u': self.username,
|
||||||
|
't': token,
|
||||||
|
's': salt,
|
||||||
|
'v': '1.16.1', # API version
|
||||||
|
'c': 'SoulSync', # Client name
|
||||||
|
'f': 'json' # Response format
|
||||||
|
}
|
||||||
|
|
||||||
|
def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Make authenticated request to Navidrome Subsonic API"""
|
||||||
|
if not self.base_url or not self.username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{self.base_url}/rest/{endpoint}"
|
||||||
|
|
||||||
|
# Add authentication parameters
|
||||||
|
auth_params = self._generate_auth_params()
|
||||||
|
if params:
|
||||||
|
auth_params.update(params)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params=auth_params, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check for Subsonic API errors
|
||||||
|
subsonic_response = data.get('subsonic-response', {})
|
||||||
|
if subsonic_response.get('status') == 'failed':
|
||||||
|
error = subsonic_response.get('error', {})
|
||||||
|
error_message = error.get('message', 'Unknown error')
|
||||||
|
logger.error(f"Navidrome API error: {error_message}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return subsonic_response
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Navidrome API request failed: {e}")
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse Navidrome response: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Check if connected to Navidrome server"""
|
||||||
|
if not self._connection_attempted:
|
||||||
|
if not self._is_connecting:
|
||||||
|
self.ensure_connection()
|
||||||
|
return (self.base_url is not None and
|
||||||
|
self.username is not None and
|
||||||
|
self.password is not None)
|
||||||
|
|
||||||
|
def get_all_artists(self) -> List[NavidromeArtist]:
|
||||||
|
"""Get all artists from the music library"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
logger.error("Not connected to Navidrome server")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._progress_callback:
|
||||||
|
self._progress_callback("Fetching artists from Navidrome...")
|
||||||
|
|
||||||
|
response = self._make_request('getArtists')
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if self._progress_callback:
|
||||||
|
self._progress_callback("Processing artist data...")
|
||||||
|
|
||||||
|
artists = []
|
||||||
|
indexes = response.get('artists', {}).get('index', [])
|
||||||
|
total_indexes = len(indexes)
|
||||||
|
|
||||||
|
for i, index in enumerate(indexes):
|
||||||
|
if self._progress_callback and total_indexes > 1:
|
||||||
|
progress_pct = int((i / total_indexes) * 100)
|
||||||
|
self._progress_callback(f"Processing artist index {i+1}/{total_indexes} ({progress_pct}%)")
|
||||||
|
|
||||||
|
for artist_data in index.get('artist', []):
|
||||||
|
artist = NavidromeArtist(artist_data, self)
|
||||||
|
# Cache the artist for quick lookup
|
||||||
|
self._artist_cache[artist.ratingKey] = artist
|
||||||
|
artists.append(artist)
|
||||||
|
|
||||||
|
if self._progress_callback:
|
||||||
|
self._progress_callback(f"Retrieved {len(artists)} artists from Navidrome")
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(artists)} artists from Navidrome")
|
||||||
|
return artists
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting artists from Navidrome: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_albums_for_artist(self, artist_id: str) -> List[NavidromeAlbum]:
|
||||||
|
"""Get all albums for a specific artist"""
|
||||||
|
# Check cache first
|
||||||
|
if artist_id in self._album_cache:
|
||||||
|
return self._album_cache[artist_id]
|
||||||
|
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get artist name for progress display
|
||||||
|
artist_name = "Unknown Artist"
|
||||||
|
if hasattr(self, '_artist_cache'):
|
||||||
|
for cached_artist in self._artist_cache.values():
|
||||||
|
if getattr(cached_artist, 'ratingKey', None) == artist_id:
|
||||||
|
artist_name = getattr(cached_artist, 'title', 'Unknown Artist')
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._progress_callback:
|
||||||
|
self._progress_callback(f"Fetching albums for artist {artist_name}...")
|
||||||
|
|
||||||
|
response = self._make_request('getArtist', {'id': artist_id})
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
albums = []
|
||||||
|
artist_data = response.get('artist', {})
|
||||||
|
album_list = artist_data.get('album', [])
|
||||||
|
|
||||||
|
if self._progress_callback and album_list:
|
||||||
|
self._progress_callback(f"Processing {len(album_list)} albums...")
|
||||||
|
|
||||||
|
for album_data in album_list:
|
||||||
|
albums.append(NavidromeAlbum(album_data, self))
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
self._album_cache[artist_id] = albums
|
||||||
|
|
||||||
|
return albums
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting albums for artist {artist_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_tracks_for_album(self, album_id: str) -> List[NavidromeTrack]:
|
||||||
|
"""Get all tracks for a specific album"""
|
||||||
|
# Check cache first
|
||||||
|
if album_id in self._track_cache:
|
||||||
|
return self._track_cache[album_id]
|
||||||
|
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get album name for progress display
|
||||||
|
album_name = "Unknown Album"
|
||||||
|
if hasattr(self, '_album_cache'):
|
||||||
|
for artist_albums in self._album_cache.values():
|
||||||
|
for cached_album in artist_albums:
|
||||||
|
if getattr(cached_album, 'ratingKey', None) == album_id:
|
||||||
|
album_name = getattr(cached_album, 'title', 'Unknown Album')
|
||||||
|
break
|
||||||
|
if album_name != "Unknown Album":
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._progress_callback:
|
||||||
|
self._progress_callback(f"Fetching tracks for album {album_name}...")
|
||||||
|
|
||||||
|
response = self._make_request('getAlbum', {'id': album_id})
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
album_data = response.get('album', {})
|
||||||
|
track_list = album_data.get('song', [])
|
||||||
|
|
||||||
|
if self._progress_callback and track_list:
|
||||||
|
self._progress_callback(f"Processing {len(track_list)} tracks...")
|
||||||
|
|
||||||
|
for track_data in track_list:
|
||||||
|
tracks.append(NavidromeTrack(track_data, self))
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
self._track_cache[album_id] = tracks
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting tracks for album {album_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_artist_by_id(self, artist_id: str) -> Optional[NavidromeArtist]:
|
||||||
|
"""Get a specific artist by ID"""
|
||||||
|
# Check cache first
|
||||||
|
if artist_id in self._artist_cache:
|
||||||
|
return self._artist_cache[artist_id]
|
||||||
|
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request('getArtist', {'id': artist_id})
|
||||||
|
if response and 'artist' in response:
|
||||||
|
artist = NavidromeArtist(response['artist'], self)
|
||||||
|
# Cache for future use
|
||||||
|
self._artist_cache[artist_id] = artist
|
||||||
|
return artist
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting artist {artist_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_album_by_id(self, album_id: str) -> Optional[NavidromeAlbum]:
|
||||||
|
"""Get a specific album by ID"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request('getAlbum', {'id': album_id})
|
||||||
|
if response and 'album' in response:
|
||||||
|
return NavidromeAlbum(response['album'], self)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting album {album_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_library_stats(self) -> Dict[str, int]:
|
||||||
|
"""Get library statistics"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get counts by making API calls
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# Get artist count
|
||||||
|
artists = self.get_all_artists()
|
||||||
|
stats['artists'] = len(artists)
|
||||||
|
|
||||||
|
# For albums and tracks, we'd need to iterate through all artists
|
||||||
|
# This is expensive, so let's use reasonable estimates or make separate calls
|
||||||
|
# For now, return what we can efficiently get
|
||||||
|
stats['albums'] = 0
|
||||||
|
stats['tracks'] = 0
|
||||||
|
|
||||||
|
# TODO: Implement more efficient counting if Navidrome provides bulk stats
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting library stats: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_all_playlists(self) -> List[NavidromePlaylistInfo]:
|
||||||
|
"""Get all playlists from Navidrome server"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request('getPlaylists')
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
playlists = []
|
||||||
|
playlists_data = response.get('playlists', {}).get('playlist', [])
|
||||||
|
|
||||||
|
for playlist_data in playlists_data:
|
||||||
|
playlist_info = NavidromePlaylistInfo(
|
||||||
|
id=playlist_data.get('id', ''),
|
||||||
|
title=playlist_data.get('name', 'Unknown Playlist'),
|
||||||
|
description=playlist_data.get('comment'),
|
||||||
|
duration=playlist_data.get('duration', 0) * 1000, # Convert to milliseconds
|
||||||
|
leaf_count=playlist_data.get('songCount', 0),
|
||||||
|
tracks=[] # Will be populated when needed
|
||||||
|
)
|
||||||
|
playlists.append(playlist_info)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(playlists)} playlists from Navidrome")
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting playlists from Navidrome: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_playlist_by_name(self, name: str) -> Optional[NavidromePlaylistInfo]:
|
||||||
|
"""Get a specific playlist by name"""
|
||||||
|
playlists = self.get_all_playlists()
|
||||||
|
for playlist in playlists:
|
||||||
|
if playlist.title.lower() == name.lower():
|
||||||
|
return playlist
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_playlist(self, name: str, tracks) -> bool:
|
||||||
|
"""Create a new playlist with given tracks"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert tracks to Navidrome track IDs
|
||||||
|
track_ids = []
|
||||||
|
for track in tracks:
|
||||||
|
if hasattr(track, 'ratingKey'):
|
||||||
|
track_ids.append(str(track.ratingKey))
|
||||||
|
elif hasattr(track, 'id'):
|
||||||
|
track_ids.append(str(track.id))
|
||||||
|
|
||||||
|
if not track_ids:
|
||||||
|
logger.warning(f"No valid tracks provided for playlist '{name}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Creating Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
||||||
|
|
||||||
|
# Create playlist with tracks
|
||||||
|
params = {
|
||||||
|
'name': name,
|
||||||
|
'songId': track_ids # Subsonic API accepts multiple songId parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self._make_request('createPlaylist', params)
|
||||||
|
|
||||||
|
if response and response.get('status') == 'ok':
|
||||||
|
logger.info(f"✅ Created Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create Navidrome playlist '{name}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating Navidrome playlist '{name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def copy_playlist(self, source_name: str, target_name: str) -> bool:
|
||||||
|
"""Copy a playlist to create a backup"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the source playlist
|
||||||
|
source_playlist = self.get_playlist_by_name(source_name)
|
||||||
|
if not source_playlist:
|
||||||
|
logger.error(f"Source playlist '{source_name}' not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get tracks from source playlist
|
||||||
|
source_tracks = self.get_playlist_tracks(source_playlist.id)
|
||||||
|
logger.debug(f"Retrieved {len(source_tracks) if source_tracks else 0} tracks from source playlist")
|
||||||
|
|
||||||
|
# Validate tracks
|
||||||
|
if not source_tracks:
|
||||||
|
logger.warning(f"Source playlist '{source_name}' has no tracks to copy")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete target playlist if it exists (for overwriting backup)
|
||||||
|
try:
|
||||||
|
target_playlist = self.get_playlist_by_name(target_name)
|
||||||
|
if target_playlist:
|
||||||
|
self._make_request('deletePlaylist', {'id': target_playlist.id})
|
||||||
|
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
||||||
|
except Exception:
|
||||||
|
pass # Target doesn't exist, which is fine
|
||||||
|
|
||||||
|
# Create new playlist with copied tracks
|
||||||
|
try:
|
||||||
|
success = self.create_playlist(target_name, source_tracks)
|
||||||
|
if success:
|
||||||
|
logger.info(f"✅ Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create backup playlist '{target_name}'")
|
||||||
|
return False
|
||||||
|
except Exception as create_error:
|
||||||
|
logger.error(f"Failed to create backup playlist: {create_error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_playlist_tracks(self, playlist_id: str) -> List[NavidromeTrack]:
|
||||||
|
"""Get all tracks from a specific playlist"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request('getPlaylist', {'id': playlist_id})
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
playlist_data = response.get('playlist', {})
|
||||||
|
|
||||||
|
for track_data in playlist_data.get('entry', []):
|
||||||
|
tracks.append(NavidromeTrack(track_data, self))
|
||||||
|
|
||||||
|
logger.debug(f"Retrieved {len(tracks)} tracks from playlist {playlist_id}")
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
||||||
|
"""Update an existing playlist or create it if it doesn't exist"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_playlist = self.get_playlist_by_name(playlist_name)
|
||||||
|
|
||||||
|
# Check if backup is enabled in config
|
||||||
|
from config.settings import config_manager
|
||||||
|
create_backup = config_manager.get('playlist_sync.create_backup', True)
|
||||||
|
|
||||||
|
if existing_playlist and create_backup:
|
||||||
|
backup_name = f"{playlist_name} Backup"
|
||||||
|
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
|
||||||
|
|
||||||
|
if self.copy_playlist(playlist_name, backup_name):
|
||||||
|
logger.info(f"✅ Backup created successfully")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
|
||||||
|
|
||||||
|
if existing_playlist:
|
||||||
|
# Delete existing playlist
|
||||||
|
response = self._make_request('deletePlaylist', {'id': existing_playlist.id})
|
||||||
|
if response and response.get('status') == 'ok':
|
||||||
|
logger.info(f"Deleted existing Navidrome playlist '{playlist_name}'")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not delete existing playlist '{playlist_name}', creating anyway")
|
||||||
|
|
||||||
|
# Create new playlist with tracks
|
||||||
|
return self.create_playlist(playlist_name, tracks)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating Navidrome playlist '{playlist_name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def trigger_library_scan(self, library_name: str = "Music") -> bool:
|
||||||
|
"""Trigger Navidrome library scan - Navidrome doesn't have scanning, always returns True"""
|
||||||
|
logger.info(f"🎵 Navidrome doesn't require library scans - library is always current")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
||||||
|
"""Check if Navidrome library is currently scanning - always returns False"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Metadata update methods for compatibility with metadata updater
|
||||||
|
def update_artist_genres(self, artist, genres: List[str]):
|
||||||
|
"""Update artist genres - not implemented for Navidrome"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"Genre update not implemented for Navidrome artist: {artist.title}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating genres for {artist.title}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_artist_poster(self, artist, image_data: bytes):
|
||||||
|
"""Update artist poster image - not implemented for Navidrome"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"Poster update not implemented for Navidrome artist: {artist.title}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating poster for {artist.title}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_album_poster(self, album, image_data: bytes):
|
||||||
|
"""Update album poster image - not implemented for Navidrome"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"Poster update not implemented for Navidrome album: {album.title}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating poster for album {album.title}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_artist_biography(self, artist) -> bool:
|
||||||
|
"""Update artist biography - not implemented for Navidrome"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"Biography update not implemented for Navidrome artist: {artist.title}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating biography for {artist.title}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def needs_update_by_age(self, artist, refresh_interval_days: int) -> bool:
|
||||||
|
"""Check if artist needs updating based on age threshold - simplified for Navidrome"""
|
||||||
|
try:
|
||||||
|
# For now, just return True for all artists since we don't have timestamp tracking yet
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error checking update age for {artist.title}: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_artist_ignored(self, artist) -> bool:
|
||||||
|
"""Check if artist is manually marked to be ignored - simplified for Navidrome"""
|
||||||
|
try:
|
||||||
|
# For now, no artists are ignored
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error checking ignore status for {artist.title}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_update_timestamp(self, artist) -> Optional[datetime]:
|
||||||
|
"""Parse the last update timestamp from artist summary - not implemented for Navidrome"""
|
||||||
|
try:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing timestamp for {artist.title}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_cache_stats(self):
|
||||||
|
"""Get cache statistics for debugging/logging"""
|
||||||
|
return {
|
||||||
|
'artists_cached': len(self._artist_cache),
|
||||||
|
'albums_cached': len(self._album_cache),
|
||||||
|
'tracks_cached': len(self._track_cache),
|
||||||
|
'bulk_albums_cached': len(self._album_cache), # For compatibility with Jellyfin interface
|
||||||
|
'bulk_tracks_cached': len(self._track_cache) # For compatibility with Jellyfin interface
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
"""Clear all caches to force fresh data on next request"""
|
||||||
|
self._artist_cache.clear()
|
||||||
|
self._album_cache.clear()
|
||||||
|
self._track_cache.clear()
|
||||||
|
logger.info("Navidrome client cache cleared")
|
||||||
|
|
||||||
|
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[NavidromeTrackInfo]:
|
||||||
|
"""Search for tracks using Navidrome search API"""
|
||||||
|
if not self.ensure_connection():
|
||||||
|
logger.warning("Navidrome not connected. Cannot perform search.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use Subsonic search3 API for music search
|
||||||
|
query = f"{artist} {title}".strip()
|
||||||
|
response = self._make_request('search3', {
|
||||||
|
'query': query,
|
||||||
|
'songCount': limit,
|
||||||
|
'artistCount': 0,
|
||||||
|
'albumCount': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
search_result = response.get('searchResult3', {})
|
||||||
|
|
||||||
|
for track_data in search_result.get('song', []):
|
||||||
|
track_info = NavidromeTrackInfo(
|
||||||
|
id=track_data.get('id', ''),
|
||||||
|
title=track_data.get('title', ''),
|
||||||
|
artist=track_data.get('artist', ''),
|
||||||
|
album=track_data.get('album', ''),
|
||||||
|
duration=track_data.get('duration', 0) * 1000, # Convert to milliseconds
|
||||||
|
track_number=track_data.get('track'),
|
||||||
|
year=track_data.get('year'),
|
||||||
|
rating=track_data.get('userRating')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store reference to original track for playlist creation
|
||||||
|
track_info._original_navidrome_track = NavidromeTrack(track_data, self)
|
||||||
|
tracks.append(track_info)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(tracks)} tracks for '{title}' by '{artist}'")
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching for tracks: {e}")
|
||||||
|
return []
|
||||||
23
main.py
23
main.py
|
|
@ -14,6 +14,7 @@ from utils.logging_config import setup_logging, get_logger
|
||||||
from core.spotify_client import SpotifyClient
|
from core.spotify_client import SpotifyClient
|
||||||
from core.plex_client import PlexClient
|
from core.plex_client import PlexClient
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
|
from core.navidrome_client import NavidromeClient
|
||||||
from core.soulseek_client import SoulseekClient
|
from core.soulseek_client import SoulseekClient
|
||||||
|
|
||||||
from ui.sidebar import ModernSidebar
|
from ui.sidebar import ModernSidebar
|
||||||
|
|
@ -29,11 +30,12 @@ logger = get_logger("main")
|
||||||
class ServiceStatusThread(QThread):
|
class ServiceStatusThread(QThread):
|
||||||
status_updated = pyqtSignal(str, bool)
|
status_updated = pyqtSignal(str, bool)
|
||||||
|
|
||||||
def __init__(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
|
def __init__(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.spotify_client = spotify_client
|
self.spotify_client = spotify_client
|
||||||
self.plex_client = plex_client
|
self.plex_client = plex_client
|
||||||
self.jellyfin_client = jellyfin_client
|
self.jellyfin_client = jellyfin_client
|
||||||
|
self.navidrome_client = navidrome_client
|
||||||
self.soulseek_client = soulseek_client
|
self.soulseek_client = soulseek_client
|
||||||
self.running = True
|
self.running = True
|
||||||
|
|
||||||
|
|
@ -57,6 +59,10 @@ class ServiceStatusThread(QThread):
|
||||||
# Use the JellyfinClient for status checking
|
# Use the JellyfinClient for status checking
|
||||||
jellyfin_status = self.jellyfin_client.is_connected()
|
jellyfin_status = self.jellyfin_client.is_connected()
|
||||||
self.status_updated.emit("jellyfin", jellyfin_status)
|
self.status_updated.emit("jellyfin", jellyfin_status)
|
||||||
|
elif active_server == "navidrome":
|
||||||
|
# Use the NavidromeClient for status checking
|
||||||
|
navidrome_status = self.navidrome_client.is_connected()
|
||||||
|
self.status_updated.emit("navidrome", navidrome_status)
|
||||||
|
|
||||||
# Check Soulseek connection (simplified check to avoid event loop issues)
|
# Check Soulseek connection (simplified check to avoid event loop issues)
|
||||||
soulseek_status = self.soulseek_client.is_configured()
|
soulseek_status = self.soulseek_client.is_configured()
|
||||||
|
|
@ -83,6 +89,7 @@ class MainWindow(QMainWindow):
|
||||||
self.spotify_client = SpotifyClient()
|
self.spotify_client = SpotifyClient()
|
||||||
self.plex_client = PlexClient()
|
self.plex_client = PlexClient()
|
||||||
self.jellyfin_client = JellyfinClient()
|
self.jellyfin_client = JellyfinClient()
|
||||||
|
self.navidrome_client = NavidromeClient()
|
||||||
self.soulseek_client = SoulseekClient()
|
self.soulseek_client = SoulseekClient()
|
||||||
|
|
||||||
self.status_thread = None
|
self.status_thread = None
|
||||||
|
|
@ -176,11 +183,12 @@ class MainWindow(QMainWindow):
|
||||||
self.dashboard_page = DashboardPage()
|
self.dashboard_page = DashboardPage()
|
||||||
self.downloads_page = DownloadsPage(self.soulseek_client)
|
self.downloads_page = DownloadsPage(self.soulseek_client)
|
||||||
self.sync_page = SyncPage(
|
self.sync_page = SyncPage(
|
||||||
spotify_client=self.spotify_client,
|
spotify_client=self.spotify_client,
|
||||||
plex_client=self.plex_client,
|
plex_client=self.plex_client,
|
||||||
soulseek_client=self.soulseek_client,
|
soulseek_client=self.soulseek_client,
|
||||||
downloads_page=self.downloads_page,
|
downloads_page=self.downloads_page,
|
||||||
jellyfin_client=self.jellyfin_client
|
jellyfin_client=self.jellyfin_client,
|
||||||
|
navidrome_client=self.navidrome_client
|
||||||
)
|
)
|
||||||
self.artists_page = ArtistsPage(downloads_page=self.downloads_page)
|
self.artists_page = ArtistsPage(downloads_page=self.downloads_page)
|
||||||
self.settings_page = SettingsPage()
|
self.settings_page = SettingsPage()
|
||||||
|
|
@ -192,7 +200,7 @@ class MainWindow(QMainWindow):
|
||||||
self.settings_page.set_toast_manager(self.toast_manager)
|
self.settings_page.set_toast_manager(self.toast_manager)
|
||||||
|
|
||||||
# Configure dashboard with service clients and page references
|
# Configure dashboard with service clients and page references
|
||||||
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.soulseek_client)
|
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.navidrome_client, self.soulseek_client)
|
||||||
self.dashboard_page.set_page_references(self.downloads_page, self.sync_page)
|
self.dashboard_page.set_page_references(self.downloads_page, self.sync_page)
|
||||||
self.dashboard_page.set_app_start_time(self.app_start_time)
|
self.dashboard_page.set_app_start_time(self.app_start_time)
|
||||||
self.dashboard_page.set_toast_manager(self.toast_manager)
|
self.dashboard_page.set_toast_manager(self.toast_manager)
|
||||||
|
|
@ -240,6 +248,7 @@ class MainWindow(QMainWindow):
|
||||||
self.spotify_client,
|
self.spotify_client,
|
||||||
self.plex_client,
|
self.plex_client,
|
||||||
self.jellyfin_client,
|
self.jellyfin_client,
|
||||||
|
self.navidrome_client,
|
||||||
self.soulseek_client
|
self.soulseek_client
|
||||||
)
|
)
|
||||||
self.status_thread.status_updated.connect(self.update_service_status)
|
self.status_thread.status_updated.connect(self.update_service_status)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from utils.logging_config import get_logger
|
||||||
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
||||||
from core.plex_client import PlexClient, PlexTrackInfo
|
from core.plex_client import PlexClient, PlexTrackInfo
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
|
from core.navidrome_client import NavidromeClient
|
||||||
from core.soulseek_client import SoulseekClient
|
from core.soulseek_client import SoulseekClient
|
||||||
from core.matching_engine import MusicMatchingEngine, MatchResult
|
from core.matching_engine import MusicMatchingEngine, MatchResult
|
||||||
|
|
||||||
|
|
@ -41,10 +42,11 @@ class SyncProgress:
|
||||||
failed_tracks: int = 0
|
failed_tracks: int = 0
|
||||||
|
|
||||||
class PlaylistSyncService:
|
class PlaylistSyncService:
|
||||||
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None):
|
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None, navidrome_client = None):
|
||||||
self.spotify_client = spotify_client
|
self.spotify_client = spotify_client
|
||||||
self.plex_client = plex_client
|
self.plex_client = plex_client
|
||||||
self.jellyfin_client = jellyfin_client
|
self.jellyfin_client = jellyfin_client
|
||||||
|
self.navidrome_client = navidrome_client
|
||||||
self.soulseek_client = soulseek_client
|
self.soulseek_client = soulseek_client
|
||||||
self.progress_callbacks = {} # Playlist-specific progress callbacks
|
self.progress_callbacks = {} # Playlist-specific progress callbacks
|
||||||
self.syncing_playlists = set() # Track multiple syncing playlists
|
self.syncing_playlists = set() # Track multiple syncing playlists
|
||||||
|
|
@ -56,12 +58,17 @@ class PlaylistSyncService:
|
||||||
try:
|
try:
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
|
|
||||||
if active_server == "jellyfin":
|
if active_server == "jellyfin":
|
||||||
if not self.jellyfin_client:
|
if not self.jellyfin_client:
|
||||||
logger.error("Jellyfin client not provided to sync service")
|
logger.error("Jellyfin client not provided to sync service")
|
||||||
return None, "jellyfin"
|
return None, "jellyfin"
|
||||||
return self.jellyfin_client, "jellyfin"
|
return self.jellyfin_client, "jellyfin"
|
||||||
|
elif active_server == "navidrome":
|
||||||
|
if not self.navidrome_client:
|
||||||
|
logger.error("Navidrome client not provided to sync service")
|
||||||
|
return None, "navidrome"
|
||||||
|
return self.navidrome_client, "navidrome"
|
||||||
else: # Default to Plex
|
else: # Default to Plex
|
||||||
return self.plex_client, "plex"
|
return self.plex_client, "plex"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -148,7 +155,8 @@ class PlaylistSyncService:
|
||||||
total_tracks = len(playlist.tracks)
|
total_tracks = len(playlist.tracks)
|
||||||
media_client, server_type = self._get_active_media_client()
|
media_client, server_type = self._get_active_media_client()
|
||||||
|
|
||||||
self._update_progress(playlist.name, "Matching tracks against Plex library", "", 20, 5, 2, total_tracks=total_tracks)
|
media_client, server_type = self._get_active_media_client()
|
||||||
|
self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks)
|
||||||
|
|
||||||
# Use the same robust matching approach as "Download Missing Tracks"
|
# Use the same robust matching approach as "Download Missing Tracks"
|
||||||
match_results = []
|
match_results = []
|
||||||
|
|
@ -203,7 +211,8 @@ class PlaylistSyncService:
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
return self._create_error_result(playlist.name, ["Sync cancelled"])
|
return self._create_error_result(playlist.name, ["Sync cancelled"])
|
||||||
|
|
||||||
self._update_progress(playlist.name, "Creating/updating Plex playlist", "", 80, 5, 4,
|
media_client, server_type = self._get_active_media_client()
|
||||||
|
self._update_progress(playlist.name, f"Creating/updating {server_type.title()} playlist", "", 80, 5, 4,
|
||||||
total_tracks=total_tracks,
|
total_tracks=total_tracks,
|
||||||
matched_tracks=len(matched_tracks),
|
matched_tracks=len(matched_tracks),
|
||||||
failed_tracks=len(unmatched_tracks))
|
failed_tracks=len(unmatched_tracks))
|
||||||
|
|
@ -313,6 +322,17 @@ class PlaylistSyncService:
|
||||||
actual_track = JellyfinTrackFromDB(db_track)
|
actual_track = JellyfinTrackFromDB(db_track)
|
||||||
logger.debug(f"✔️ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
logger.debug(f"✔️ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
||||||
return actual_track, confidence
|
return actual_track, confidence
|
||||||
|
elif server_type == "navidrome":
|
||||||
|
# For Navidrome, create a track object from database info (similar to Jellyfin)
|
||||||
|
class NavidromeTrackFromDB:
|
||||||
|
def __init__(self, db_track):
|
||||||
|
self.ratingKey = db_track.id
|
||||||
|
self.title = db_track.title
|
||||||
|
self.id = db_track.id
|
||||||
|
|
||||||
|
actual_track = NavidromeTrackFromDB(db_track)
|
||||||
|
logger.debug(f"✔️ Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
|
||||||
|
return actual_track, confidence
|
||||||
else:
|
else:
|
||||||
# For Plex, use the original fetchItem approach
|
# For Plex, use the original fetchItem approach
|
||||||
# Validate that the track ID is numeric (Plex requirement)
|
# Validate that the track ID is numeric (Plex requirement)
|
||||||
|
|
@ -368,11 +388,21 @@ class PlaylistSyncService:
|
||||||
logger.error(f"Error fetching Spotify playlist: {e}")
|
logger.error(f"Error fetching Spotify playlist: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_plex_tracks(self) -> List[PlexTrackInfo]:
|
async def _get_media_tracks(self) -> List:
|
||||||
|
"""Get tracks from the active media server"""
|
||||||
try:
|
try:
|
||||||
return self.plex_client.search_tracks("", limit=10000)
|
media_client, server_type = self._get_active_media_client()
|
||||||
|
if not media_client:
|
||||||
|
logger.error(f"No active media client available")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if hasattr(media_client, 'search_tracks'):
|
||||||
|
return media_client.search_tracks("", limit=10000)
|
||||||
|
else:
|
||||||
|
logger.warning(f"{server_type.title()} client doesn't support track search")
|
||||||
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching Plex tracks: {e}")
|
logger.error(f"Error fetching {server_type} tracks: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def _download_missing_tracks(self, unmatched_tracks: List[MatchResult]) -> int:
|
async def _download_missing_tracks(self, unmatched_tracks: List[MatchResult]) -> int:
|
||||||
|
|
@ -415,37 +445,41 @@ class PlaylistSyncService:
|
||||||
spotify_playlist = self._get_spotify_playlist(playlist_name)
|
spotify_playlist = self._get_spotify_playlist(playlist_name)
|
||||||
if not spotify_playlist:
|
if not spotify_playlist:
|
||||||
return {"error": f"Playlist '{playlist_name}' not found"}
|
return {"error": f"Playlist '{playlist_name}' not found"}
|
||||||
|
|
||||||
plex_tracks = self.plex_client.search_tracks("", limit=1000)
|
media_client, server_type = self._get_active_media_client()
|
||||||
|
if not media_client or not hasattr(media_client, 'search_tracks'):
|
||||||
|
return {"error": f"Active media server ({server_type}) doesn't support track search"}
|
||||||
|
|
||||||
|
media_tracks = media_client.search_tracks("", limit=1000)
|
||||||
|
|
||||||
match_results = self.matching_engine.match_playlist_tracks(
|
match_results = self.matching_engine.match_playlist_tracks(
|
||||||
spotify_playlist.tracks,
|
spotify_playlist.tracks,
|
||||||
plex_tracks
|
media_tracks
|
||||||
)
|
)
|
||||||
|
|
||||||
stats = self.matching_engine.get_match_statistics(match_results)
|
stats = self.matching_engine.get_match_statistics(match_results)
|
||||||
|
|
||||||
preview = {
|
preview = {
|
||||||
"playlist_name": playlist_name,
|
"playlist_name": playlist_name,
|
||||||
"total_tracks": len(spotify_playlist.tracks),
|
"total_tracks": len(spotify_playlist.tracks),
|
||||||
"available_in_plex": stats["matched_tracks"],
|
f"available_in_{server_type}": stats["matched_tracks"],
|
||||||
"needs_download": stats["total_tracks"] - stats["matched_tracks"],
|
"needs_download": stats["total_tracks"] - stats["matched_tracks"],
|
||||||
"match_percentage": stats["match_percentage"],
|
"match_percentage": stats["match_percentage"],
|
||||||
"confidence_breakdown": stats["confidence_distribution"],
|
"confidence_breakdown": stats["confidence_distribution"],
|
||||||
"tracks_preview": []
|
"tracks_preview": []
|
||||||
}
|
}
|
||||||
|
|
||||||
for result in match_results[:10]:
|
for result in match_results[:10]:
|
||||||
track_info = {
|
track_info = {
|
||||||
"spotify_track": f"{result.spotify_track.name} - {result.spotify_track.artists[0]}",
|
"spotify_track": f"{result.spotify_track.name} - {result.spotify_track.artists[0]}",
|
||||||
"plex_match": result.plex_track.title if result.plex_track else None,
|
f"{server_type}_match": getattr(result, 'plex_track', None).title if getattr(result, 'plex_track', None) else None,
|
||||||
"confidence": result.confidence,
|
"confidence": result.confidence,
|
||||||
"status": "available" if result.is_match else "needs_download"
|
"status": "available" if result.is_match else "needs_download"
|
||||||
}
|
}
|
||||||
preview["tracks_preview"].append(track_info)
|
preview["tracks_preview"].append(track_info)
|
||||||
|
|
||||||
return preview
|
return preview
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating sync preview: {e}")
|
logger.error(f"Error generating sync preview: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
@ -453,30 +487,34 @@ class PlaylistSyncService:
|
||||||
def get_library_comparison(self) -> Dict[str, Any]:
|
def get_library_comparison(self) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
spotify_playlists = self.spotify_client.get_user_playlists()
|
spotify_playlists = self.spotify_client.get_user_playlists()
|
||||||
plex_playlists = self.plex_client.get_all_playlists()
|
|
||||||
plex_stats = self.plex_client.get_library_stats()
|
|
||||||
|
|
||||||
spotify_track_count = sum(len(p.tracks) for p in spotify_playlists)
|
spotify_track_count = sum(len(p.tracks) for p in spotify_playlists)
|
||||||
|
|
||||||
|
media_client, server_type = self._get_active_media_client()
|
||||||
|
if not media_client:
|
||||||
|
return {"error": f"No active media client available"}
|
||||||
|
|
||||||
|
media_playlists = media_client.get_all_playlists() if hasattr(media_client, 'get_all_playlists') else []
|
||||||
|
media_stats = media_client.get_library_stats() if hasattr(media_client, 'get_library_stats') else {}
|
||||||
|
|
||||||
comparison = {
|
comparison = {
|
||||||
"spotify": {
|
"spotify": {
|
||||||
"playlists": len(spotify_playlists),
|
"playlists": len(spotify_playlists),
|
||||||
"total_tracks": spotify_track_count
|
"total_tracks": spotify_track_count
|
||||||
},
|
},
|
||||||
"plex": {
|
server_type: {
|
||||||
"playlists": len(plex_playlists),
|
"playlists": len(media_playlists),
|
||||||
"artists": plex_stats.get("artists", 0),
|
"artists": media_stats.get("artists", 0),
|
||||||
"albums": plex_stats.get("albums", 0),
|
"albums": media_stats.get("albums", 0),
|
||||||
"tracks": plex_stats.get("tracks", 0)
|
"tracks": media_stats.get("tracks", 0)
|
||||||
},
|
},
|
||||||
"sync_potential": {
|
"sync_potential": {
|
||||||
"estimated_matches": min(spotify_track_count, plex_stats.get("tracks", 0)),
|
"estimated_matches": min(spotify_track_count, media_stats.get("tracks", 0)),
|
||||||
"potential_downloads": max(0, spotify_track_count - plex_stats.get("tracks", 0))
|
"potential_downloads": max(0, spotify_track_count - media_stats.get("tracks", 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return comparison
|
return comparison
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating library comparison: {e}")
|
logger.error(f"Error generating library comparison: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
BIN
ui/assets/navidrome_icon.png
Normal file
BIN
ui/assets/navidrome_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -9,7 +9,7 @@ from utils.logging_config import get_logger
|
||||||
logger = get_logger("database_updater_widget")
|
logger = get_logger("database_updater_widget")
|
||||||
|
|
||||||
class DatabaseUpdaterWidget(QFrame):
|
class DatabaseUpdaterWidget(QFrame):
|
||||||
"""UI widget for updating SoulSync database with Plex library data"""
|
"""UI widget for updating SoulSync database with media server library data (Plex, Jellyfin, or Navidrome)"""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -37,7 +37,12 @@ class DatabaseUpdaterWidget(QFrame):
|
||||||
try:
|
try:
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
|
if active_server == "jellyfin":
|
||||||
|
server_name = "Jellyfin"
|
||||||
|
elif active_server == "navidrome":
|
||||||
|
server_name = "Navidrome"
|
||||||
|
else:
|
||||||
|
server_name = "Plex"
|
||||||
except:
|
except:
|
||||||
server_name = "Plex" # Fallback
|
server_name = "Plex" # Fallback
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1943,6 +1943,7 @@ class DashboardDataProvider(QObject):
|
||||||
'spotify': ServiceStatus('Spotify', False, datetime.now()),
|
'spotify': ServiceStatus('Spotify', False, datetime.now()),
|
||||||
'plex': ServiceStatus('Plex', False, datetime.now()),
|
'plex': ServiceStatus('Plex', False, datetime.now()),
|
||||||
'jellyfin': ServiceStatus('Jellyfin', False, datetime.now()),
|
'jellyfin': ServiceStatus('Jellyfin', False, datetime.now()),
|
||||||
|
'navidrome': ServiceStatus('Navidrome', False, datetime.now()),
|
||||||
'soulseek': ServiceStatus('Soulseek', False, datetime.now())
|
'soulseek': ServiceStatus('Soulseek', False, datetime.now())
|
||||||
}
|
}
|
||||||
self.download_stats = DownloadStats()
|
self.download_stats = DownloadStats()
|
||||||
|
|
@ -1960,11 +1961,12 @@ class DashboardDataProvider(QObject):
|
||||||
self.system_stats_timer.timeout.connect(self.update_system_stats)
|
self.system_stats_timer.timeout.connect(self.update_system_stats)
|
||||||
self.system_stats_timer.start(10000) # Update every 10 seconds
|
self.system_stats_timer.start(10000) # Update every 10 seconds
|
||||||
|
|
||||||
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
|
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client):
|
||||||
self.service_clients = {
|
self.service_clients = {
|
||||||
'spotify_client': spotify_client,
|
'spotify_client': spotify_client,
|
||||||
'plex_client': plex_client,
|
'plex_client': plex_client,
|
||||||
'jellyfin_client': jellyfin_client,
|
'jellyfin_client': jellyfin_client,
|
||||||
|
'navidrome_client': navidrome_client,
|
||||||
'soulseek_client': soulseek_client
|
'soulseek_client': soulseek_client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2099,6 +2101,7 @@ class DashboardDataProvider(QObject):
|
||||||
'spotify': 'spotify_client',
|
'spotify': 'spotify_client',
|
||||||
'plex': 'plex_client',
|
'plex': 'plex_client',
|
||||||
'jellyfin': None, # Jellyfin doesn't need a client, tests via config
|
'jellyfin': None, # Jellyfin doesn't need a client, tests via config
|
||||||
|
'navidrome': 'navidrome_client',
|
||||||
'soulseek': 'soulseek_client'
|
'soulseek': 'soulseek_client'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2712,15 +2715,16 @@ class DashboardPage(QWidget):
|
||||||
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
|
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client, downloads_page=None):
|
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client, downloads_page=None):
|
||||||
"""Called from main window to provide service client references"""
|
"""Called from main window to provide service client references"""
|
||||||
self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, soulseek_client)
|
self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client)
|
||||||
|
|
||||||
# Store service clients for wishlist modal
|
# Store service clients for wishlist modal
|
||||||
self.service_clients = {
|
self.service_clients = {
|
||||||
'spotify_client': spotify_client,
|
'spotify_client': spotify_client,
|
||||||
'plex_client': plex_client,
|
'plex_client': plex_client,
|
||||||
'jellyfin_client': jellyfin_client,
|
'jellyfin_client': jellyfin_client,
|
||||||
|
'navidrome_client': navidrome_client,
|
||||||
'soulseek_client': soulseek_client,
|
'soulseek_client': soulseek_client,
|
||||||
'downloads_page': downloads_page
|
'downloads_page': downloads_page
|
||||||
}
|
}
|
||||||
|
|
@ -3051,7 +3055,12 @@ class DashboardPage(QWidget):
|
||||||
# Create service status cards with dynamic media server
|
# Create service status cards with dynamic media server
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
server_name = "Plex" if active_server == "plex" else "Jellyfin"
|
server_name_map = {
|
||||||
|
'plex': 'Plex',
|
||||||
|
'jellyfin': 'Jellyfin',
|
||||||
|
'navidrome': 'Navidrome'
|
||||||
|
}
|
||||||
|
server_name = server_name_map.get(active_server, 'Jellyfin')
|
||||||
services = ['Spotify', server_name, 'Soulseek']
|
services = ['Spotify', server_name, 'Soulseek']
|
||||||
for service in services:
|
for service in services:
|
||||||
card = ServiceStatusCard(service)
|
card = ServiceStatusCard(service)
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,8 @@ class ServiceTestThread(QThread):
|
||||||
success, message = self._test_plex()
|
success, message = self._test_plex()
|
||||||
elif self.service_type == "jellyfin":
|
elif self.service_type == "jellyfin":
|
||||||
success, message = self._test_jellyfin()
|
success, message = self._test_jellyfin()
|
||||||
|
elif self.service_type == "navidrome":
|
||||||
|
success, message = self._test_navidrome()
|
||||||
elif self.service_type == "soulseek":
|
elif self.service_type == "soulseek":
|
||||||
success, message = self._test_soulseek()
|
success, message = self._test_soulseek()
|
||||||
else:
|
else:
|
||||||
|
|
@ -644,7 +646,72 @@ class ServiceTestThread(QThread):
|
||||||
return False, "✗ Cannot connect to Jellyfin server.\nCheck your server URL and network."
|
return False, "✗ Cannot connect to Jellyfin server.\nCheck your server URL and network."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"✗ Jellyfin test failed:\n{str(e)}"
|
return False, f"✗ Jellyfin test failed:\n{str(e)}"
|
||||||
|
|
||||||
|
def _test_navidrome(self):
|
||||||
|
"""Test Navidrome connection"""
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
base_url = self.test_config['base_url']
|
||||||
|
username = self.test_config['username']
|
||||||
|
password = self.test_config['password']
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
return False, "Please enter Navidrome server URL"
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return False, "Please enter Navidrome username"
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
return False, "Please enter Navidrome password"
|
||||||
|
|
||||||
|
# Clean URL - remove trailing slash
|
||||||
|
if base_url.endswith('/'):
|
||||||
|
base_url = base_url[:-1]
|
||||||
|
|
||||||
|
# Generate authentication parameters for Subsonic API
|
||||||
|
salt = secrets.token_hex(8)
|
||||||
|
token = hashlib.md5((password + salt).encode()).hexdigest()
|
||||||
|
|
||||||
|
# Test connection with ping endpoint
|
||||||
|
params = {
|
||||||
|
'u': username,
|
||||||
|
't': token,
|
||||||
|
's': salt,
|
||||||
|
'v': '1.16.1',
|
||||||
|
'c': 'SoulSync',
|
||||||
|
'f': 'json'
|
||||||
|
}
|
||||||
|
|
||||||
|
test_url = f"{base_url}/rest/ping"
|
||||||
|
response = requests.get(test_url, params=params, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
subsonic_response = data.get('subsonic-response', {})
|
||||||
|
|
||||||
|
if subsonic_response.get('status') == 'ok':
|
||||||
|
version = subsonic_response.get('version', 'Unknown')
|
||||||
|
message = f"✓ Navidrome connection successful!\nSubsonic API Version: {version}"
|
||||||
|
return True, message
|
||||||
|
elif subsonic_response.get('status') == 'failed':
|
||||||
|
error = subsonic_response.get('error', {})
|
||||||
|
error_message = error.get('message', 'Unknown error')
|
||||||
|
return False, f"✗ Navidrome authentication failed:\n{error_message}"
|
||||||
|
else:
|
||||||
|
return False, "✗ Unexpected response from Navidrome server"
|
||||||
|
else:
|
||||||
|
return False, f"✗ Navidrome connection failed.\nHTTP {response.status_code}: {response.text}"
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return False, "✗ Navidrome connection timeout.\nCheck your server URL."
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, "✗ Cannot connect to Navidrome server.\nCheck your server URL and network."
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"✗ Navidrome test failed:\n{str(e)}"
|
||||||
|
|
||||||
def _test_soulseek(self):
|
def _test_soulseek(self):
|
||||||
"""Test Soulseek connection"""
|
"""Test Soulseek connection"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -837,6 +904,125 @@ class JellyfinDetectionThread(QThread):
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
class NavidromeDetectionThread(QThread):
|
||||||
|
progress_updated = pyqtSignal(int, str) # progress value, current url
|
||||||
|
detection_completed = pyqtSignal(str) # found_url (empty if not found)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.cancelled = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
import requests
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
def get_network_info():
|
||||||
|
"""Get comprehensive network information with subnet detection"""
|
||||||
|
try:
|
||||||
|
# Get local IP using socket method
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
local_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
# Parse network info
|
||||||
|
network = ipaddress.ip_network(f"{local_ip}/24", strict=False)
|
||||||
|
return {
|
||||||
|
'local_ip': local_ip,
|
||||||
|
'network': network,
|
||||||
|
'subnet': str(network.network_address) + "/24"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting network info: {e}")
|
||||||
|
return {'local_ip': '127.0.0.1', 'network': None, 'subnet': '127.0.0.1/32'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test common Navidrome URLs first
|
||||||
|
common_urls = [
|
||||||
|
"http://localhost:4533",
|
||||||
|
"http://127.0.0.1:4533",
|
||||||
|
"http://navidrome:4533"
|
||||||
|
]
|
||||||
|
|
||||||
|
network_info = get_network_info()
|
||||||
|
local_ip = network_info['local_ip']
|
||||||
|
|
||||||
|
# Add local IP with common ports
|
||||||
|
common_urls.extend([
|
||||||
|
f"http://{local_ip}:4533"
|
||||||
|
])
|
||||||
|
|
||||||
|
total_hosts = len(common_urls)
|
||||||
|
current_host = 0
|
||||||
|
|
||||||
|
# Test common URLs first
|
||||||
|
for url in common_urls:
|
||||||
|
if self.cancelled:
|
||||||
|
break
|
||||||
|
|
||||||
|
current_host += 1
|
||||||
|
self.progress_updated.emit(int((current_host / total_hosts) * 100), url)
|
||||||
|
|
||||||
|
if self.test_navidrome_url(url):
|
||||||
|
self.detection_completed.emit(url)
|
||||||
|
return
|
||||||
|
|
||||||
|
# If no common URLs worked, signal not found
|
||||||
|
self.detection_completed.emit("")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Navidrome detection error: {e}")
|
||||||
|
self.detection_completed.emit("")
|
||||||
|
|
||||||
|
def test_navidrome_url(self, url, timeout=5):
|
||||||
|
"""Test if URL hosts a Navidrome server by checking for ping endpoint"""
|
||||||
|
try:
|
||||||
|
# Test Navidrome ping endpoint
|
||||||
|
ping_url = f"{url.rstrip('/')}/rest/ping"
|
||||||
|
print(f"Testing Navidrome at: {ping_url}")
|
||||||
|
|
||||||
|
response = requests.get(ping_url, params={
|
||||||
|
'u': 'test',
|
||||||
|
't': 'test',
|
||||||
|
's': 'test',
|
||||||
|
'v': '1.16.1',
|
||||||
|
'c': 'SoulSync',
|
||||||
|
'f': 'json'
|
||||||
|
}, timeout=timeout)
|
||||||
|
|
||||||
|
print(f"Response status: {response.status_code}")
|
||||||
|
|
||||||
|
# Navidrome should return status 401 or 403 for invalid credentials, not 404
|
||||||
|
if response.status_code in [200, 401, 403]:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response data: {data}")
|
||||||
|
# Check if it's a valid Subsonic API response
|
||||||
|
if 'subsonic-response' in data:
|
||||||
|
print(f"✓ Found Navidrome server at {url}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"JSON parse error: {e}")
|
||||||
|
|
||||||
|
# Also try a simple GET to the root to see if it's at least a web server
|
||||||
|
try:
|
||||||
|
root_response = requests.get(url, timeout=timeout)
|
||||||
|
if root_response.status_code == 200 and 'navidrome' in root_response.text.lower():
|
||||||
|
print(f"✓ Found Navidrome web interface at {url}")
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error testing {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
class SettingsGroup(QGroupBox):
|
class SettingsGroup(QGroupBox):
|
||||||
def __init__(self, title: str, parent=None):
|
def __init__(self, title: str, parent=None):
|
||||||
super().__init__(title, parent)
|
super().__init__(title, parent)
|
||||||
|
|
@ -1024,19 +1210,29 @@ class SettingsPage(QWidget):
|
||||||
jellyfin_config = config_manager.get_jellyfin_config()
|
jellyfin_config = config_manager.get_jellyfin_config()
|
||||||
self.jellyfin_url_input.setText(jellyfin_config.get('base_url', ''))
|
self.jellyfin_url_input.setText(jellyfin_config.get('base_url', ''))
|
||||||
self.jellyfin_api_key_input.setText(jellyfin_config.get('api_key', ''))
|
self.jellyfin_api_key_input.setText(jellyfin_config.get('api_key', ''))
|
||||||
|
|
||||||
|
# Load Navidrome config
|
||||||
|
navidrome_config = config_manager.get_navidrome_config()
|
||||||
|
self.navidrome_url_input.setText(navidrome_config.get('base_url', ''))
|
||||||
|
self.navidrome_username_input.setText(navidrome_config.get('username', ''))
|
||||||
|
self.navidrome_password_input.setText(navidrome_config.get('password', ''))
|
||||||
|
|
||||||
# Initialize server selection
|
# Initialize server selection
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
self.pending_server_change = None
|
self.pending_server_change = None
|
||||||
self.update_server_toggle_styles(active_server)
|
self.update_server_toggle_styles(active_server)
|
||||||
|
|
||||||
# Show/hide appropriate containers based on active server
|
# Show/hide appropriate containers based on active server
|
||||||
|
self.plex_container.hide()
|
||||||
|
self.jellyfin_container.hide()
|
||||||
|
self.navidrome_container.hide()
|
||||||
|
|
||||||
if active_server == 'plex':
|
if active_server == 'plex':
|
||||||
self.plex_container.show()
|
self.plex_container.show()
|
||||||
self.jellyfin_container.hide()
|
elif active_server == 'jellyfin':
|
||||||
else:
|
|
||||||
self.plex_container.hide()
|
|
||||||
self.jellyfin_container.show()
|
self.jellyfin_container.show()
|
||||||
|
elif active_server == 'navidrome':
|
||||||
|
self.navidrome_container.show()
|
||||||
|
|
||||||
# Load Soulseek config
|
# Load Soulseek config
|
||||||
soulseek_config = config_manager.get_soulseek_config()
|
soulseek_config = config_manager.get_soulseek_config()
|
||||||
|
|
@ -1111,7 +1307,12 @@ class SettingsPage(QWidget):
|
||||||
# Save Jellyfin settings
|
# Save Jellyfin settings
|
||||||
config_manager.set('jellyfin.base_url', self.jellyfin_url_input.text())
|
config_manager.set('jellyfin.base_url', self.jellyfin_url_input.text())
|
||||||
config_manager.set('jellyfin.api_key', self.jellyfin_api_key_input.text())
|
config_manager.set('jellyfin.api_key', self.jellyfin_api_key_input.text())
|
||||||
|
|
||||||
|
# Save Navidrome settings
|
||||||
|
config_manager.set('navidrome.base_url', self.navidrome_url_input.text())
|
||||||
|
config_manager.set('navidrome.username', self.navidrome_username_input.text())
|
||||||
|
config_manager.set('navidrome.password', self.navidrome_password_input.text())
|
||||||
|
|
||||||
# Save pending server change if any
|
# Save pending server change if any
|
||||||
if hasattr(self, 'pending_server_change') and self.pending_server_change:
|
if hasattr(self, 'pending_server_change') and self.pending_server_change:
|
||||||
config_manager.set_active_media_server(self.pending_server_change)
|
config_manager.set_active_media_server(self.pending_server_change)
|
||||||
|
|
@ -1259,6 +1460,13 @@ class SettingsPage(QWidget):
|
||||||
'api_key': self.jellyfin_api_key_input.text()
|
'api_key': self.jellyfin_api_key_input.text()
|
||||||
}
|
}
|
||||||
self.start_service_test('jellyfin', test_config)
|
self.start_service_test('jellyfin', test_config)
|
||||||
|
elif active_server == 'navidrome':
|
||||||
|
test_config = {
|
||||||
|
'base_url': self.navidrome_url_input.text(),
|
||||||
|
'username': self.navidrome_username_input.text(),
|
||||||
|
'password': self.navidrome_password_input.text()
|
||||||
|
}
|
||||||
|
self.start_service_test('navidrome', test_config)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unknown active server type: {active_server}")
|
logger.warning(f"Unknown active server type: {active_server}")
|
||||||
|
|
||||||
|
|
@ -2186,9 +2394,16 @@ class SettingsPage(QWidget):
|
||||||
self.jellyfin_toggle_button.setFixedHeight(40)
|
self.jellyfin_toggle_button.setFixedHeight(40)
|
||||||
self.jellyfin_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
self.jellyfin_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.jellyfin_toggle_button.clicked.connect(lambda: self.select_media_server('jellyfin'))
|
self.jellyfin_toggle_button.clicked.connect(lambda: self.select_media_server('jellyfin'))
|
||||||
|
|
||||||
|
# Navidrome toggle button
|
||||||
|
self.navidrome_toggle_button = QPushButton()
|
||||||
|
self.navidrome_toggle_button.setFixedHeight(40)
|
||||||
|
self.navidrome_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
self.navidrome_toggle_button.clicked.connect(lambda: self.select_media_server('navidrome'))
|
||||||
|
|
||||||
toggle_container.addWidget(self.plex_toggle_button)
|
toggle_container.addWidget(self.plex_toggle_button)
|
||||||
toggle_container.addWidget(self.jellyfin_toggle_button)
|
toggle_container.addWidget(self.jellyfin_toggle_button)
|
||||||
|
toggle_container.addWidget(self.navidrome_toggle_button)
|
||||||
server_selection_layout.addLayout(toggle_container)
|
server_selection_layout.addLayout(toggle_container)
|
||||||
|
|
||||||
# Restart warning (initially hidden)
|
# Restart warning (initially hidden)
|
||||||
|
|
@ -2323,7 +2538,78 @@ class SettingsPage(QWidget):
|
||||||
|
|
||||||
# Add Jellyfin frame to its container
|
# Add Jellyfin frame to its container
|
||||||
jellyfin_container_layout.addWidget(jellyfin_frame)
|
jellyfin_container_layout.addWidget(jellyfin_frame)
|
||||||
|
|
||||||
|
# Navidrome Settings Container
|
||||||
|
self.navidrome_container = QWidget()
|
||||||
|
self.navidrome_container.setStyleSheet("background: transparent;")
|
||||||
|
navidrome_container_layout = QVBoxLayout(self.navidrome_container)
|
||||||
|
navidrome_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
navidrome_container_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Navidrome settings
|
||||||
|
navidrome_frame = QFrame()
|
||||||
|
navidrome_frame.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background: #333333;
|
||||||
|
border: 1px solid #444444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
navidrome_layout = QVBoxLayout(navidrome_frame)
|
||||||
|
navidrome_layout.setSpacing(8)
|
||||||
|
|
||||||
|
navidrome_title = QLabel("Navidrome")
|
||||||
|
navidrome_title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||||||
|
navidrome_title.setStyleSheet("color: #ff6b6b;") # Navidrome red color
|
||||||
|
navidrome_layout.addWidget(navidrome_title)
|
||||||
|
|
||||||
|
# Server URL
|
||||||
|
navidrome_url_label = QLabel("Server URL:")
|
||||||
|
navidrome_url_label.setStyleSheet(self.get_label_style(11))
|
||||||
|
navidrome_layout.addWidget(navidrome_url_label)
|
||||||
|
|
||||||
|
navidrome_url_input_layout = QHBoxLayout()
|
||||||
|
self.navidrome_url_input = QLineEdit()
|
||||||
|
self.navidrome_url_input.setStyleSheet(self.get_input_style())
|
||||||
|
self.navidrome_url_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
self.form_inputs['navidrome.base_url'] = self.navidrome_url_input
|
||||||
|
|
||||||
|
navidrome_detect_btn = QPushButton("Auto-detect")
|
||||||
|
navidrome_detect_btn.setFixedSize(80, 30)
|
||||||
|
navidrome_detect_btn.clicked.connect(self.auto_detect_navidrome)
|
||||||
|
navidrome_detect_btn.setStyleSheet(self.get_test_button_style())
|
||||||
|
|
||||||
|
navidrome_url_input_layout.addWidget(self.navidrome_url_input)
|
||||||
|
navidrome_url_input_layout.addWidget(navidrome_detect_btn)
|
||||||
|
navidrome_layout.addLayout(navidrome_url_input_layout)
|
||||||
|
|
||||||
|
# Username
|
||||||
|
navidrome_username_label = QLabel("Username:")
|
||||||
|
navidrome_username_label.setStyleSheet(self.get_label_style(11))
|
||||||
|
navidrome_layout.addWidget(navidrome_username_label)
|
||||||
|
|
||||||
|
self.navidrome_username_input = QLineEdit()
|
||||||
|
self.navidrome_username_input.setStyleSheet(self.get_input_style())
|
||||||
|
self.navidrome_username_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
self.form_inputs['navidrome.username'] = self.navidrome_username_input
|
||||||
|
navidrome_layout.addWidget(self.navidrome_username_input)
|
||||||
|
|
||||||
|
# Password
|
||||||
|
navidrome_password_label = QLabel("Password:")
|
||||||
|
navidrome_password_label.setStyleSheet(self.get_label_style(11))
|
||||||
|
navidrome_layout.addWidget(navidrome_password_label)
|
||||||
|
|
||||||
|
self.navidrome_password_input = QLineEdit()
|
||||||
|
self.navidrome_password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
|
self.navidrome_password_input.setStyleSheet(self.get_input_style())
|
||||||
|
self.navidrome_password_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
self.form_inputs['navidrome.password'] = self.navidrome_password_input
|
||||||
|
navidrome_layout.addWidget(self.navidrome_password_input)
|
||||||
|
|
||||||
|
# Add Navidrome frame to its container
|
||||||
|
navidrome_container_layout.addWidget(navidrome_frame)
|
||||||
|
|
||||||
# Soulseek settings
|
# Soulseek settings
|
||||||
soulseek_frame = QFrame()
|
soulseek_frame = QFrame()
|
||||||
soulseek_frame.setStyleSheet("""
|
soulseek_frame.setStyleSheet("""
|
||||||
|
|
@ -2380,6 +2666,7 @@ class SettingsPage(QWidget):
|
||||||
api_layout.addWidget(server_selection_container)
|
api_layout.addWidget(server_selection_container)
|
||||||
api_layout.addWidget(self.plex_container)
|
api_layout.addWidget(self.plex_container)
|
||||||
api_layout.addWidget(self.jellyfin_container)
|
api_layout.addWidget(self.jellyfin_container)
|
||||||
|
api_layout.addWidget(self.navidrome_container)
|
||||||
api_layout.addWidget(soulseek_frame)
|
api_layout.addWidget(soulseek_frame)
|
||||||
|
|
||||||
# Test connections
|
# Test connections
|
||||||
|
|
@ -2721,12 +3008,16 @@ class SettingsPage(QWidget):
|
||||||
self.update_server_toggle_styles(server_type)
|
self.update_server_toggle_styles(server_type)
|
||||||
|
|
||||||
# Show/hide appropriate containers
|
# Show/hide appropriate containers
|
||||||
|
self.plex_container.hide()
|
||||||
|
self.jellyfin_container.hide()
|
||||||
|
self.navidrome_container.hide()
|
||||||
|
|
||||||
if server_type == 'plex':
|
if server_type == 'plex':
|
||||||
self.plex_container.show()
|
self.plex_container.show()
|
||||||
self.jellyfin_container.hide()
|
elif server_type == 'jellyfin':
|
||||||
else:
|
|
||||||
self.plex_container.hide()
|
|
||||||
self.jellyfin_container.show()
|
self.jellyfin_container.show()
|
||||||
|
elif server_type == 'navidrome':
|
||||||
|
self.navidrome_container.show()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error selecting media server: {e}")
|
logger.error(f"Error selecting media server: {e}")
|
||||||
|
|
@ -2796,9 +3087,21 @@ class SettingsPage(QWidget):
|
||||||
"jellyfin_icon.png",
|
"jellyfin_icon.png",
|
||||||
32
|
32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not hasattr(self, '_cached_navidrome_icon'):
|
||||||
|
self._cached_navidrome_icon = download_and_cache_logo(
|
||||||
|
"https://raw.githubusercontent.com/navidrome/navidrome/master/resources/logo-192x192.png",
|
||||||
|
"navidrome_icon.png",
|
||||||
|
32
|
||||||
|
)
|
||||||
|
# Fallback to a simple text-based icon if download fails
|
||||||
|
if self._cached_navidrome_icon.isNull():
|
||||||
|
logger.warning("Navidrome icon download failed, creating fallback icon")
|
||||||
|
self._cached_navidrome_icon = self._create_fallback_icon("N", "#ff6b6b")
|
||||||
|
|
||||||
plex_icon = self._cached_plex_icon
|
plex_icon = self._cached_plex_icon
|
||||||
jellyfin_icon = self._cached_jellyfin_icon
|
jellyfin_icon = self._cached_jellyfin_icon
|
||||||
|
navidrome_icon = self._cached_navidrome_icon
|
||||||
|
|
||||||
# Active button styles with appropriate colors
|
# Active button styles with appropriate colors
|
||||||
active_plex_style = """
|
active_plex_style = """
|
||||||
|
|
@ -2830,6 +3133,21 @@ class SettingsPage(QWidget):
|
||||||
stop:1 rgba(150, 82, 175, 1.0));
|
stop:1 rgba(150, 82, 175, 1.0));
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
active_navidrome_style = """
|
||||||
|
QPushButton {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||||
|
stop:0 rgba(255, 107, 107, 0.8),
|
||||||
|
stop:1 rgba(235, 87, 87, 0.9));
|
||||||
|
border: 2px solid rgba(255, 107, 107, 1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||||
|
stop:0 rgba(255, 107, 107, 0.9),
|
||||||
|
stop:1 rgba(235, 87, 87, 1.0));
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
# Inactive button style
|
# Inactive button style
|
||||||
inactive_style = """
|
inactive_style = """
|
||||||
|
|
@ -2844,19 +3162,64 @@ class SettingsPage(QWidget):
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Set icons and styles
|
# Set icons, text, and styles
|
||||||
self.plex_toggle_button.setIcon(plex_icon)
|
self.plex_toggle_button.setIcon(plex_icon)
|
||||||
self.plex_toggle_button.setIconSize(QSize(28, 28))
|
self.plex_toggle_button.setIconSize(QSize(28, 28))
|
||||||
|
self.plex_toggle_button.setText("Plex")
|
||||||
|
|
||||||
self.jellyfin_toggle_button.setIcon(jellyfin_icon)
|
self.jellyfin_toggle_button.setIcon(jellyfin_icon)
|
||||||
self.jellyfin_toggle_button.setIconSize(QSize(28, 28))
|
self.jellyfin_toggle_button.setIconSize(QSize(28, 28))
|
||||||
|
self.jellyfin_toggle_button.setText("Jellyfin")
|
||||||
|
|
||||||
|
self.navidrome_toggle_button.setIcon(navidrome_icon)
|
||||||
|
self.navidrome_toggle_button.setIconSize(QSize(28, 28))
|
||||||
|
self.navidrome_toggle_button.setText("Navidrome")
|
||||||
|
|
||||||
|
# Debug: Check if icons are properly loaded
|
||||||
|
if navidrome_icon.isNull():
|
||||||
|
logger.warning("Navidrome icon failed to load!")
|
||||||
|
else:
|
||||||
|
logger.info("Navidrome icon loaded successfully")
|
||||||
|
|
||||||
|
# Reset all buttons to inactive first
|
||||||
|
self.plex_toggle_button.setStyleSheet(inactive_style)
|
||||||
|
self.jellyfin_toggle_button.setStyleSheet(inactive_style)
|
||||||
|
self.navidrome_toggle_button.setStyleSheet(inactive_style)
|
||||||
|
|
||||||
|
# Set the active server button style
|
||||||
if active_server == 'plex':
|
if active_server == 'plex':
|
||||||
self.plex_toggle_button.setStyleSheet(active_plex_style)
|
self.plex_toggle_button.setStyleSheet(active_plex_style)
|
||||||
self.jellyfin_toggle_button.setStyleSheet(inactive_style)
|
elif active_server == 'jellyfin':
|
||||||
else:
|
|
||||||
self.plex_toggle_button.setStyleSheet(inactive_style)
|
|
||||||
self.jellyfin_toggle_button.setStyleSheet(active_jellyfin_style)
|
self.jellyfin_toggle_button.setStyleSheet(active_jellyfin_style)
|
||||||
|
elif active_server == 'navidrome':
|
||||||
|
self.navidrome_toggle_button.setStyleSheet(active_navidrome_style)
|
||||||
|
|
||||||
|
def _create_fallback_icon(self, text, color):
|
||||||
|
"""Create a simple text-based fallback icon"""
|
||||||
|
from PyQt6.QtGui import QPixmap, QPainter, QFont, QColor
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
# Create a 32x32 pixmap
|
||||||
|
pixmap = QPixmap(32, 32)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
# Draw background circle
|
||||||
|
painter.setBrush(QColor(color))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.drawEllipse(2, 2, 28, 28)
|
||||||
|
|
||||||
|
# Draw text
|
||||||
|
painter.setPen(QColor("white"))
|
||||||
|
font = QFont("Arial", 14, QFont.Weight.Bold)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.drawText(0, 0, 32, 32, Qt.AlignmentFlag.AlignCenter, text)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
|
|
||||||
def auto_detect_jellyfin(self):
|
def auto_detect_jellyfin(self):
|
||||||
"""Auto-detect Jellyfin server URL using background thread"""
|
"""Auto-detect Jellyfin server URL using background thread"""
|
||||||
# Don't start new detection if one is already running
|
# Don't start new detection if one is already running
|
||||||
|
|
@ -2960,7 +3323,102 @@ class SettingsPage(QWidget):
|
||||||
self.detection_thread.start()
|
self.detection_thread.start()
|
||||||
|
|
||||||
self.detection_dialog.show()
|
self.detection_dialog.show()
|
||||||
|
|
||||||
|
def auto_detect_navidrome(self):
|
||||||
|
"""Auto-detect Navidrome server URL using background thread"""
|
||||||
|
# Don't start new detection if one is already running
|
||||||
|
if self.detection_thread and self.detection_thread.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create animated loading dialog
|
||||||
|
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||||
|
from PyQt6.QtCore import QTimer, QPropertyAnimation, QRect
|
||||||
|
from PyQt6.QtGui import QPainter, QColor
|
||||||
|
|
||||||
|
self.detection_dialog = QDialog(self)
|
||||||
|
self.detection_dialog.setWindowTitle("Auto-detecting Navidrome Server")
|
||||||
|
self.detection_dialog.setModal(True)
|
||||||
|
self.detection_dialog.setFixedSize(400, 180)
|
||||||
|
self.detection_dialog.setWindowFlags(self.detection_dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
||||||
|
|
||||||
|
# Apply dark theme styling
|
||||||
|
self.detection_dialog.setStyleSheet("""
|
||||||
|
QDialog {
|
||||||
|
background-color: #282828;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
background-color: #404040;
|
||||||
|
border: 1px solid #606060;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #505050;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self.detection_dialog)
|
||||||
|
layout.setSpacing(20)
|
||||||
|
|
||||||
|
# Status text
|
||||||
|
status_label = QLabel("Scanning network for Navidrome servers...")
|
||||||
|
status_label.setWordWrap(True)
|
||||||
|
layout.addWidget(status_label)
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.cancel_detection)
|
||||||
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(cancel_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Start Navidrome detection thread
|
||||||
|
self.detection_thread = NavidromeDetectionThread()
|
||||||
|
self.detection_thread.progress_updated.connect(self.on_detection_progress, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self.detection_thread.detection_completed.connect(self.on_navidrome_detection_completed, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self.detection_thread.start()
|
||||||
|
|
||||||
|
self.detection_dialog.show()
|
||||||
|
|
||||||
|
def on_navidrome_detection_completed(self, found_url):
|
||||||
|
"""Handle Navidrome detection completion"""
|
||||||
|
# Stop animation and close dialog
|
||||||
|
if hasattr(self, 'loading_animation'):
|
||||||
|
self.loading_animation.stop()
|
||||||
|
|
||||||
|
if hasattr(self, 'detection_dialog') and self.detection_dialog:
|
||||||
|
self.detection_dialog.close()
|
||||||
|
self.detection_dialog = None
|
||||||
|
|
||||||
|
# Properly cleanup thread
|
||||||
|
if self.detection_thread:
|
||||||
|
if self.detection_thread.isRunning():
|
||||||
|
self.detection_thread.quit()
|
||||||
|
self.detection_thread.wait(1000) # Wait up to 1 second
|
||||||
|
self.detection_thread = None
|
||||||
|
|
||||||
|
if found_url:
|
||||||
|
self.navidrome_url_input.setText(found_url)
|
||||||
|
# Show success toast
|
||||||
|
from ui.components.toast_manager import ToastManager
|
||||||
|
toast_manager = ToastManager(self)
|
||||||
|
toast_manager.show_toast(f"✓ Navidrome server detected: {found_url}", "success", 4000)
|
||||||
|
else:
|
||||||
|
# Show error toast
|
||||||
|
from ui.components.toast_manager import ToastManager
|
||||||
|
toast_manager = ToastManager(self)
|
||||||
|
toast_manager.show_toast("❌ No Navidrome servers found on the network", "error", 4000)
|
||||||
|
|
||||||
def on_jellyfin_detection_completed(self, found_url):
|
def on_jellyfin_detection_completed(self, found_url):
|
||||||
"""Handle Jellyfin detection completion"""
|
"""Handle Jellyfin detection completion"""
|
||||||
# Stop animation and close dialog
|
# Stop animation and close dialog
|
||||||
|
|
|
||||||
|
|
@ -1709,7 +1709,8 @@ class PlaylistDetailsModal(QDialog):
|
||||||
self.parent_page.spotify_client,
|
self.parent_page.spotify_client,
|
||||||
self.parent_page.plex_client,
|
self.parent_page.plex_client,
|
||||||
self.parent_page.soulseek_client,
|
self.parent_page.soulseek_client,
|
||||||
getattr(self.parent_page, 'jellyfin_client', None)
|
getattr(self.parent_page, 'jellyfin_client', None),
|
||||||
|
getattr(self.parent_page, 'navidrome_client', None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start sync
|
# Start sync
|
||||||
|
|
@ -1839,7 +1840,15 @@ class PlaylistDetailsModal(QDialog):
|
||||||
|
|
||||||
def on_analysis_started(self, total_tracks):
|
def on_analysis_started(self, total_tracks):
|
||||||
"""Handle analysis started signal"""
|
"""Handle analysis started signal"""
|
||||||
print(f"Started analyzing {total_tracks} tracks against Plex library")
|
# Get server name for log message
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
server_name = active_server.title() if active_server else "Plex"
|
||||||
|
except:
|
||||||
|
server_name = "Plex"
|
||||||
|
|
||||||
|
print(f"Started analyzing {total_tracks} tracks against {server_name} library")
|
||||||
|
|
||||||
def on_track_analyzed(self, track_index, result):
|
def on_track_analyzed(self, track_index, result):
|
||||||
"""Handle individual track analysis completion"""
|
"""Handle individual track analysis completion"""
|
||||||
|
|
@ -1858,12 +1867,20 @@ class PlaylistDetailsModal(QDialog):
|
||||||
|
|
||||||
if not missing_tracks:
|
if not missing_tracks:
|
||||||
QMessageBox.information(self, "Analysis Complete",
|
QMessageBox.information(self, "Analysis Complete",
|
||||||
"All tracks already exist in Plex library!\nNo downloads needed.")
|
f"All tracks already exist in {server_name} library!\nNo downloads needed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show results to user
|
# Show results to user
|
||||||
message = f"Analysis complete!\n\n"
|
message = f"Analysis complete!\n\n"
|
||||||
message += f"Tracks already in Plex: {len(existing_tracks)}\n"
|
# Get server name for display
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
server_name = active_server.title() if active_server else "Plex"
|
||||||
|
except:
|
||||||
|
server_name = "Plex"
|
||||||
|
|
||||||
|
message += f"Tracks already in {server_name}: {len(existing_tracks)}\n"
|
||||||
message += f"Tracks to download: {len(missing_tracks)}\n\n"
|
message += f"Tracks to download: {len(missing_tracks)}\n\n"
|
||||||
message += "Ready to start downloading missing tracks?"
|
message += "Ready to start downloading missing tracks?"
|
||||||
|
|
||||||
|
|
@ -3086,11 +3103,12 @@ class SyncPage(QWidget):
|
||||||
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
|
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
|
||||||
database_updated_externally = pyqtSignal()
|
database_updated_externally = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, tidal_client=None, parent=None):
|
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, navidrome_client=None, tidal_client=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.spotify_client = spotify_client
|
self.spotify_client = spotify_client
|
||||||
self.plex_client = plex_client
|
self.plex_client = plex_client
|
||||||
self.jellyfin_client = jellyfin_client
|
self.jellyfin_client = jellyfin_client
|
||||||
|
self.navidrome_client = navidrome_client
|
||||||
self.soulseek_client = soulseek_client
|
self.soulseek_client = soulseek_client
|
||||||
self.tidal_client = tidal_client or TidalClient()
|
self.tidal_client = tidal_client or TidalClient()
|
||||||
self.downloads_page = downloads_page
|
self.downloads_page = downloads_page
|
||||||
|
|
@ -3293,7 +3311,7 @@ class SyncPage(QWidget):
|
||||||
if playlist.id in self.active_sync_workers:
|
if playlist.id in self.active_sync_workers:
|
||||||
# Already syncing
|
# Already syncing
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create sync service if not available
|
# Create sync service if not available
|
||||||
if not hasattr(self, 'sync_service'):
|
if not hasattr(self, 'sync_service'):
|
||||||
from services.sync_service import PlaylistSyncService
|
from services.sync_service import PlaylistSyncService
|
||||||
|
|
@ -3301,7 +3319,8 @@ class SyncPage(QWidget):
|
||||||
self.spotify_client,
|
self.spotify_client,
|
||||||
self.plex_client,
|
self.plex_client,
|
||||||
self.soulseek_client,
|
self.soulseek_client,
|
||||||
getattr(self, 'jellyfin_client', None)
|
getattr(self, 'jellyfin_client', None),
|
||||||
|
getattr(self, 'navidrome_client', None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create sync worker
|
# Create sync worker
|
||||||
|
|
@ -3355,7 +3374,8 @@ class SyncPage(QWidget):
|
||||||
self.spotify_client,
|
self.spotify_client,
|
||||||
self.plex_client,
|
self.plex_client,
|
||||||
self.soulseek_client,
|
self.soulseek_client,
|
||||||
getattr(self, 'jellyfin_client', None)
|
getattr(self, 'jellyfin_client', None),
|
||||||
|
getattr(self, 'navidrome_client', None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create sync worker for sequential sync
|
# Create sync worker for sequential sync
|
||||||
|
|
@ -4059,7 +4079,15 @@ class SyncPage(QWidget):
|
||||||
title_label.setStyleSheet("color: #ffffff;")
|
title_label.setStyleSheet("color: #ffffff;")
|
||||||
|
|
||||||
# Subtitle
|
# Subtitle
|
||||||
subtitle_label = QLabel("Synchronize your Spotify playlists with Plex")
|
# Get active server name for subtitle
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
server_name = active_server.title() if active_server else "Plex"
|
||||||
|
except:
|
||||||
|
server_name = "Plex"
|
||||||
|
|
||||||
|
subtitle_label = QLabel(f"Synchronize your Spotify playlists with {server_name}")
|
||||||
subtitle_label.setFont(QFont("Arial", 14))
|
subtitle_label.setFont(QFont("Arial", 14))
|
||||||
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
||||||
|
|
||||||
|
|
@ -8100,7 +8128,15 @@ class DownloadMissingTracksModal(QDialog):
|
||||||
# The modal now stays open.
|
# The modal now stays open.
|
||||||
# The process_finished signal is still emitted to unlock the main UI.
|
# The process_finished signal is still emitted to unlock the main UI.
|
||||||
self.process_finished.emit()
|
self.process_finished.emit()
|
||||||
QMessageBox.information(self, "Analysis Complete", "All tracks already exist in Plex! No downloads needed.")
|
# Get server name for message
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
server_name = active_server.title() if active_server else "Plex"
|
||||||
|
except:
|
||||||
|
server_name = "Plex"
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Analysis Complete", f"All tracks already exist in {server_name}! No downloads needed.")
|
||||||
|
|
||||||
def on_analysis_failed(self, error_message):
|
def on_analysis_failed(self, error_message):
|
||||||
print(f"❌ Analysis failed: {error_message}")
|
print(f"❌ Analysis failed: {error_message}")
|
||||||
|
|
|
||||||
|
|
@ -1308,7 +1308,12 @@ class ModernSidebar(QWidget):
|
||||||
# Dynamic media server status - determine which server is active
|
# Dynamic media server status - determine which server is active
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
server_name = "Plex" if active_server == "plex" else "Jellyfin"
|
server_name_map = {
|
||||||
|
'plex': 'Plex',
|
||||||
|
'jellyfin': 'Jellyfin',
|
||||||
|
'navidrome': 'Navidrome'
|
||||||
|
}
|
||||||
|
server_name = server_name_map.get(active_server, 'Jellyfin')
|
||||||
self.media_server_status = StatusIndicator(server_name)
|
self.media_server_status = StatusIndicator(server_name)
|
||||||
|
|
||||||
self.soulseek_status = StatusIndicator("Soulseek")
|
self.soulseek_status = StatusIndicator("Soulseek")
|
||||||
|
|
@ -1333,6 +1338,7 @@ class ModernSidebar(QWidget):
|
||||||
"spotify": self.spotify_status,
|
"spotify": self.spotify_status,
|
||||||
"plex": self.media_server_status,
|
"plex": self.media_server_status,
|
||||||
"jellyfin": self.media_server_status,
|
"jellyfin": self.media_server_status,
|
||||||
|
"navidrome": self.media_server_status,
|
||||||
"soulseek": self.soulseek_status
|
"soulseek": self.soulseek_status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1341,7 +1347,12 @@ class ModernSidebar(QWidget):
|
||||||
|
|
||||||
def update_media_server_name(self, server_type: str):
|
def update_media_server_name(self, server_type: str):
|
||||||
"""Update the media server status indicator name"""
|
"""Update the media server status indicator name"""
|
||||||
server_name = "Plex" if server_type == "plex" else "Jellyfin"
|
server_name_map = {
|
||||||
|
'plex': 'Plex',
|
||||||
|
'jellyfin': 'Jellyfin',
|
||||||
|
'navidrome': 'Navidrome'
|
||||||
|
}
|
||||||
|
server_name = server_name_map.get(server_type, 'Jellyfin')
|
||||||
if hasattr(self, 'media_server_status'):
|
if hasattr(self, 'media_server_status'):
|
||||||
self.media_server_status.update_name(server_name)
|
self.media_server_status.update_name(server_name)
|
||||||
|
|
||||||
|
|
|
||||||
108
web_server.py
108
web_server.py
|
|
@ -23,6 +23,7 @@ from config.settings import config_manager
|
||||||
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
||||||
from core.plex_client import PlexClient
|
from core.plex_client import PlexClient
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
|
from core.navidrome_client import NavidromeClient
|
||||||
from core.soulseek_client import SoulseekClient
|
from core.soulseek_client import SoulseekClient
|
||||||
from core.tidal_client import TidalClient # Added import for Tidal
|
from core.tidal_client import TidalClient # Added import for Tidal
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
|
@ -95,14 +96,15 @@ try:
|
||||||
spotify_client = SpotifyClient()
|
spotify_client = SpotifyClient()
|
||||||
plex_client = PlexClient()
|
plex_client = PlexClient()
|
||||||
jellyfin_client = JellyfinClient()
|
jellyfin_client = JellyfinClient()
|
||||||
|
navidrome_client = NavidromeClient()
|
||||||
soulseek_client = SoulseekClient()
|
soulseek_client = SoulseekClient()
|
||||||
tidal_client = TidalClient()
|
tidal_client = TidalClient()
|
||||||
matching_engine = MusicMatchingEngine()
|
matching_engine = MusicMatchingEngine()
|
||||||
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client)
|
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, navidrome_client)
|
||||||
print("✅ Core service clients initialized.")
|
print("✅ Core service clients initialized.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
||||||
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = matching_engine = sync_service = None
|
spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = None
|
||||||
|
|
||||||
# --- Global Streaming State Management ---
|
# --- Global Streaming State Management ---
|
||||||
# Thread-safe state tracking for streaming functionality
|
# Thread-safe state tracking for streaming functionality
|
||||||
|
|
@ -1146,6 +1148,48 @@ def run_service_test(service, test_config):
|
||||||
return True, f"Successfully connected to Jellyfin server: {server_name}"
|
return True, f"Successfully connected to Jellyfin server: {server_name}"
|
||||||
else:
|
else:
|
||||||
return False, "Could not connect to Jellyfin. Check URL and API Key."
|
return False, "Could not connect to Jellyfin. Check URL and API Key."
|
||||||
|
elif service == "navidrome":
|
||||||
|
# Test Navidrome connection using Subsonic API
|
||||||
|
base_url = test_config.get('base_url', '')
|
||||||
|
username = test_config.get('username', '')
|
||||||
|
password = test_config.get('password', '')
|
||||||
|
|
||||||
|
if not all([base_url, username, password]):
|
||||||
|
return False, "Missing Navidrome URL, username, or password."
|
||||||
|
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
# Generate salt and token for Subsonic API authentication
|
||||||
|
salt = ''.join(random.choices(string.ascii_letters + string.digits, k=6))
|
||||||
|
token = hashlib.md5((password + salt).encode()).hexdigest()
|
||||||
|
|
||||||
|
# Test ping endpoint
|
||||||
|
url = f"{base_url.rstrip('/')}/rest/ping"
|
||||||
|
response = requests.get(url, params={
|
||||||
|
'u': username,
|
||||||
|
't': token,
|
||||||
|
's': salt,
|
||||||
|
'v': '1.16.1',
|
||||||
|
'c': 'soulsync',
|
||||||
|
'f': 'json'
|
||||||
|
}, timeout=5)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('subsonic-response', {}).get('status') == 'ok':
|
||||||
|
server_version = data.get('subsonic-response', {}).get('version', 'Unknown')
|
||||||
|
return True, f"Successfully connected to Navidrome server (v{server_version})"
|
||||||
|
else:
|
||||||
|
error = data.get('subsonic-response', {}).get('error', {})
|
||||||
|
return False, f"Navidrome authentication failed: {error.get('message', 'Unknown error')}"
|
||||||
|
else:
|
||||||
|
return False, f"Could not connect to Navidrome server (HTTP {response.status_code})"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Navidrome connection error: {str(e)}"
|
||||||
elif service == "soulseek":
|
elif service == "soulseek":
|
||||||
temp_client = SoulseekClient()
|
temp_client = SoulseekClient()
|
||||||
async def check():
|
async def check():
|
||||||
|
|
@ -1274,7 +1318,39 @@ def run_detection(server_type):
|
||||||
# slskd returns 401 when not authenticated, which is still a valid response
|
# slskd returns 401 when not authenticated, which is still a valid response
|
||||||
if response.status_code in [200, 401]:
|
if response.status_code in [200, 401]:
|
||||||
return f"http://{ip}:{port}"
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_navidrome_server(ip, port=4533):
|
||||||
|
"""Test if a Navidrome server is running at the given IP and port"""
|
||||||
|
try:
|
||||||
|
# Try Navidrome's ping endpoint (part of Subsonic API)
|
||||||
|
url = f"http://{ip}:{port}/rest/ping"
|
||||||
|
response = requests.get(url, timeout=2, params={
|
||||||
|
'u': 'test', # Dummy username for ping test
|
||||||
|
'v': '1.16.1', # API version
|
||||||
|
'c': 'soulsync', # Client name
|
||||||
|
'f': 'json' # Response format
|
||||||
|
})
|
||||||
|
|
||||||
|
# Navidrome should respond even with invalid credentials for ping
|
||||||
|
if response.status_code in [200, 401, 403]:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
# Check for Subsonic/Navidrome API response structure
|
||||||
|
if 'subsonic-response' in data:
|
||||||
|
return f"http://{ip}:{port}"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also try the web interface
|
||||||
|
web_url = f"http://{ip}:{port}/"
|
||||||
|
web_response = requests.get(web_url, timeout=2)
|
||||||
|
if web_response.status_code == 200 and 'navidrome' in web_response.text.lower():
|
||||||
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
@ -1286,6 +1362,7 @@ def run_detection(server_type):
|
||||||
test_functions = {
|
test_functions = {
|
||||||
'plex': test_plex_server,
|
'plex': test_plex_server,
|
||||||
'jellyfin': test_jellyfin_server,
|
'jellyfin': test_jellyfin_server,
|
||||||
|
'navidrome': test_navidrome_server,
|
||||||
'slskd': test_slskd_server
|
'slskd': test_slskd_server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1414,6 +1491,9 @@ def get_status():
|
||||||
# Create fresh client to ensure Docker URL resolution
|
# Create fresh client to ensure Docker URL resolution
|
||||||
temp_jellyfin_client = JellyfinClient()
|
temp_jellyfin_client = JellyfinClient()
|
||||||
media_server_status = temp_jellyfin_client.is_connected()
|
media_server_status = temp_jellyfin_client.is_connected()
|
||||||
|
elif active_server == "navidrome":
|
||||||
|
# Test Navidrome connection using existing client instance (non-destructive)
|
||||||
|
media_server_status = navidrome_client.is_connected()
|
||||||
media_server_response_time = (time.time() - media_server_start) * 1000
|
media_server_response_time = (time.time() - media_server_start) * 1000
|
||||||
|
|
||||||
# Test Soulseek (just check if configured, no network test)
|
# Test Soulseek (just check if configured, no network test)
|
||||||
|
|
@ -1592,7 +1672,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||||
|
|
||||||
for service in ['spotify', 'plex', 'jellyfin', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'playlist_sync', 'tidal']:
|
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'playlist_sync', 'tidal']:
|
||||||
if service in new_settings:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -2484,11 +2564,14 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b
|
||||||
else:
|
else:
|
||||||
# Check if album exists in database with completeness info
|
# Check if album exists in database with completeness info
|
||||||
try:
|
try:
|
||||||
|
# Get active server for database checking
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
|
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
|
||||||
title=album_name,
|
title=album_name,
|
||||||
artist=artist_name,
|
artist=artist_name,
|
||||||
expected_track_count=total_tracks if total_tracks > 0 else None,
|
expected_track_count=total_tracks if total_tracks > 0 else None,
|
||||||
confidence_threshold=0.7 # Slightly lower threshold for better matching
|
confidence_threshold=0.7, # Slightly lower threshold for better matching
|
||||||
|
server_source=active_server # Check only the active server
|
||||||
)
|
)
|
||||||
except Exception as db_error:
|
except Exception as db_error:
|
||||||
print(f"⚠️ Database error for album '{album_name}': {db_error}")
|
print(f"⚠️ Database error for album '{album_name}': {db_error}")
|
||||||
|
|
@ -2575,11 +2658,14 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode:
|
||||||
elif album_type == 'ep' or total_tracks > 1:
|
elif album_type == 'ep' or total_tracks > 1:
|
||||||
# Treat EPs like albums
|
# Treat EPs like albums
|
||||||
try:
|
try:
|
||||||
|
# Get active server for database checking
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
|
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
|
||||||
title=single_name,
|
title=single_name,
|
||||||
artist=artist_name,
|
artist=artist_name,
|
||||||
expected_track_count=total_tracks,
|
expected_track_count=total_tracks,
|
||||||
confidence_threshold=0.7
|
confidence_threshold=0.7,
|
||||||
|
server_source=active_server # Check only the active server
|
||||||
)
|
)
|
||||||
except Exception as db_error:
|
except Exception as db_error:
|
||||||
print(f"⚠️ Database error for EP '{single_name}': {db_error}")
|
print(f"⚠️ Database error for EP '{single_name}': {db_error}")
|
||||||
|
|
@ -5509,6 +5595,7 @@ def _process_wishlist_automatically():
|
||||||
# ===============================
|
# ===============================
|
||||||
|
|
||||||
def _db_update_progress_callback(current_item, processed, total, percentage):
|
def _db_update_progress_callback(current_item, processed, total, percentage):
|
||||||
|
print(f"📊 [DB Progress] {current_item} - {processed}/{total} ({percentage:.1f}%)")
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
db_update_state.update({
|
db_update_state.update({
|
||||||
"current_item": current_item,
|
"current_item": current_item,
|
||||||
|
|
@ -5518,6 +5605,7 @@ def _db_update_progress_callback(current_item, processed, total, percentage):
|
||||||
})
|
})
|
||||||
|
|
||||||
def _db_update_phase_callback(phase):
|
def _db_update_phase_callback(phase):
|
||||||
|
print(f"🔄 [DB Phase] {phase}")
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
db_update_state["phase"] = phase
|
db_update_state["phase"] = phase
|
||||||
|
|
||||||
|
|
@ -5555,6 +5643,8 @@ def _run_db_update_task(full_refresh, server_type):
|
||||||
media_client = plex_client
|
media_client = plex_client
|
||||||
elif server_type == "jellyfin":
|
elif server_type == "jellyfin":
|
||||||
media_client = jellyfin_client
|
media_client = jellyfin_client
|
||||||
|
elif server_type == "navidrome":
|
||||||
|
media_client = navidrome_client
|
||||||
|
|
||||||
if not media_client:
|
if not media_client:
|
||||||
_db_update_error_callback(f"Media client for '{server_type}' not available.")
|
_db_update_error_callback(f"Media client for '{server_type}' not available.")
|
||||||
|
|
@ -5564,7 +5654,8 @@ def _run_db_update_task(full_refresh, server_type):
|
||||||
db_update_worker = DatabaseUpdateWorker(
|
db_update_worker = DatabaseUpdateWorker(
|
||||||
media_client=media_client,
|
media_client=media_client,
|
||||||
full_refresh=full_refresh,
|
full_refresh=full_refresh,
|
||||||
server_type=server_type
|
server_type=server_type,
|
||||||
|
force_sequential=True # Force sequential processing in web server mode
|
||||||
)
|
)
|
||||||
# Connect signals to callbacks (handle both Qt and headless modes)
|
# Connect signals to callbacks (handle both Qt and headless modes)
|
||||||
try:
|
try:
|
||||||
|
|
@ -5828,6 +5919,9 @@ def start_database_update():
|
||||||
def get_database_update_status():
|
def get_database_update_status():
|
||||||
"""Endpoint to poll for the current update status."""
|
"""Endpoint to poll for the current update status."""
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
|
# Debug: Log current state occasionally
|
||||||
|
if db_update_state["status"] == "running":
|
||||||
|
print(f"📊 [Status Check] {db_update_state['processed']}/{db_update_state['total']} ({db_update_state['progress']:.1f}%) - {db_update_state['phase']}")
|
||||||
return jsonify(db_update_state)
|
return jsonify(db_update_state)
|
||||||
|
|
||||||
@app.route('/api/database/update/stop', methods=['POST'])
|
@app.route('/api/database/update/stop', methods=['POST'])
|
||||||
|
|
|
||||||
|
|
@ -721,8 +721,16 @@
|
||||||
|
|
||||||
<!-- Server Toggle Buttons -->
|
<!-- Server Toggle Buttons -->
|
||||||
<div class="server-toggle-container">
|
<div class="server-toggle-container">
|
||||||
<button class="server-toggle-btn active" id="plex-toggle" onclick="toggleServer('plex')">Plex</button>
|
<button class="server-toggle-btn active" id="plex-toggle" onclick="toggleServer('plex')">
|
||||||
<button class="server-toggle-btn" id="jellyfin-toggle" onclick="toggleServer('jellyfin')">Jellyfin</button>
|
<img src="https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg" alt="Plex" class="server-logo">
|
||||||
|
</button>
|
||||||
|
<button class="server-toggle-btn" id="jellyfin-toggle" onclick="toggleServer('jellyfin')">
|
||||||
|
<img src="https://jellyfin.org/images/logo.svg" alt="Jellyfin" class="server-logo">
|
||||||
|
</button>
|
||||||
|
<button class="server-toggle-btn" id="navidrome-toggle" onclick="toggleServer('navidrome')">
|
||||||
|
<img src="https://tweakers.net/ext/i/2007323764.png" alt="Navidrome" class="server-logo">
|
||||||
|
Navidrome
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plex Settings -->
|
<!-- Plex Settings -->
|
||||||
|
|
@ -756,7 +764,27 @@
|
||||||
<button class="test-button" onclick="testConnection('jellyfin')">Test</button>
|
<button class="test-button" onclick="testConnection('jellyfin')">Test</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Navidrome Settings -->
|
||||||
|
<div class="server-config-container hidden" id="navidrome-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Navidrome Server URL:</label>
|
||||||
|
<input type="url" id="navidrome-url" placeholder="http://localhost:4533">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username:</label>
|
||||||
|
<input type="text" id="navidrome-username" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password:</label>
|
||||||
|
<input type="password" id="navidrome-password" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="detect-button" onclick="autoDetectNavidrome()">Auto-detect</button>
|
||||||
|
<button class="test-button" onclick="testConnection('navidrome')">Test</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Server Test Button -->
|
<!-- Server Test Button -->
|
||||||
<div class="server-test-section">
|
<div class="server-test-section">
|
||||||
<button class="test-button server-test-btn" onclick="testConnection('server')">Test Server</button>
|
<button class="test-button server-test-btn" onclick="testConnection('server')">Test Server</button>
|
||||||
|
|
|
||||||
|
|
@ -1372,7 +1372,12 @@ async function loadSettingsData() {
|
||||||
// Populate Jellyfin settings
|
// Populate Jellyfin settings
|
||||||
document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || '';
|
document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || '';
|
||||||
document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || '';
|
document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || '';
|
||||||
|
|
||||||
|
// Populate Navidrome settings
|
||||||
|
document.getElementById('navidrome-url').value = settings.navidrome?.base_url || '';
|
||||||
|
document.getElementById('navidrome-username').value = settings.navidrome?.username || '';
|
||||||
|
document.getElementById('navidrome-password').value = settings.navidrome?.password || '';
|
||||||
|
|
||||||
// Set active server and toggle visibility
|
// Set active server and toggle visibility
|
||||||
const activeServer = settings.active_media_server || 'plex';
|
const activeServer = settings.active_media_server || 'plex';
|
||||||
toggleServer(activeServer);
|
toggleServer(activeServer);
|
||||||
|
|
@ -1424,16 +1429,23 @@ function toggleServer(serverType) {
|
||||||
// Update toggle buttons
|
// Update toggle buttons
|
||||||
document.getElementById('plex-toggle').classList.remove('active');
|
document.getElementById('plex-toggle').classList.remove('active');
|
||||||
document.getElementById('jellyfin-toggle').classList.remove('active');
|
document.getElementById('jellyfin-toggle').classList.remove('active');
|
||||||
|
document.getElementById('navidrome-toggle').classList.remove('active');
|
||||||
document.getElementById(`${serverType}-toggle`).classList.add('active');
|
document.getElementById(`${serverType}-toggle`).classList.add('active');
|
||||||
|
|
||||||
// Show/hide server containers
|
// Show/hide server containers
|
||||||
document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex');
|
document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex');
|
||||||
document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin');
|
document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin');
|
||||||
|
document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
// Determine active server from toggle buttons
|
// Determine active server from toggle buttons
|
||||||
const activeServer = document.getElementById('plex-toggle').classList.contains('active') ? 'plex' : 'jellyfin';
|
let activeServer = 'plex';
|
||||||
|
if (document.getElementById('jellyfin-toggle').classList.contains('active')) {
|
||||||
|
activeServer = 'jellyfin';
|
||||||
|
} else if (document.getElementById('navidrome-toggle').classList.contains('active')) {
|
||||||
|
activeServer = 'navidrome';
|
||||||
|
}
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
active_media_server: activeServer,
|
active_media_server: activeServer,
|
||||||
|
|
@ -1455,6 +1467,11 @@ async function saveSettings() {
|
||||||
base_url: document.getElementById('jellyfin-url').value,
|
base_url: document.getElementById('jellyfin-url').value,
|
||||||
api_key: document.getElementById('jellyfin-api-key').value
|
api_key: document.getElementById('jellyfin-api-key').value
|
||||||
},
|
},
|
||||||
|
navidrome: {
|
||||||
|
base_url: document.getElementById('navidrome-url').value,
|
||||||
|
username: document.getElementById('navidrome-username').value,
|
||||||
|
password: document.getElementById('navidrome-password').value
|
||||||
|
},
|
||||||
soulseek: {
|
soulseek: {
|
||||||
slskd_url: document.getElementById('soulseek-url').value,
|
slskd_url: document.getElementById('soulseek-url').value,
|
||||||
api_key: document.getElementById('soulseek-api-key').value,
|
api_key: document.getElementById('soulseek-api-key').value,
|
||||||
|
|
@ -1608,6 +1625,33 @@ async function autoDetectJellyfin() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function autoDetectNavidrome() {
|
||||||
|
try {
|
||||||
|
showLoadingOverlay('Auto-detecting Navidrome server...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/detect-media-server', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ server_type: 'navidrome' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
document.getElementById('navidrome-url').value = result.found_url;
|
||||||
|
showToast(`Navidrome server detected: ${result.found_url}`, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(result.error, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error auto-detecting Navidrome:', error);
|
||||||
|
showToast('Failed to auto-detect Navidrome server', 'error');
|
||||||
|
} finally {
|
||||||
|
hideLoadingOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function autoDetectSlskd() {
|
async function autoDetectSlskd() {
|
||||||
try {
|
try {
|
||||||
showLoadingOverlay('Auto-detecting Soulseek (slskd) server...');
|
showLoadingOverlay('Auto-detecting Soulseek (slskd) server...');
|
||||||
|
|
@ -6830,6 +6874,7 @@ function stopDbStatsPolling() {
|
||||||
|
|
||||||
function stopDbUpdatePolling() {
|
function stopDbUpdatePolling() {
|
||||||
if (dbUpdateStatusInterval) {
|
if (dbUpdateStatusInterval) {
|
||||||
|
console.log('⏹️ Stopping database update polling');
|
||||||
clearInterval(dbUpdateStatusInterval);
|
clearInterval(dbUpdateStatusInterval);
|
||||||
dbUpdateStatusInterval = null;
|
dbUpdateStatusInterval = null;
|
||||||
}
|
}
|
||||||
|
|
@ -6922,8 +6967,8 @@ async function loadDashboardData() {
|
||||||
metadataButton.addEventListener('click', handleMetadataUpdateButtonClick);
|
metadataButton.addEventListener('click', handleMetadataUpdateButtonClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check active media server and hide metadata updater if Jellyfin
|
// Check active media server and hide metadata updater if not Plex
|
||||||
await checkAndHideMetadataUpdaterForJellyfin();
|
await checkAndHideMetadataUpdaterForNonPlex();
|
||||||
|
|
||||||
// Check for ongoing metadata update and restore state
|
// Check for ongoing metadata update and restore state
|
||||||
await checkAndRestoreMetadataUpdateState();
|
await checkAndRestoreMetadataUpdateState();
|
||||||
|
|
@ -7111,20 +7156,24 @@ async function checkForAutoInitiatedWishlistProcess() {
|
||||||
|
|
||||||
async function checkAndUpdateDbProgress() {
|
async function checkAndUpdateDbProgress() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/database/update/status');
|
const response = await fetch('/api/database/update/status', {
|
||||||
|
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||||
|
});
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const state = await response.json();
|
const state = await response.json();
|
||||||
|
console.debug('📊 DB Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`);
|
||||||
updateDbProgressUI(state);
|
updateDbProgressUI(state);
|
||||||
|
|
||||||
if (state.status === 'running') {
|
// Start polling only if not already polling and status is running
|
||||||
// If an update is running, start polling for progress
|
if (state.status === 'running' && !dbUpdateStatusInterval) {
|
||||||
stopDbUpdatePolling();
|
console.log('🔄 Starting database update polling (1 second interval)');
|
||||||
dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000);
|
dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not fetch DB update status:', error);
|
console.warn('Could not fetch DB update status:', error);
|
||||||
|
// Don't stop polling on network errors - keep trying
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12919,24 +12968,24 @@ function updateMetadataProgressUI(status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check active media server and hide metadata updater if Jellyfin
|
* Check active media server and hide metadata updater if not Plex
|
||||||
*/
|
*/
|
||||||
async function checkAndHideMetadataUpdaterForJellyfin() {
|
async function checkAndHideMetadataUpdaterForNonPlex() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/active-media-server');
|
const response = await fetch('/api/active-media-server');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const metadataCard = document.getElementById('metadata-updater-card');
|
const metadataCard = document.getElementById('metadata-updater-card');
|
||||||
if (metadataCard) {
|
if (metadataCard) {
|
||||||
if (data.active_server === 'jellyfin') {
|
if (data.active_server === 'plex') {
|
||||||
// Hide metadata updater for Jellyfin (same as dashboard.py behavior)
|
// Show metadata updater only for Plex
|
||||||
metadataCard.style.display = 'none';
|
|
||||||
console.log('Metadata updater hidden: Jellyfin is active server');
|
|
||||||
} else {
|
|
||||||
// Show metadata updater for Plex
|
|
||||||
metadataCard.style.display = 'block';
|
metadataCard.style.display = 'block';
|
||||||
console.log('Metadata updater shown: Plex is active server');
|
console.log('Metadata updater shown: Plex is active server');
|
||||||
|
} else {
|
||||||
|
// Hide metadata updater for Jellyfin and Navidrome (same as dashboard.py behavior)
|
||||||
|
metadataCard.style.display = 'none';
|
||||||
|
console.log(`Metadata updater hidden: ${data.active_server} is active server`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1058,6 +1058,17 @@ body {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-logo {
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-toggle-btn.active {
|
.server-toggle-btn.active {
|
||||||
|
|
@ -1072,10 +1083,18 @@ body {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-toggle-btn:hover .server-logo {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
.server-toggle-btn.active:hover {
|
.server-toggle-btn.active:hover {
|
||||||
background: rgba(29, 185, 84, 0.3);
|
background: rgba(29, 185, 84, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-toggle-btn.active .server-logo {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
.server-config-container {
|
.server-config-container {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue