From 90d99d2a66e3b726e2fe8c0f98e1c7616387be8f Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 21 Sep 2025 12:30:00 -0700 Subject: [PATCH 1/6] Update docker-compose.yml --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index a7e7ff29..a0ebf80f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,6 @@ version: '3.8' services: soulsync: - build: . image: boulderbadgedad/soulsync:latest container_name: soulsync-webui environment: From 0f64d17bf63cd6a017c2cfc627e4be74c84b7e2f Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 21 Sep 2025 14:10:17 -0700 Subject: [PATCH 2/6] add navidrome to settings --- config/config.example.json | 6 +++ config/settings.py | 17 ++++++-- web_server.py | 84 +++++++++++++++++++++++++++++++++++++- webui/index.html | 34 +++++++++++++-- webui/static/script.js | 50 +++++++++++++++++++++-- webui/static/style.css | 19 +++++++++ 6 files changed, 198 insertions(+), 12 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 5ac9d86c..8afd09b0 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -20,6 +20,12 @@ "api_key": "JELLYFIN_API_KEY", "auto_detect": true }, + "navidrome": { + "base_url": "http://localhost:4533", + "username": "NAVIDROME_USERNAME", + "password": "NAVIDROME_PASSWORD", + "auto_detect": true + }, "soulseek": { "slskd_url": "http://host.docker.internal:5030", "api_key": "SoulseekAPIKey", diff --git a/config/settings.py b/config/settings.py index 99f5854d..f9dc8aa2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -66,7 +66,10 @@ class ConfigManager: def get_jellyfin_config(self) -> Dict[str, str]: return self.get('jellyfin', {}) - + + def get_navidrome_config(self) -> Dict[str, str]: + return self.get('navidrome', {}) + def get_soulseek_config(self) -> Dict[str, str]: return self.get('soulseek', {}) @@ -83,8 +86,8 @@ class ConfigManager: return self.get('active_media_server', 'plex') def set_active_media_server(self, server: str): - """Set the active media server (plex or jellyfin)""" - if server not in ['plex', 'jellyfin']: + """Set the active media server (plex, jellyfin, or navidrome)""" + if server not in ['plex', 'jellyfin', 'navidrome']: raise ValueError(f"Invalid media server: {server}") self.set('active_media_server', server) @@ -95,6 +98,8 @@ class ConfigManager: return self.get_plex_config() elif active_server == 'jellyfin': return self.get_jellyfin_config() + elif active_server == 'navidrome': + return self.get_navidrome_config() else: return {} @@ -111,6 +116,9 @@ class ConfigManager: elif active_server == 'jellyfin': jellyfin = self.get_jellyfin_config() 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 ( bool(spotify.get('client_id')) and @@ -127,9 +135,10 @@ class ConfigManager: '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['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 return validation diff --git a/web_server.py b/web_server.py index 0fa705f8..f424a964 100644 --- a/web_server.py +++ b/web_server.py @@ -1146,6 +1146,48 @@ def run_service_test(service, test_config): return True, f"Successfully connected to Jellyfin server: {server_name}" else: 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": temp_client = SoulseekClient() async def check(): @@ -1274,7 +1316,39 @@ def run_detection(server_type): # slskd returns 401 when not authenticated, which is still a valid response if response.status_code in [200, 401]: 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: pass return None @@ -1286,6 +1360,7 @@ def run_detection(server_type): test_functions = { 'plex': test_plex_server, 'jellyfin': test_jellyfin_server, + 'navidrome': test_navidrome_server, 'slskd': test_slskd_server } @@ -1414,6 +1489,11 @@ def get_status(): # Create fresh client to ensure Docker URL resolution temp_jellyfin_client = JellyfinClient() media_server_status = temp_jellyfin_client.is_connected() + elif active_server == "navidrome": + # Test Navidrome connection + navidrome_config = config_manager.get('navidrome', {}) + success, _ = run_service_test('navidrome', navidrome_config) + media_server_status = success media_server_response_time = (time.time() - media_server_start) * 1000 # Test Soulseek (just check if configured, no network test) @@ -1592,7 +1672,7 @@ def handle_settings(): if 'active_media_server' in new_settings: 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: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) diff --git a/webui/index.html b/webui/index.html index 4e280663..00265fce 100644 --- a/webui/index.html +++ b/webui/index.html @@ -721,8 +721,16 @@
- - + + +
@@ -756,7 +764,27 @@ - + + + +
diff --git a/webui/static/script.js b/webui/static/script.js index 62e777c6..8246df0f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1372,7 +1372,12 @@ async function loadSettingsData() { // Populate Jellyfin settings document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || ''; 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 const activeServer = settings.active_media_server || 'plex'; toggleServer(activeServer); @@ -1424,16 +1429,23 @@ function toggleServer(serverType) { // Update toggle buttons document.getElementById('plex-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'); - + // Show/hide server containers document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex'); document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin'); + document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome'); } async function saveSettings() { // 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 = { active_media_server: activeServer, @@ -1455,6 +1467,11 @@ async function saveSettings() { base_url: document.getElementById('jellyfin-url').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: { slskd_url: document.getElementById('soulseek-url').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() { try { showLoadingOverlay('Auto-detecting Soulseek (slskd) server...'); diff --git a/webui/static/style.css b/webui/static/style.css index 72c9a90b..637f030f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -1058,6 +1058,17 @@ body { font-weight: 500; cursor: pointer; 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 { @@ -1072,10 +1083,18 @@ body { color: #ffffff; } +.server-toggle-btn:hover .server-logo { + opacity: 1.0; +} + .server-toggle-btn.active:hover { background: rgba(29, 185, 84, 0.3); } +.server-toggle-btn.active .server-logo { + opacity: 1.0; +} + .server-config-container { padding-top: 8px; } From fcee0194e718865c5cd04325291df5e0aca31207 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 21 Sep 2025 21:02:12 -0700 Subject: [PATCH 3/6] navidrome functionality start --- core/database_update_worker.py | 269 +++++++- core/media_scan_manager.py | 20 +- core/navidrome_client.py | 774 +++++++++++++++++++++++ main.py | 23 +- ui/components/database_updater_widget.py | 9 +- ui/pages/dashboard.py | 10 +- ui/pages/settings.py | 76 ++- web_server.py | 20 +- webui/static/script.js | 13 +- 9 files changed, 1152 insertions(+), 62 deletions(-) create mode 100644 core/navidrome_client.py diff --git a/core/database_update_worker.py b/core/database_update_worker.py index 457c17bb..ca4e3c54 100644 --- a/core/database_update_worker.py +++ b/core/database_update_worker.py @@ -53,14 +53,17 @@ class DatabaseUpdateWorker(QThread): error = pyqtSignal(str) # error_message 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__() - + + # Force sequential processing for web server mode to avoid threading issues + self.force_sequential = force_sequential + # Initialize signal callbacks for headless mode if not QT_AVAILABLE: self.callbacks = { 'progress_updated': [], - 'artist_processed': [], + 'artist_processed': [], 'finished': [], 'error': [], 'phase_changed': [] @@ -93,12 +96,15 @@ class DatabaseUpdateWorker(QThread): database_config = config_manager.get('database', {}) 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": # Reduce workers to prevent database lock issues with bulk inserts self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety if base_max_workers > 3: logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety") + elif self.server_type == "navidrome": + # Navidrome uses standard worker count like Plex + self.max_workers = base_max_workers else: # Plex uses standard worker count self.max_workers = base_max_workers @@ -128,32 +134,42 @@ class DatabaseUpdateWorker(QThread): """Stop the database update process""" self.should_stop = True - # Clear Jellyfin cache when user stops scan to free memory - if self.server_type == "jellyfin" and hasattr(self, 'media_client'): + # Clear media client cache when user stops scan to free memory + if self.server_type in ["jellyfin", "navidrome"] and hasattr(self, 'media_client'): 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() - 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: - 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): """Main worker thread execution""" try: # Initialize database self.database = get_database(self.database_path) - + if self.full_refresh: logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data") self.database.clear_server_data(self.server_type) - + # Show cache preparation phase for Jellyfin and set up progress callback if self.server_type == "jellyfin": self._emit_signal('phase_changed', "Preparing Jellyfin cache for fast processing...") # Connect Jellyfin client progress to UI if hasattr(self.media_client, 'set_progress_callback'): self.media_client.set_progress_callback(lambda msg: self._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 artists_to_process = self._get_all_artists() if not artists_to_process: @@ -173,12 +189,13 @@ class DatabaseUpdateWorker(QThread): # Phase 2: Process artists and their albums/tracks self._emit_signal('phase_changed', "Processing artists, albums, and tracks...") - + # FAST PATH: For Jellyfin track-based incremental, process new tracks directly if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'): self._process_jellyfin_new_tracks_directly(artists_to_process) else: # Standard artist processing for Plex or full refresh + 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) # Record full refresh completion for tracking purposes @@ -189,14 +206,18 @@ class DatabaseUpdateWorker(QThread): except Exception as e: logger.warning(f"Could not record full refresh completion: {e}") - # Clear Jellyfin cache after full refresh to free memory - if self.full_refresh and self.server_type == "jellyfin": + # Clear cache after full refresh to free memory + if self.full_refresh and self.server_type in ["jellyfin", "navidrome"]: 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() - 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: - 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) if not self.full_refresh and self.database: @@ -236,10 +257,12 @@ class DatabaseUpdateWorker(QThread): if not self.media_client.ensure_connection(): logger.error(f"Could not connect to {self.server_type} server") return [] - + + logger.info(f"๐ŸŽฏ _get_all_artists: Calling media_client.get_all_artists() for {self.server_type}") 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 - + except Exception as e: logger.error(f"Error getting artists from {self.server_type}: {e}") return [] @@ -288,11 +311,17 @@ class DatabaseUpdateWorker(QThread): if self.server_type == "jellyfin": if hasattr(self.media_client, 'set_progress_callback'): 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) if self.server_type == "jellyfin": 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) recent_albums = self._get_recent_albums_for_server() if not recent_albums: @@ -450,6 +479,107 @@ class DatabaseUpdateWorker(QThread): logger.error(f"Error in smart incremental update: {e}") # Fallback to empty list - user can try full refresh 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: """FAST Jellyfin incremental update using recent tracks directly (no caching needed)""" @@ -694,6 +824,8 @@ class DatabaseUpdateWorker(QThread): return self._get_recent_albums_plex() elif self.server_type == "jellyfin": return self._get_recent_albums_jellyfin() + elif self.server_type == "navidrome": + return self._get_recent_albums_navidrome() else: logger.error(f"Unknown server type: {self.server_type}") return [] @@ -782,10 +914,62 @@ class DatabaseUpdateWorker(QThread): except Exception as e: logger.error(f"Error getting recent Jellyfin albums: {e}") 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): """Process all artists and their albums/tracks using thread pool""" total_artists = len(artists) + logger.info(f"๐ŸŽฏ Processing {total_artists} artists with progress tracking") def process_single_artist(artist): """Process a single artist and return results""" @@ -806,6 +990,7 @@ class DatabaseUpdateWorker(QThread): total_artists, progress_percent ) + logger.debug(f"๐Ÿ”„ Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}") # Process the 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}") return (getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0) - # Process artists in parallel using ThreadPoolExecutor - 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): + # Process artists - use sequential processing in web server mode to avoid threading issues + if not QT_AVAILABLE or self.force_sequential: + # Sequential processing for web server mode + for i, artist in enumerate(artists): if self.should_stop: break - - result = future.result() + + result = process_single_artist(artist) 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) + 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]: """Process an artist and all their albums and tracks with optimized API usage""" diff --git a/core/media_scan_manager.py b/core/media_scan_manager.py index 8a3f5dd7..16acb7d8 100644 --- a/core/media_scan_manager.py +++ b/core/media_scan_manager.py @@ -59,7 +59,8 @@ class MediaScanManager: # Try to find the main window from top-level widgets main_window = None 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 break @@ -70,7 +71,13 @@ class MediaScanManager: return client, "jellyfin" else: 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 client = getattr(main_window, 'plex_client', None) if client and client.is_connected(): @@ -88,12 +95,17 @@ class MediaScanManager: # Headless mode - try to get clients from global instances import sys 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": client = getattr(module, 'jellyfin_client', None) if client and hasattr(client, 'is_connected') and client.is_connected(): 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) if client and hasattr(client, 'is_connected') and client.is_connected(): return client, "plex" diff --git a/core/navidrome_client.py b/core/navidrome_client.py new file mode 100644 index 00000000..aa55a67b --- /dev/null +++ b/core/navidrome_client.py @@ -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 [] \ No newline at end of file diff --git a/main.py b/main.py index 5588fc15..1ea71bc9 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ from utils.logging_config import setup_logging, get_logger from core.spotify_client import SpotifyClient from core.plex_client import PlexClient from core.jellyfin_client import JellyfinClient +from core.navidrome_client import NavidromeClient from core.soulseek_client import SoulseekClient from ui.sidebar import ModernSidebar @@ -29,11 +30,12 @@ logger = get_logger("main") class ServiceStatusThread(QThread): 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__() self.spotify_client = spotify_client self.plex_client = plex_client self.jellyfin_client = jellyfin_client + self.navidrome_client = navidrome_client self.soulseek_client = soulseek_client self.running = True @@ -57,6 +59,10 @@ class ServiceStatusThread(QThread): # Use the JellyfinClient for status checking jellyfin_status = self.jellyfin_client.is_connected() 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) soulseek_status = self.soulseek_client.is_configured() @@ -83,6 +89,7 @@ class MainWindow(QMainWindow): self.spotify_client = SpotifyClient() self.plex_client = PlexClient() self.jellyfin_client = JellyfinClient() + self.navidrome_client = NavidromeClient() self.soulseek_client = SoulseekClient() self.status_thread = None @@ -176,11 +183,12 @@ class MainWindow(QMainWindow): self.dashboard_page = DashboardPage() self.downloads_page = DownloadsPage(self.soulseek_client) self.sync_page = SyncPage( - spotify_client=self.spotify_client, - plex_client=self.plex_client, - soulseek_client=self.soulseek_client, - downloads_page=self.downloads_page, - jellyfin_client=self.jellyfin_client + spotify_client=self.spotify_client, + plex_client=self.plex_client, + soulseek_client=self.soulseek_client, + downloads_page=self.downloads_page, + jellyfin_client=self.jellyfin_client, + navidrome_client=self.navidrome_client ) self.artists_page = ArtistsPage(downloads_page=self.downloads_page) self.settings_page = SettingsPage() @@ -192,7 +200,7 @@ class MainWindow(QMainWindow): self.settings_page.set_toast_manager(self.toast_manager) # Configure dashboard with service clients and page references - self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.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_app_start_time(self.app_start_time) self.dashboard_page.set_toast_manager(self.toast_manager) @@ -240,6 +248,7 @@ class MainWindow(QMainWindow): self.spotify_client, self.plex_client, self.jellyfin_client, + self.navidrome_client, self.soulseek_client ) self.status_thread.status_updated.connect(self.update_service_status) diff --git a/ui/components/database_updater_widget.py b/ui/components/database_updater_widget.py index c6598694..63f0a51d 100644 --- a/ui/components/database_updater_widget.py +++ b/ui/components/database_updater_widget.py @@ -9,7 +9,7 @@ from utils.logging_config import get_logger logger = get_logger("database_updater_widget") 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): super().__init__(parent) @@ -37,7 +37,12 @@ class DatabaseUpdaterWidget(QFrame): try: from config.settings import config_manager 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: server_name = "Plex" # Fallback diff --git a/ui/pages/dashboard.py b/ui/pages/dashboard.py index b94f37e2..60741fc3 100644 --- a/ui/pages/dashboard.py +++ b/ui/pages/dashboard.py @@ -1960,11 +1960,12 @@ class DashboardDataProvider(QObject): self.system_stats_timer.timeout.connect(self.update_system_stats) self.system_stats_timer.start(10000) # Update every 10 seconds - def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client): + def set_service_clients(self, spotify_client, plex_client, jellyfin_client, navidrome_client, soulseek_client): self.service_clients = { 'spotify_client': spotify_client, 'plex_client': plex_client, 'jellyfin_client': jellyfin_client, + 'navidrome_client': navidrome_client, 'soulseek_client': soulseek_client } @@ -2712,15 +2713,16 @@ class DashboardPage(QWidget): self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished) return True - def set_service_clients(self, spotify_client, plex_client, 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""" - 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 self.service_clients = { 'spotify_client': spotify_client, 'plex_client': plex_client, 'jellyfin_client': jellyfin_client, + 'navidrome_client': navidrome_client, 'soulseek_client': soulseek_client, 'downloads_page': downloads_page } diff --git a/ui/pages/settings.py b/ui/pages/settings.py index 3d180bf5..a4fac5d7 100644 --- a/ui/pages/settings.py +++ b/ui/pages/settings.py @@ -454,6 +454,8 @@ class ServiceTestThread(QThread): success, message = self._test_plex() elif self.service_type == "jellyfin": success, message = self._test_jellyfin() + elif self.service_type == "navidrome": + success, message = self._test_navidrome() elif self.service_type == "soulseek": success, message = self._test_soulseek() else: @@ -644,7 +646,72 @@ class ServiceTestThread(QThread): return False, "โœ— Cannot connect to Jellyfin server.\nCheck your server URL and network." except Exception as 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): """Test Soulseek connection""" try: @@ -1259,6 +1326,13 @@ class SettingsPage(QWidget): 'api_key': self.jellyfin_api_key_input.text() } 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: logger.warning(f"Unknown active server type: {active_server}") diff --git a/web_server.py b/web_server.py index f424a964..76788a5e 100644 --- a/web_server.py +++ b/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.plex_client import PlexClient from core.jellyfin_client import JellyfinClient +from core.navidrome_client import NavidromeClient from core.soulseek_client import SoulseekClient from core.tidal_client import TidalClient # Added import for Tidal from core.matching_engine import MusicMatchingEngine @@ -95,6 +96,7 @@ try: spotify_client = SpotifyClient() plex_client = PlexClient() jellyfin_client = JellyfinClient() + navidrome_client = NavidromeClient() soulseek_client = SoulseekClient() tidal_client = TidalClient() matching_engine = MusicMatchingEngine() @@ -102,7 +104,7 @@ try: print("โœ… Core service clients initialized.") except Exception as 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 --- # Thread-safe state tracking for streaming functionality @@ -1490,10 +1492,8 @@ def get_status(): temp_jellyfin_client = JellyfinClient() media_server_status = temp_jellyfin_client.is_connected() elif active_server == "navidrome": - # Test Navidrome connection - navidrome_config = config_manager.get('navidrome', {}) - success, _ = run_service_test('navidrome', navidrome_config) - media_server_status = success + # 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 # Test Soulseek (just check if configured, no network test) @@ -5589,6 +5589,7 @@ def _process_wishlist_automatically(): # =============================== def _db_update_progress_callback(current_item, processed, total, percentage): + print(f"๐Ÿ“Š [DB Progress] {current_item} - {processed}/{total} ({percentage:.1f}%)") with db_update_lock: db_update_state.update({ "current_item": current_item, @@ -5598,6 +5599,7 @@ def _db_update_progress_callback(current_item, processed, total, percentage): }) def _db_update_phase_callback(phase): + print(f"๐Ÿ”„ [DB Phase] {phase}") with db_update_lock: db_update_state["phase"] = phase @@ -5635,6 +5637,8 @@ def _run_db_update_task(full_refresh, server_type): media_client = plex_client elif server_type == "jellyfin": media_client = jellyfin_client + elif server_type == "navidrome": + media_client = navidrome_client if not media_client: _db_update_error_callback(f"Media client for '{server_type}' not available.") @@ -5644,7 +5648,8 @@ def _run_db_update_task(full_refresh, server_type): db_update_worker = DatabaseUpdateWorker( media_client=media_client, 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) try: @@ -5908,6 +5913,9 @@ def start_database_update(): def get_database_update_status(): """Endpoint to poll for the current update status.""" 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) @app.route('/api/database/update/stop', methods=['POST']) diff --git a/webui/static/script.js b/webui/static/script.js index 8246df0f..15d9afad 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -6874,6 +6874,7 @@ function stopDbStatsPolling() { function stopDbUpdatePolling() { if (dbUpdateStatusInterval) { + console.log('โน๏ธ Stopping database update polling'); clearInterval(dbUpdateStatusInterval); dbUpdateStatusInterval = null; } @@ -7155,20 +7156,24 @@ async function checkForAutoInitiatedWishlistProcess() { async function checkAndUpdateDbProgress() { 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; const state = await response.json(); + console.debug('๐Ÿ“Š DB Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); updateDbProgressUI(state); - if (state.status === 'running') { - // If an update is running, start polling for progress - stopDbUpdatePolling(); + // Start polling only if not already polling and status is running + if (state.status === 'running' && !dbUpdateStatusInterval) { + console.log('๐Ÿ”„ Starting database update polling (1 second interval)'); dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000); } } catch (error) { console.warn('Could not fetch DB update status:', error); + // Don't stop polling on network errors - keep trying } } From 907ea21103c1442a8ce9cd43e183794243d1f334 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 21 Sep 2025 21:41:35 -0700 Subject: [PATCH 4/6] Update web_server.py --- web_server.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web_server.py b/web_server.py index 76788a5e..0dc05e1d 100644 --- a/web_server.py +++ b/web_server.py @@ -2564,11 +2564,14 @@ def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: b else: # Check if album exists in database with completeness info 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( title=album_name, artist=artist_name, 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: print(f"โš ๏ธ Database error for album '{album_name}': {db_error}") @@ -2655,11 +2658,14 @@ def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: elif album_type == 'ep' or total_tracks > 1: # Treat EPs like albums 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( title=single_name, artist=artist_name, 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: print(f"โš ๏ธ Database error for EP '{single_name}': {db_error}") From 41df3d84b7047d3e448d1a6a268a4ad3d6178e17 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 22 Sep 2025 07:49:48 -0700 Subject: [PATCH 5/6] navidrome support --- services/sync_service.py | 102 +++++++++++++++++++++++++++------------ web_server.py | 2 +- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/services/sync_service.py b/services/sync_service.py index 4899f0a4..9b9a8c87 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -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.plex_client import PlexClient, PlexTrackInfo from core.jellyfin_client import JellyfinClient +from core.navidrome_client import NavidromeClient from core.soulseek_client import SoulseekClient from core.matching_engine import MusicMatchingEngine, MatchResult @@ -41,10 +42,11 @@ class SyncProgress: failed_tracks: int = 0 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.plex_client = plex_client self.jellyfin_client = jellyfin_client + self.navidrome_client = navidrome_client self.soulseek_client = soulseek_client self.progress_callbacks = {} # Playlist-specific progress callbacks self.syncing_playlists = set() # Track multiple syncing playlists @@ -56,12 +58,17 @@ class PlaylistSyncService: try: from config.settings import config_manager active_server = config_manager.get_active_media_server() - + if active_server == "jellyfin": if not self.jellyfin_client: logger.error("Jellyfin client not provided to sync service") return None, "jellyfin" return self.jellyfin_client, "jellyfin" + 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 return self.plex_client, "plex" except Exception as e: @@ -148,7 +155,8 @@ class PlaylistSyncService: total_tracks = len(playlist.tracks) 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" match_results = [] @@ -203,7 +211,8 @@ class PlaylistSyncService: if self._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, matched_tracks=len(matched_tracks), failed_tracks=len(unmatched_tracks)) @@ -313,6 +322,17 @@ class PlaylistSyncService: actual_track = JellyfinTrackFromDB(db_track) logger.debug(f"โœ”๏ธ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})") 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: # For Plex, use the original fetchItem approach # Validate that the track ID is numeric (Plex requirement) @@ -368,11 +388,21 @@ class PlaylistSyncService: logger.error(f"Error fetching Spotify playlist: {e}") 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: - 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: - logger.error(f"Error fetching Plex tracks: {e}") + logger.error(f"Error fetching {server_type} tracks: {e}") return [] 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) if not spotify_playlist: 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( - spotify_playlist.tracks, - plex_tracks + spotify_playlist.tracks, + media_tracks ) - + stats = self.matching_engine.get_match_statistics(match_results) - + preview = { "playlist_name": playlist_name, "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"], "match_percentage": stats["match_percentage"], "confidence_breakdown": stats["confidence_distribution"], "tracks_preview": [] } - + for result in match_results[:10]: track_info = { "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, "status": "available" if result.is_match else "needs_download" } preview["tracks_preview"].append(track_info) - + return preview - + except Exception as e: logger.error(f"Error generating sync preview: {e}") return {"error": str(e)} @@ -453,30 +487,34 @@ class PlaylistSyncService: def get_library_comparison(self) -> Dict[str, Any]: try: 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) - + + 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 = { "spotify": { "playlists": len(spotify_playlists), "total_tracks": spotify_track_count }, - "plex": { - "playlists": len(plex_playlists), - "artists": plex_stats.get("artists", 0), - "albums": plex_stats.get("albums", 0), - "tracks": plex_stats.get("tracks", 0) + server_type: { + "playlists": len(media_playlists), + "artists": media_stats.get("artists", 0), + "albums": media_stats.get("albums", 0), + "tracks": media_stats.get("tracks", 0) }, "sync_potential": { - "estimated_matches": min(spotify_track_count, plex_stats.get("tracks", 0)), - "potential_downloads": max(0, 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 - media_stats.get("tracks", 0)) } } - + return comparison - + except Exception as e: logger.error(f"Error generating library comparison: {e}") return {"error": str(e)} \ No newline at end of file diff --git a/web_server.py b/web_server.py index 0dc05e1d..45020fee 100644 --- a/web_server.py +++ b/web_server.py @@ -100,7 +100,7 @@ try: soulseek_client = SoulseekClient() tidal_client = TidalClient() 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.") except Exception as e: print(f"๐Ÿ”ด FATAL: Error initializing service clients: {e}") From ba37225e1b45f7162fde9f9dc1a0f3f2959a97fc Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 22 Sep 2025 09:46:30 -0700 Subject: [PATCH 6/6] gui navidrome support --- config/config.json | 12 +- ui/assets/navidrome_icon.png | Bin 0 -> 13071 bytes ui/pages/dashboard.py | 9 +- ui/pages/settings.py | 420 +++++++++++++++++++++++++++++++++-- ui/pages/sync.py | 56 ++++- ui/sidebar.py | 15 +- webui/static/script.js | 22 +- 7 files changed, 489 insertions(+), 45 deletions(-) create mode 100644 ui/assets/navidrome_icon.png diff --git a/config/config.json b/config/config.json index 951c7d86..8afd09b0 100644 --- a/config/config.json +++ b/config/config.json @@ -20,11 +20,17 @@ "api_key": "JELLYFIN_API_KEY", "auto_detect": true }, + "navidrome": { + "base_url": "http://localhost:4533", + "username": "NAVIDROME_USERNAME", + "password": "NAVIDROME_PASSWORD", + "auto_detect": true + }, "soulseek": { - "slskd_url": "http://localhost:5030", + "slskd_url": "http://host.docker.internal:5030", "api_key": "SoulseekAPIKey", - "download_path": "./Downloads", - "transfer_path": "./Transfer" + "download_path": "/app/downloads", + "transfer_path": "/app/Transfer" }, "logging": { "path": "logs/app.log", diff --git a/ui/assets/navidrome_icon.png b/ui/assets/navidrome_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1fa2234e186b06aa955db4e764f57051cd73d43d GIT binary patch literal 13071 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 X|L