import os import json import asyncio import requests import socket import ipaddress import subprocess import platform from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app from config.settings import config_manager from core.spotify_client import SpotifyClient from core.plex_client import PlexClient from core.jellyfin_client import JellyfinClient from core.soulseek_client import SoulseekClient from core.tidal_client import TidalClient # Added import for Tidal # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) # Correctly point to the 'webui' directory for templates and static files app = Flask( __name__, template_folder=os.path.join(base_dir, 'webui'), static_folder=os.path.join(base_dir, 'webui', 'static') ) # --- Initialize Core Application Components --- print("🚀 Initializing SoulSync services for Web UI...") try: spotify_client = SpotifyClient() plex_client = PlexClient() jellyfin_client = JellyfinClient() soulseek_client = SoulseekClient() tidal_client = TidalClient() 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 = None # --- Refactored Logic from GUI Threads --- # This logic is extracted from your QThread classes to be used directly by Flask. def run_service_test(service, test_config): """ Performs the actual connection test for a given service. This logic is adapted from your ServiceTestThread. It temporarily modifies the config, runs the test, then restores the config. """ original_config = {} try: # 1. Save original config for the specific service original_config = config_manager.get(service, {}) # 2. Temporarily set the new config for the test for key, value in test_config.items(): config_manager.set(f"{service}.{key}", value) # 3. Run the test with the temporary config if service == "spotify": temp_client = SpotifyClient() if temp_client.is_authenticated(): return True, "Spotify connection successful!" else: return False, "Spotify authentication failed. Check credentials and complete OAuth flow in browser if prompted." elif service == "tidal": temp_client = TidalClient() if temp_client.is_authenticated(): user_info = temp_client.get_user_info() username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User' return True, f"Tidal connection successful! Connected as: {username}" else: return False, "Tidal authentication failed. Please use the 'Authenticate' button and complete the flow in your browser." elif service == "plex": temp_client = PlexClient() if temp_client.is_connected(): return True, f"Successfully connected to Plex server: {temp_client.server.friendlyName}" else: return False, "Could not connect to Plex. Check URL and Token." elif service == "jellyfin": temp_client = JellyfinClient() if temp_client.is_connected(): # FIX: Check if server_info exists before accessing it. server_name = "Unknown Server" if hasattr(temp_client, 'server_info') and temp_client.server_info: server_name = temp_client.server_info.get('ServerName', 'Unknown Server') 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 == "soulseek": temp_client = SoulseekClient() async def check(): return await temp_client.check_connection() if asyncio.run(check()): return True, "Successfully connected to slskd." else: return False, "Could not connect to slskd. Check URL and API Key." return False, "Unknown service." except AttributeError as e: # This specifically catches the error you reported for Jellyfin if "'JellyfinClient' object has no attribute 'server_info'" in str(e): return False, "Connection failed. Please check your Jellyfin URL and API Key." else: return False, f"An unexpected error occurred: {e}" except Exception as e: import traceback traceback.print_exc() return False, str(e) finally: # 4. CRITICAL: Restore the original config if original_config: for key, value in original_config.items(): config_manager.set(f"{service}.{key}", value) print(f"✅ Restored original config for '{service}' after test.") def run_detection(server_type): """ Performs comprehensive network detection for a given server type (plex, jellyfin, slskd). This implements the same scanning logic as the GUI's detection threads. """ print(f"Running comprehensive detection for {server_type}...") 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() # Try to get actual subnet mask try: if platform.system() == "Windows": # Windows: Use netsh to get subnet info result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'], capture_output=True, text=True, timeout=3) # Parse output for subnet mask (simplified) subnet_mask = "255.255.255.0" # Default fallback else: # Linux/Mac: Try to parse network interfaces result = subprocess.run(['ip', 'route', 'show'], capture_output=True, text=True, timeout=3) subnet_mask = "255.255.255.0" # Default fallback except: subnet_mask = "255.255.255.0" # Default /24 # Calculate network range network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False) return str(network.network_address), str(network.netmask), local_ip, network except Exception as e: # Fallback to original method s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) local_ip = s.getsockname()[0] s.close() # Default to /24 network network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False) return str(network.network_address), "255.255.255.0", local_ip, network def test_plex_server(ip, port=32400): """Test if a Plex server is running at the given IP and port""" try: url = f"http://{ip}:{port}/web/index.html" response = requests.get(url, timeout=2, allow_redirects=True) # Check for Plex-specific indicators if response.status_code == 200: # Check if it's actually Plex if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers): return f"http://{ip}:{port}" # Also try the API endpoint api_url = f"http://{ip}:{port}/identity" api_response = requests.get(api_url, timeout=1) if api_response.status_code == 200 and 'MediaContainer' in api_response.text: return f"http://{ip}:{port}" except: pass return None def test_jellyfin_server(ip, port=8096): """Test if a Jellyfin server is running at the given IP and port""" try: # Try the system info endpoint first url = f"http://{ip}:{port}/System/Info" response = requests.get(url, timeout=2, allow_redirects=True) if response.status_code == 200: # Check if response contains Jellyfin-specific content if 'jellyfin' in response.text.lower() or 'ServerName' in response.text: return f"http://{ip}:{port}" # Also try the web interface web_url = f"http://{ip}:{port}/web/index.html" web_response = requests.get(web_url, timeout=1) if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower(): return f"http://{ip}:{port}" except: pass return None def test_slskd_server(ip, port=5030): """Test if a slskd server is running at the given IP and port""" try: # slskd specific API endpoint url = f"http://{ip}:{port}/api/v0/session" response = requests.get(url, timeout=2) # 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 try: network_addr, netmask, local_ip, network = get_network_info() # Select the appropriate test function test_functions = { 'plex': test_plex_server, 'jellyfin': test_jellyfin_server, 'slskd': test_slskd_server } test_func = test_functions.get(server_type) if not test_func: return None # Priority 1: Test localhost first print(f"Testing localhost for {server_type}...") localhost_result = test_func("localhost") if localhost_result: print(f"Found {server_type} at localhost!") return localhost_result # Priority 2: Test local IP print(f"Testing local IP {local_ip} for {server_type}...") local_result = test_func(local_ip) if local_result: print(f"Found {server_type} at {local_ip}!") return local_result # Priority 3: Test common IPs (router gateway, etc.) common_ips = [ local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway local_ip.rsplit('.', 1)[0] + '.100', # Common static IP ] print(f"Testing common IPs for {server_type}...") for ip in common_ips: print(f" Checking {ip}...") result = test_func(ip) if result: print(f"Found {server_type} at {ip}!") return result # Priority 4: Scan the network range (limited to reasonable size) network_hosts = list(network.hosts()) if len(network_hosts) > 50: # Limit scan to reasonable size for performance step = max(1, len(network_hosts) // 50) network_hosts = network_hosts[::step] print(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...") # Use ThreadPoolExecutor for concurrent scanning (limited for web context) with ThreadPoolExecutor(max_workers=5) as executor: # Submit all tasks future_to_ip = {executor.submit(test_func, str(ip)): str(ip) for ip in network_hosts} try: for future in as_completed(future_to_ip): ip = future_to_ip[future] try: result = future.result() if result: print(f"Found {server_type} at {ip}!") # Cancel all pending futures before returning for f in future_to_ip: if not f.done(): f.cancel() return result except Exception as e: print(f"Error testing {ip}: {e}") continue except Exception as e: print(f"Error in concurrent scanning: {e}") print(f"No {server_type} server found on network") return None except Exception as e: print(f"Error during {server_type} detection: {e}") return None # --- Web UI Routes --- @app.route('/') def index(): return render_template('index.html') # --- API Endpoints --- @app.route('/status') def get_status(): if not all([spotify_client, plex_client, jellyfin_client, soulseek_client, config_manager]): return jsonify({"error": "Core services not initialized."}), 500 try: active_server = config_manager.get_active_media_server() media_server_status = False if active_server == "plex": media_server_status = plex_client.is_connected() elif active_server == "jellyfin": media_server_status = jellyfin_client.is_connected() status_data = { 'spotify': spotify_client.is_authenticated(), 'media_server': media_server_status, 'soulseek': soulseek_client.is_configured(), 'active_media_server': active_server } return jsonify(status_data) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/settings', methods=['GET', 'POST']) def handle_settings(): global tidal_client # Declare that we might modify the global instance if not config_manager: return jsonify({"error": "Server configuration manager is not initialized."}), 500 if request.method == 'POST': try: new_settings = request.get_json() if not new_settings: return jsonify({"success": False, "error": "No data received."}), 400 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']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) print("✅ Settings saved successfully via Web UI.") spotify_client._setup_client() plex_client.server = None jellyfin_client.server = None soulseek_client._setup_client() # FIX: Re-instantiate the global tidal_client to pick up new settings tidal_client = TidalClient() print("✅ Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 else: # GET request try: return jsonify(config_manager.config_data) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/test-connection', methods=['POST']) def test_connection_endpoint(): data = request.get_json() service = data.get('service') if not service: return jsonify({"success": False, "error": "No service specified."}), 400 print(f"Received test connection request for: {service}") # Get the current settings from the main config manager to test with test_config = config_manager.get(service, {}) # For media servers, the service name might be 'server' if service == 'server': active_server = config_manager.get_active_media_server() test_config = config_manager.get(active_server, {}) service = active_server # use the actual server name for the test success, message = run_service_test(service, test_config) return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""}) @app.route('/api/detect-media-server', methods=['POST']) def detect_media_server_endpoint(): data = request.get_json() server_type = data.get('server_type') print(f"Received auto-detect request for: {server_type}") found_url = run_detection(server_type) if found_url: return jsonify({"success": True, "found_url": found_url}) else: return jsonify({"success": False, "error": f"No {server_type} server found on common local addresses."}) @app.route('/api/detect-soulseek', methods=['POST']) def detect_soulseek_endpoint(): print("Received auto-detect request for slskd") found_url = run_detection('slskd') if found_url: return jsonify({"success": True, "found_url": found_url}) else: return jsonify({"success": False, "error": "No slskd server found on common local addresses."}) # --- Full Tidal Authentication Flow --- @app.route('/auth/tidal') def auth_tidal(): """ Initiates the Tidal OAuth authentication flow by calling the client's authenticate method, which should handle opening the browser. This now mirrors the GUI's approach. """ # FIX: Create a fresh client instance to ensure it uses the latest settings from config.json temp_tidal_client = TidalClient() if not temp_tidal_client: return "Tidal client could not be initialized on the server.", 500 # The authenticate() method in your GUI likely opens a browser and blocks. # The web server will also block here until authentication is complete. # The user will see the URL to visit in the console where the server is running. print(" tidal_client.authenticate() to start the flow.") print("Please follow the instructions in the console to log in to Tidal.") if temp_tidal_client.authenticate(): # Re-initialize the main client instance after successful auth global tidal_client tidal_client = TidalClient() return "
You can now close this window and return to the SoulSync application.
" else: return "Please check the console output of the server for a login URL and follow the instructions.
", 400 @app.route('/tidal/callback') def tidal_callback(): """ Handles the callback from Tidal after the user authorizes the application. It receives an authorization code, exchanges it for an access token, and saves the token. """ global tidal_client # We will re-initialize the global client auth_code = request.args.get('code') if not auth_code: error = request.args.get('error', 'Unknown error') error_description = request.args.get('error_description', 'No description provided.') return f"Error: {error}
{error_description}
Please close this window and try again.
", 400 try: # Create a temporary client for the token exchange temp_tidal_client = TidalClient() success = temp_tidal_client.fetch_token_from_code(auth_code) if success: # Re-initialize the main global tidal_client instance with the new token tidal_client = TidalClient() return "You can now close this window and return to the SoulSync application.
" else: return "Could not exchange authorization code for a token. Please try again.
", 400 except Exception as e: print(f"🔴 Error during Tidal token exchange: {e}") return f"An unexpected error occurred during the authentication process: {e}
", 500 # --- Placeholder API Endpoints for Other Pages --- @app.route('/api/activity') def get_activity(): # Placeholder: returns mock activity data mock_activity = [ {"time": "1 min ago", "text": "Service status checked."}, {"time": "5 min ago", "text": "Application server started."} ] return jsonify({"activities": mock_activity}) @app.route('/api/playlists') def get_playlists(): # Placeholder: returns mock playlist data if spotify_client and spotify_client.is_authenticated(): # In a real implementation, you would call spotify_client.get_user_playlists() mock_playlists = [ {"id": "1", "name": "Chill Vibes"}, {"id": "2", "name": "Workout Mix"}, {"id": "3", "name": "Liked Songs"} ] return jsonify({"playlists": mock_playlists}) return jsonify({"playlists": [], "error": "Spotify not authenticated."}) @app.route('/api/sync', methods=['POST']) def start_sync(): # Placeholder: simulates starting a sync return jsonify({"success": True, "message": "Sync process started."}) @app.route('/api/search', methods=['POST']) def search_music(): """ Perform real Soulseek search using the actual soulseek_client. Returns progressive search results matching the GUI's SearchThread implementation. """ if not soulseek_client: return jsonify({"error": "Soulseek client not initialized"}), 500 data = request.get_json() query = data.get('query', '').strip() if not query: return jsonify({"error": "No search query provided"}), 400 print(f"🔍 Starting Soulseek search for: '{query}'") try: import asyncio # Create new event loop for this request loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # Perform the actual search using soulseek_client results = loop.run_until_complete(soulseek_client.search(query)) # Process results to match frontend expectations if isinstance(results, tuple) and len(results) == 2: tracks, albums = results else: # Fallback for backward compatibility tracks = results if isinstance(results, list) else [] albums = [] # Convert track results to JSON-serializable format tracks_json = [] for track in tracks: tracks_json.append({ "type": "track", "title": getattr(track, 'title', 'Unknown Title'), "artist": getattr(track, 'artist', 'Unknown Artist'), "album": getattr(track, 'album', 'Unknown Album'), "quality": getattr(track, 'quality', 'Unknown'), "bitrate": getattr(track, 'bitrate', None), "duration": getattr(track, 'duration', None), "filename": getattr(track, 'filename', ''), "username": getattr(track, 'username', ''), "file_size": getattr(track, 'file_size', 0), "search_result_data": { # Store the original object data for download purposes "filename": getattr(track, 'filename', ''), "username": getattr(track, 'username', ''), "file_size": getattr(track, 'file_size', 0), } }) # Convert album results to JSON-serializable format albums_json = [] for album in albums: albums_json.append({ "type": "album", "title": getattr(album, 'album_name', getattr(album, 'title', 'Unknown Album')), "artist": getattr(album, 'artist', 'Unknown Artist'), "track_count": getattr(album, 'track_count', 0), "username": getattr(album, 'username', ''), "size_mb": getattr(album, 'total_size', 0) / (1024 * 1024) if hasattr(album, 'total_size') else 0, "tracks": getattr(album, 'tracks', []), "search_result_data": { # Store the original object data for download purposes "album_name": getattr(album, 'album_name', getattr(album, 'title', '')), "artist": getattr(album, 'artist', ''), "username": getattr(album, 'username', ''), "tracks": getattr(album, 'tracks', []) } }) total_results = len(tracks_json) + len(albums_json) print(f"✅ Search completed: {len(tracks_json)} tracks, {len(albums_json)} albums ({total_results} total)") return jsonify({ "success": True, "results": { "tracks": tracks_json, "albums": albums_json, "total_tracks": len(tracks_json), "total_albums": len(albums_json), "query": query } }) finally: # Clean up the event loop try: pending = asyncio.all_tasks(loop) for task in pending: task.cancel() if pending: loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) loop.close() except Exception as e: print(f"Error cleaning up search event loop: {e}") except Exception as e: import traceback traceback.print_exc() print(f"❌ Search failed: {e}") return jsonify({"error": f"Search failed: {str(e)}"}), 500 @app.route('/api/search/cancel', methods=['POST']) def cancel_search(): """Cancel any active search operations""" # Note: In a full implementation, you would track active search operations # and cancel them here. For now, this is a placeholder. print("🛑 Search cancellation requested") return jsonify({"success": True, "message": "Search cancellation requested"}) # Global download tracking active_downloads = {} # Dict to track active downloads completed_downloads = [] # List to store completed downloads @app.route('/api/downloads/start', methods=['POST']) def start_download(): """ Start a regular download using the soulseek_client. This matches the GUI's start_download functionality. """ if not soulseek_client: return jsonify({"error": "Soulseek client not initialized"}), 500 data = request.get_json() if not data: return jsonify({"error": "No download data provided"}), 400 try: # Extract search result data search_data = data.get('search_result_data', data) filename = search_data.get('filename') username = search_data.get('username') if not filename or not username: return jsonify({"error": "Missing required download parameters (filename, username)"}), 400 print(f"⬇️ Starting download: '{filename}' from '{username}'") # Create download item for tracking download_id = f"{username}_{filename}_{len(active_downloads)}" download_item = { "id": download_id, "title": data.get('title', filename), "artist": data.get('artist', 'Unknown Artist'), "filename": filename, "username": username, "status": "queued", "progress": 0, "file_size": data.get('file_size', 0), "download_speed": 0, "eta": None, "start_time": None, "spotify_matched": False } active_downloads[download_id] = download_item # Start the actual download using asyncio import asyncio import threading def download_worker(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # This would call the actual soulseek_client.download method # For now, we'll simulate the download process result = loop.run_until_complete(simulate_download(download_item)) print(f"✅ Download completed: {download_id}") except Exception as e: print(f"❌ Download failed: {download_id} - {e}") download_item["status"] = "failed" download_item["error"] = str(e) finally: loop.close() # Start download in background thread thread = threading.Thread(target=download_worker) thread.daemon = True thread.start() return jsonify({ "success": True, "download_id": download_id, "message": f"Download started for '{filename}'" }) except Exception as e: import traceback traceback.print_exc() return jsonify({"error": f"Failed to start download: {str(e)}"}), 500 @app.route('/api/downloads/start-matched', methods=['POST']) def start_matched_download(): """ Start a download with confirmed Spotify match data. This matches the GUI's start_matched_download functionality. """ if not soulseek_client: return jsonify({"error": "Soulseek client not initialized"}), 500 data = request.get_json() if not data: return jsonify({"error": "No download data provided"}), 400 try: # Extract search result data and Spotify match data search_data = data.get('search_result_data', data) spotify_match = data.get('spotify_match', {}) filename = search_data.get('filename') username = search_data.get('username') if not filename or not username: return jsonify({"error": "Missing required download parameters"}), 400 matched_artist = spotify_match.get('artist', {}) matched_album = spotify_match.get('album', {}) print(f"⬇️🎵 Starting matched download: '{filename}' from '{username}'") print(f" 🎤 Matched Artist: {matched_artist.get('name', 'Unknown')}") print(f" 💿 Matched Album: {matched_album.get('name', 'Unknown')}") # Create download item for tracking with Spotify match info download_id = f"{username}_{filename}_{len(active_downloads)}_matched" download_item = { "id": download_id, "title": data.get('title', filename), "artist": data.get('artist', 'Unknown Artist'), "filename": filename, "username": username, "status": "queued", "progress": 0, "file_size": data.get('file_size', 0), "download_speed": 0, "eta": None, "start_time": None, "spotify_matched": True, "matched_artist": matched_artist, "matched_album": matched_album } active_downloads[download_id] = download_item # Start the actual download using asyncio import asyncio import threading def matched_download_worker(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # This would call the actual soulseek_client.download method # and then apply metadata enhancement with the Spotify match result = loop.run_until_complete(simulate_matched_download(download_item)) print(f"✅ Matched download completed: {download_id}") except Exception as e: print(f"❌ Matched download failed: {download_id} - {e}") download_item["status"] = "failed" download_item["error"] = str(e) finally: loop.close() # Start download in background thread thread = threading.Thread(target=matched_download_worker) thread.daemon = True thread.start() return jsonify({ "success": True, "download_id": download_id, "message": f"Matched download started for '{filename}'" }) except Exception as e: import traceback traceback.print_exc() return jsonify({"error": f"Failed to start matched download: {str(e)}"}), 500 @app.route('/api/downloads/status', methods=['GET']) def get_download_status(): """ Get the current status of all downloads (active and completed). This matches the GUI's download queue functionality. """ try: # Get real download status from soulseek_client if available real_downloads = [] if soulseek_client: try: import asyncio loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # This would call soulseek_client.get_all_downloads() # For now, we'll use our tracked downloads pass finally: loop.close() except Exception as e: print(f"Error getting real download status: {e}") # Separate active and completed downloads active = [] completed = [] for download_id, download in active_downloads.items(): if download["status"] in ["downloading", "queued"]: active.append(download) else: completed.append(download) # Add any completed downloads from our completed list completed.extend(completed_downloads) return jsonify({ "success": True, "downloads": { "active": active, "completed": completed, "active_count": len(active), "completed_count": len(completed) } }) except Exception as e: print(f"Error getting download status: {e}") return jsonify({"error": f"Failed to get download status: {str(e)}"}), 500 @app.route('/api/downloads/cancel/