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 @@