import os import json import asyncio import requests import socket import ipaddress import subprocess import platform import threading import time import shutil import glob from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, send_file # --- 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, Playlist as SpotifyPlaylist, Track as SpotifyTrack 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 from core.matching_engine import MusicMatchingEngine from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker from database.music_database import get_database from services.sync_service import PlaylistSyncService from datetime import datetime # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) project_root = os.path.dirname(base_dir) # Go up one level to the project root config_path = os.path.join(project_root, 'config', 'config.json') if os.path.exists(config_path): print(f"Found config file at: {config_path}") # Assuming your config_manager has a method to load from a specific path if hasattr(config_manager, 'load_config'): config_manager.load_config(config_path) print("โœ… Web server configuration loaded successfully.") else: # Fallback if no load_config method, try re-initializing with path print("๐Ÿ”ด WARNING: config_manager does not have a 'load_config' method. Attempting re-init.") try: from config.settings import ConfigManager config_manager = ConfigManager(config_path) print("โœ… Web server configuration re-initialized successfully.") except Exception as e: print(f"๐Ÿ”ด FAILED to re-initialize config_manager: {e}") else: print(f"๐Ÿ”ด WARNING: config.json not found at {config_path}. Using default settings.") # 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() matching_engine = MusicMatchingEngine() sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client) 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 # --- Global Streaming State Management --- # Thread-safe state tracking for streaming functionality stream_state = { "status": "stopped", # States: stopped, loading, queued, ready, error "progress": 0, "track_info": None, "file_path": None, # Path to the audio file in the 'Stream' folder "error_message": None } stream_lock = threading.Lock() # Prevent race conditions stream_background_task = None stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time db_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DBUpdate") db_update_worker = None db_update_state = { "status": "idle", # idle, running, finished, error "phase": "Idle", "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "" } # --- Sync Page Globals --- sync_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SyncWorker") active_sync_workers = {} # Key: playlist_id, Value: Future object sync_states = {} # Key: playlist_id, Value: dict with progress info sync_lock = threading.Lock() db_update_lock = threading.Lock() # --- Global Matched Downloads Context Management --- # Thread-safe storage for matched download contexts # Key: slskd download ID, Value: dict containing Spotify artist/album data matched_downloads_context = {} matched_context_lock = threading.Lock() def _prepare_stream_task(track_data): """ Background streaming task that downloads track to Stream folder and updates global state. This replicates the logic from StreamingThread.run() in the GUI app. """ try: print(f"๐ŸŽต Starting stream preparation for: {track_data.get('filename')}") # Update state to loading with stream_lock: stream_state.update({ "status": "loading", "progress": 0, "track_info": track_data, "file_path": None, "error_message": None }) # Get paths download_path = config_manager.get('soulseek.download_path', './downloads') project_root = os.path.dirname(os.path.abspath(__file__)) # Web server root stream_folder = os.path.join(project_root, 'Stream') # Ensure Stream directory exists os.makedirs(stream_folder, exist_ok=True) # Clear any existing files in Stream folder (only one file at a time) for existing_file in glob.glob(os.path.join(stream_folder, '*')): try: if os.path.isfile(existing_file): os.remove(existing_file) elif os.path.isdir(existing_file): shutil.rmtree(existing_file) print(f"๐Ÿ—‘๏ธ Cleared old stream file: {existing_file}") except Exception as e: print(f"โš ๏ธ Could not remove existing stream file: {e}") # Start the download using the same mechanism as regular downloads loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: download_result = loop.run_until_complete(soulseek_client.download( track_data.get('username'), track_data.get('filename'), track_data.get('size', 0) )) if not download_result: with stream_lock: stream_state.update({ "status": "error", "error_message": "Failed to initiate download" }) return # Poll for completion with progress updates max_wait_time = 45 # Wait up to 45 seconds poll_interval = 2 # Check every 2 seconds for wait_count in range(max_wait_time // poll_interval): # Check download progress via slskd API try: transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads')) download_status = _find_streaming_download_in_transfers(transfers_data, track_data) if download_status: api_progress = download_status.get('percentComplete', 0) download_state = download_status.get('state', '').lower() original_state = download_status.get('state', '') # Update progress with stream_lock: stream_state["progress"] = api_progress if 'queued' in download_state or 'initializing' in download_state: stream_state["status"] = "queued" elif 'inprogress' in download_state: stream_state["status"] = "loading" # Check if download is complete is_completed = ('Succeeded' in original_state or ('Completed' in original_state and 'Errored' not in original_state) or api_progress >= 100) if is_completed: print(f"โœ“ Download completed via API status: {original_state}") # Try to find the actual file found_file = _find_downloaded_file(download_path, track_data) if found_file: # Move file to Stream folder original_filename = os.path.basename(found_file) stream_path = os.path.join(stream_folder, original_filename) shutil.move(found_file, stream_path) print(f"โœ“ Moved file to stream folder: {stream_path}") # Update state to ready with stream_lock: stream_state.update({ "status": "ready", "progress": 100, "file_path": stream_path }) # Clean up download from slskd API try: download_id = download_status.get('id', '') if download_id: success = loop.run_until_complete( soulseek_client.signal_download_completion( download_id, track_data.get('username'), remove=True) ) if success: print(f"โœ“ Cleaned up download {download_id} from API") except Exception as e: print(f"โš ๏ธ Error cleaning up download: {e}") return # Success! else: print("โŒ Could not find downloaded file") break except Exception as e: print(f"โš ๏ธ Error checking download progress: {e}") # Wait before next poll time.sleep(poll_interval) # If we get here, download timed out with stream_lock: stream_state.update({ "status": "error", "error_message": "Download timed out" }) finally: loop.close() except Exception as e: print(f"โŒ Stream preparation failed: {e}") with stream_lock: stream_state.update({ "status": "error", "error_message": str(e) }) def _find_streaming_download_in_transfers(transfers_data, track_data): """Find streaming download in transfer data using same logic as download queue""" try: if not transfers_data: return None # Flatten the transfers data structure all_transfers = [] for user_data in transfers_data: if 'directories' in user_data: for directory in user_data['directories']: if 'files' in directory: all_transfers.extend(directory['files']) # Look for our specific file by filename and username target_filename = os.path.basename(track_data.get('filename', '')) target_username = track_data.get('username', '') for transfer in all_transfers: transfer_filename = os.path.basename(transfer.get('filename', '')) transfer_username = transfer.get('username', '') if (transfer_filename == target_filename and transfer_username == target_username): return transfer return None except Exception as e: print(f"Error finding streaming download in transfers: {e}") return None def _find_downloaded_file(download_path, track_data): """Find the downloaded audio file in the downloads directory tree""" audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} target_filename = os.path.basename(track_data.get('filename', '')) try: # Walk through the downloads directory to find the file for root, dirs, files in os.walk(download_path): for file in files: # Check if this is our target file if file == target_filename: file_path = os.path.join(root, file) # Verify it's an audio file and has content if (os.path.splitext(file)[1].lower() in audio_extensions and os.path.getsize(file_path) > 1024): # At least 1KB return file_path print(f"โŒ Could not find downloaded file: {target_filename}") return None except Exception as e: print(f"Error searching for downloaded file: {e}") return 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 "

โœ… Tidal Authentication Successful!

You can now close this window and return to the SoulSync application.

" else: return "

โŒ Tidal Authentication Failed

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"

Tidal Authentication Failed

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 "

โœ… Tidal Authentication Successful!

You can now close this window and return to the SoulSync application.

" else: return "

โŒ Tidal Authentication Failed

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 Error Occurred

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(): """Real search using soulseek_client""" data = request.get_json() query = data.get('query') if not query: return jsonify({"error": "No search query provided."}), 400 print(f"Web UI Search for: '{query}'") try: tracks, albums = asyncio.run(soulseek_client.search(query)) # Convert to dictionaries for JSON response processed_albums = [] for album in albums: album_dict = album.__dict__.copy() album_dict["tracks"] = [track.__dict__ for track in album.tracks] album_dict["result_type"] = "album" processed_albums.append(album_dict) processed_tracks = [] for track in tracks: track_dict = track.__dict__.copy() track_dict["result_type"] = "track" processed_tracks.append(track_dict) # Sort by quality score all_results = sorted(processed_albums + processed_tracks, key=lambda x: x.get('quality_score', 0), reverse=True) return jsonify({"results": all_results}) except Exception as e: print(f"Search error: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/download', methods=['POST']) def start_download(): """Simple download route""" data = request.get_json() if not data: return jsonify({"error": "No download data provided."}), 400 try: result_type = data.get('result_type', 'track') if result_type == 'album': tracks = data.get('tracks', []) if not tracks: return jsonify({"error": "No tracks found in album."}), 400 started_downloads = 0 for track_data in tracks: try: download_id = asyncio.run(soulseek_client.download( track_data.get('username'), track_data.get('filename'), track_data.get('size', 0) )) if download_id: started_downloads += 1 except Exception as e: print(f"Failed to start track download: {e}") continue return jsonify({ "success": True, "message": f"Started {started_downloads} downloads from album" }) else: # Single track download username = data.get('username') filename = data.get('filename') file_size = data.get('size', 0) if not username or not filename: return jsonify({"error": "Missing username or filename."}), 400 download_id = asyncio.run(soulseek_client.download(username, filename, file_size)) if download_id: return jsonify({"success": True, "message": "Download started"}) else: return jsonify({"error": "Failed to start download"}), 500 except Exception as e: print(f"Download error: {e}") return jsonify({"error": str(e)}), 500 def _find_completed_file_robust(download_dir, api_filename): """ Robustly finds a completed file on disk, accounting for name variations and unexpected subdirectories. This version uses the superior normalization logic from the GUI's matching_engine.py to ensure consistency. """ import re import os from difflib import SequenceMatcher from unidecode import unidecode def normalize_for_finding(text: str) -> str: """A powerful normalization function adapted from matching_engine.py.""" if not text: return "" text = unidecode(text).lower() # Replace common separators with spaces to preserve word boundaries text = re.sub(r'[._/]', ' ', text) # Keep alphanumeric, spaces, and hyphens. Remove brackets/parentheses content. text = re.sub(r'[\[\(].*?[\]\)]', '', text) text = re.sub(r'[^a-z0-9\s-]', '', text) # Consolidate multiple spaces return ' '.join(text.split()).strip() target_basename = os.path.basename(api_filename) normalized_target = normalize_for_finding(target_basename) print(f" searching for normalized filename '{normalized_target}' in '{download_dir}'...") best_match_path = None highest_similarity = 0.0 # Walk through the entire download directory for root, _, files in os.walk(download_dir): for file in files: # Direct match is the best case if os.path.basename(file) == target_basename: print(f"Found exact match: {os.path.join(root, file)}") return os.path.join(root, file) # Fuzzy matching for variations normalized_file = normalize_for_finding(file) similarity = SequenceMatcher(None, normalized_target, normalized_file).ratio() if similarity > highest_similarity: highest_similarity = similarity best_match_path = os.path.join(root, file) # Use a high confidence threshold for fuzzy matches to avoid incorrect files if highest_similarity > 0.85: print(f"Found best fuzzy match with similarity {highest_similarity:.2f}: {best_match_path}") return best_match_path print(f"Could not find a confident match for '{target_basename}'. Highest similarity was {highest_similarity:.2f}.") return None @app.route('/api/downloads/status') def get_download_status(): """ A robust status checker that correctly finds completed files by searching the entire download directory with fuzzy matching, mirroring the logic from downloads.py. """ if not soulseek_client: return jsonify({"transfers": []}) try: global _processed_download_ids transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads')) if not transfers_data: return jsonify({"transfers": []}) all_transfers = [] completed_matched_downloads = [] # This logic now correctly processes the nested structure from the slskd API for user_data in transfers_data: username = user_data.get('username', 'Unknown') if 'directories' in user_data: for directory in user_data['directories']: if 'files' in directory: for file_info in directory['files']: file_info['username'] = username all_transfers.append(file_info) state = file_info.get('state', '').lower() # Check for completion state if ('succeeded' in state or 'completed' in state) and 'errored' not in state: filename_from_api = file_info.get('filename') if not filename_from_api: continue # Check if this completed download has a matched context context_key = f"{username}::{filename_from_api}" with matched_context_lock: context = matched_downloads_context.get(context_key) if context and context_key not in _processed_download_ids: download_dir = config_manager.get('soulseek.download_path', './downloads') # Use the new robust file finder found_path = _find_completed_file_robust(download_dir, filename_from_api) if found_path: print(f"๐ŸŽฏ Found completed matched file on disk: {found_path}") completed_matched_downloads.append((context_key, context, found_path)) # Don't add to _processed_download_ids yet - wait until thread starts successfully else: print(f"โŒ CRITICAL: Could not find '{os.path.basename(filename_from_api)}' on disk. Post-processing skipped.") # If we found completed matched downloads, start processing them in background threads if completed_matched_downloads: def process_completed_downloads(): for context_key, context, found_path in completed_matched_downloads: try: print(f"๐Ÿš€ Starting post-processing thread for: {context_key}") # Start the post-processing in a separate thread thread = threading.Thread(target=_post_process_matched_download, args=(context_key, context, found_path)) thread.daemon = True thread.start() # Only mark as processed AFTER thread starts successfully _processed_download_ids.add(context_key) print(f"โœ… Marked as processed: {context_key}") # Remove context so it's not processed again with matched_context_lock: if context_key in matched_downloads_context: del matched_downloads_context[context_key] print(f"๐Ÿ—‘๏ธ Removed context: {context_key}") except Exception as e: print(f"โŒ Error starting post-processing thread for {context_key}: {e}") # Don't add to processed set if thread failed to start print(f"โš ๏ธ Will retry {context_key} on next check") # Start a single thread to manage the launching of all processing threads processing_thread = threading.Thread(target=process_completed_downloads) processing_thread.daemon = True processing_thread.start() return jsonify({"transfers": all_transfers}) except Exception as e: print(f"Error fetching download status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/downloads/cancel', methods=['POST']) def cancel_download(): """ Cancel a specific download transfer, matching GUI functionality. """ data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided."}), 400 download_id = data.get('download_id') username = data.get('username') if not all([download_id, username]): return jsonify({"success": False, "error": "Missing download_id or username."}), 400 try: # Call the same client method the GUI uses success = asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True)) if success: return jsonify({"success": True, "message": "Download cancelled."}) else: return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500 except Exception as e: print(f"Error cancelling download: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/downloads/clear-finished', methods=['POST']) def clear_finished_downloads(): """ Clear all terminal (completed, cancelled, failed) downloads from slskd. """ try: # This single client call handles clearing everything that is no longer active success = asyncio.run(soulseek_client.clear_all_completed_downloads()) if success: return jsonify({"success": True, "message": "Finished downloads cleared."}) else: return jsonify({"success": False, "error": "Backend failed to clear downloads."}), 500 except Exception as e: print(f"Error clearing finished downloads: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/artists') def get_artists(): # Placeholder: returns mock artist data mock_artists = [ {"name": "Queen", "album_count": 15, "image": None}, {"name": "Led Zeppelin", "album_count": 9, "image": None} ] return jsonify({"artists": mock_artists}) @app.route('/api/stream/start', methods=['POST']) def stream_start(): """Start streaming a track in the background""" global stream_background_task data = request.get_json() if not data: return jsonify({"success": False, "error": "No track data provided"}), 400 print(f"๐ŸŽต Web UI Stream request for: {data.get('filename')}") try: # Stop any existing streaming task if stream_background_task and not stream_background_task.done(): stream_background_task.cancel() # Reset stream state with stream_lock: stream_state.update({ "status": "stopped", "progress": 0, "track_info": None, "file_path": None, "error_message": None }) # Start new background streaming task stream_background_task = stream_executor.submit(_prepare_stream_task, data) return jsonify({"success": True, "message": "Streaming started"}) except Exception as e: print(f"โŒ Error starting stream: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/stream/status') def stream_status(): """Get current streaming status and progress""" try: with stream_lock: # Return copy of current stream state return jsonify({ "status": stream_state["status"], "progress": stream_state["progress"], "track_info": stream_state["track_info"], "error_message": stream_state["error_message"] }) except Exception as e: print(f"โŒ Error getting stream status: {e}") return jsonify({ "status": "error", "progress": 0, "track_info": None, "error_message": str(e) }), 500 @app.route('/stream/audio') def stream_audio(): """Serve the audio file from the Stream folder""" try: with stream_lock: if stream_state["status"] != "ready" or not stream_state["file_path"]: return jsonify({"error": "No audio file ready for streaming"}), 404 file_path = stream_state["file_path"] if not os.path.exists(file_path): return jsonify({"error": "Audio file not found"}), 404 print(f"๐ŸŽต Serving audio file: {os.path.basename(file_path)}") # Determine MIME type based on file extension file_ext = os.path.splitext(file_path)[1].lower() mime_types = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.ogg': 'audio/ogg', '.aac': 'audio/aac', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.wma': 'audio/x-ms-wma' } mimetype = mime_types.get(file_ext, 'audio/mpeg') # Default to MP3 return send_file(file_path, as_attachment=False, mimetype=mimetype) except Exception as e: print(f"โŒ Error serving audio file: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/stream/stop', methods=['POST']) def stream_stop(): """Stop streaming and clean up""" global stream_background_task try: # Cancel background task if stream_background_task and not stream_background_task.done(): stream_background_task.cancel() # Clear Stream folder project_root = os.path.dirname(os.path.abspath(__file__)) stream_folder = os.path.join(project_root, 'Stream') if os.path.exists(stream_folder): for filename in os.listdir(stream_folder): file_path = os.path.join(stream_folder, filename) if os.path.isfile(file_path): os.remove(file_path) print(f"๐Ÿ—‘๏ธ Removed stream file: {filename}") # Reset stream state with stream_lock: stream_state.update({ "status": "stopped", "progress": 0, "track_info": None, "file_path": None, "error_message": None }) return jsonify({"success": True, "message": "Stream stopped"}) except Exception as e: print(f"โŒ Error stopping stream: {e}") return jsonify({"success": False, "error": str(e)}), 500 # --- Matched Downloads API Endpoints --- def _generate_artist_suggestions(search_result, is_album=False, album_result=None): """ Port of ArtistSuggestionThread.generate_artist_suggestions() from GUI Generate artist suggestions using multiple strategies """ if not spotify_client or not matching_engine: return [] try: print(f"๐Ÿ” Generating artist suggestions for: {search_result.get('artist', '')} - {search_result.get('title', '')}") suggestions = [] # Special handling for albums - use album title to find artist if is_album and album_result and album_result.get('album_title'): print(f"๐ŸŽต Album mode detected - using album title for artist search") album_title = album_result.get('album_title', '') # Clean album title (remove year prefixes like "(2005)") import re clean_album_title = re.sub(r'^\(\d{4}\)\s*', '', album_title).strip() print(f" clean_album_title: '{clean_album_title}'") # Search tracks using album title to find the artist tracks = spotify_client.search_tracks(clean_album_title, limit=20) print(f"๐Ÿ“Š Found {len(tracks)} tracks from album search") # Collect unique artists and their associated tracks/albums unique_artists = {} # artist_name -> list of (track, album) tuples for track in tracks: for artist_name in track.artists: if artist_name not in unique_artists: unique_artists[artist_name] = [] unique_artists[artist_name].append((track, track.album)) # Batch fetch artist objects for speed from concurrent.futures import ThreadPoolExecutor, as_completed artist_objects = {} # artist_name -> Artist object def fetch_artist(artist_name): try: matches = spotify_client.search_artists(artist_name, limit=1) if matches: return artist_name, matches[0] except Exception as e: print(f"โš ๏ธ Error fetching artist '{artist_name}': {e}") return artist_name, None # Use limited concurrency to respect rate limits with ThreadPoolExecutor(max_workers=3) as executor: future_to_artist = {executor.submit(fetch_artist, name): name for name in unique_artists.keys()} for future in as_completed(future_to_artist): artist_name, artist_obj = future.result() if artist_obj: artist_objects[artist_name] = artist_obj # Calculate confidence scores for each artist artist_scores = {} for artist_name, track_album_pairs in unique_artists.items(): if artist_name not in artist_objects: continue artist = artist_objects[artist_name] best_confidence = 0 # Find the best confidence score across all albums for this artist for track, album in track_album_pairs: confidence = matching_engine.similarity_score( matching_engine.normalize_string(clean_album_title), matching_engine.normalize_string(album) ) if confidence > best_confidence: best_confidence = confidence artist_scores[artist_name] = (artist, best_confidence) # Create suggestions from top matches for artist_name, (artist, confidence) in sorted(artist_scores.items(), key=lambda x: x[1][1], reverse=True)[:8]: suggestions.append({ "artist": { "id": artist.id, "name": artist.name, "image_url": getattr(artist, 'image_url', None), "genres": getattr(artist, 'genres', []), "popularity": getattr(artist, 'popularity', 0) }, "confidence": confidence }) else: # Single track mode - search by artist name search_artist = search_result.get('artist', '') if not search_artist: return [] print(f"๐ŸŽต Single track mode - searching for artist: '{search_artist}'") # Search for artists directly artist_matches = spotify_client.search_artists(search_artist, limit=10) for artist in artist_matches: # Calculate confidence based on artist name similarity confidence = matching_engine.similarity_score( matching_engine.normalize_string(search_artist), matching_engine.normalize_string(artist.name) ) suggestions.append({ "artist": { "id": artist.id, "name": artist.name, "image_url": getattr(artist, 'image_url', None), "genres": getattr(artist, 'genres', []), "popularity": getattr(artist, 'popularity', 0) }, "confidence": confidence }) # Sort by confidence and return top results suggestions.sort(key=lambda x: x['confidence'], reverse=True) return suggestions[:4] except Exception as e: print(f"โŒ Error generating artist suggestions: {e}") return [] def _generate_album_suggestions(selected_artist, search_result): """ Port of AlbumSuggestionThread logic from GUI Generate album suggestions for a selected artist """ if not spotify_client or not matching_engine: return [] try: print(f"๐Ÿ” Generating album suggestions for artist: {selected_artist['name']}") # Determine target album name from search result target_album_name = search_result.get('album', '') or search_result.get('album_title', '') if not target_album_name: print("โš ๏ธ No album name found in search result") return [] # Clean target album name import re clean_target = re.sub(r'^\(\d{4}\)\s*', '', target_album_name).strip() print(f" target_album: '{clean_target}'") # Get artist's albums from Spotify artist_albums = spotify_client.get_artist_albums(selected_artist['id'], limit=50) print(f"๐Ÿ“Š Found {len(artist_albums)} albums for artist") album_matches = [] for album in artist_albums: # Calculate confidence based on album name similarity confidence = matching_engine.similarity_score( matching_engine.normalize_string(clean_target), matching_engine.normalize_string(album.name) ) album_matches.append({ "album": { "id": album.id, "name": album.name, "release_date": getattr(album, 'release_date', ''), "album_type": getattr(album, 'album_type', 'album'), "image_url": getattr(album, 'image_url', None), "total_tracks": getattr(album, 'total_tracks', 0) }, "confidence": confidence }) # Sort by confidence and return top results album_matches.sort(key=lambda x: x['confidence'], reverse=True) return album_matches[:4] except Exception as e: print(f"โŒ Error generating album suggestions: {e}") return [] @app.route('/api/match/suggestions', methods=['POST']) def get_match_suggestions(): """Get AI-powered suggestions for artist or album matching""" try: data = request.get_json() search_result = data.get('search_result', {}) context = data.get('context', 'artist') # 'artist' or 'album' if context == 'artist': is_album = data.get('is_album', False) album_result = data.get('album_result', None) if is_album else None suggestions = _generate_artist_suggestions(search_result, is_album, album_result) elif context == 'album': selected_artist = data.get('selected_artist', {}) suggestions = _generate_album_suggestions(selected_artist, search_result) else: return jsonify({"error": "Invalid context. Must be 'artist' or 'album'"}), 400 return jsonify({"suggestions": suggestions}) except Exception as e: print(f"โŒ Error in match suggestions: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/match/search', methods=['POST']) def search_match(): """Manual search for artists or albums""" try: data = request.get_json() query = data.get('query', '').strip() context = data.get('context', 'artist') # 'artist' or 'album' if not query: return jsonify({"results": []}) if context == 'artist': # Search for artists artist_matches = spotify_client.search_artists(query, limit=8) results = [] for artist in artist_matches: # Calculate confidence based on search similarity confidence = matching_engine.similarity_score( matching_engine.normalize_string(query), matching_engine.normalize_string(artist.name) ) results.append({ "artist": { "id": artist.id, "name": artist.name, "image_url": getattr(artist, 'image_url', None), "genres": getattr(artist, 'genres', []), "popularity": getattr(artist, 'popularity', 0) }, "confidence": confidence }) return jsonify({"results": results}) elif context == 'album': # Search for albums by specific artist artist_id = data.get('artist_id') if not artist_id: return jsonify({"error": "Artist ID required for album search"}), 400 # Get artist's albums and filter by query artist_albums = spotify_client.get_artist_albums(artist_id, limit=50) results = [] for album in artist_albums: # Calculate confidence based on query similarity confidence = matching_engine.similarity_score( matching_engine.normalize_string(query), matching_engine.normalize_string(album.name) ) # Only include results with reasonable similarity if confidence > 0.3: results.append({ "album": { "id": album.id, "name": album.name, "release_date": getattr(album, 'release_date', ''), "album_type": getattr(album, 'album_type', 'album'), "image_url": getattr(album, 'image_url', None), "total_tracks": getattr(album, 'total_tracks', 0) }, "confidence": confidence }) # Sort by confidence results.sort(key=lambda x: x['confidence'], reverse=True) return jsonify({"results": results[:8]}) else: return jsonify({"error": "Invalid context. Must be 'artist' or 'album'"}), 400 except Exception as e: print(f"โŒ Error in match search: {e}") return jsonify({"error": str(e)}), 500 def _start_album_download_tasks(album_result, spotify_artist, spotify_album): """ This final version now fetches the official Spotify tracklist and uses it to match and correct the metadata for each individual track before downloading, ensuring perfect tagging and naming. """ print(f"๐ŸŽต Processing matched album download for '{spotify_album['name']}' with {len(album_result.get('tracks', []))} tracks.") tracks_to_download = album_result.get('tracks', []) if not tracks_to_download: print("โš ๏ธ Album result contained no tracks. Aborting.") return 0 # --- THIS IS THE NEW LOGIC --- # Fetch the official tracklist from Spotify ONCE for the entire album. official_spotify_tracks = _get_spotify_album_tracks(spotify_album) if not official_spotify_tracks: print("โš ๏ธ Could not fetch official tracklist from Spotify. Metadata may be inaccurate.") # --- END OF NEW LOGIC --- started_count = 0 for track_data in tracks_to_download: try: username = track_data.get('username') or album_result.get('username') filename = track_data.get('filename') size = track_data.get('size', 0) if not username or not filename: continue # Pre-parse the filename to get a baseline for metadata parsed_meta = _parse_filename_metadata(filename) # --- THIS IS THE CRITICAL MATCHING STEP --- # Match the parsed metadata against the official Spotify tracklist corrected_meta = _match_track_to_spotify_title(parsed_meta, official_spotify_tracks) # --- END OF CRITICAL STEP --- # Create a clean context object using the CORRECTED metadata individual_track_context = { 'username': username, 'filename': filename, 'size': size, 'title': corrected_meta.get('title'), 'artist': corrected_meta.get('artist') or spotify_artist['name'], 'album': spotify_album['name'], 'track_number': corrected_meta.get('track_number') } download_id = asyncio.run(soulseek_client.download(username, filename, size)) if download_id: context_key = f"{username}::{filename}" with matched_context_lock: matched_downloads_context[context_key] = { "spotify_artist": spotify_artist, "spotify_album": spotify_album, "original_search_result": individual_track_context, # Contains corrected data "is_album_download": True } print(f" + Queued track: {filename} (Matched to: '{corrected_meta.get('title')}')") started_count += 1 else: print(f" - Failed to queue track: {filename}") except Exception as e: print(f"โŒ Error processing track in album batch: {track_data.get('filename')}. Error: {e}") continue return started_count @app.route('/api/download/matched', methods=['POST']) def start_matched_download(): """ Starts a matched download. This version corrects a bug where album context was being discarded for individual album track downloads, ensuring they are processed identically to single track downloads. """ try: data = request.get_json() download_payload = data.get('search_result', {}) spotify_artist = data.get('spotify_artist', {}) spotify_album = data.get('spotify_album', None) if not download_payload or not spotify_artist: return jsonify({"success": False, "error": "Missing download payload or artist data"}), 400 # This check is for full album downloads (when the main album card button is clicked) is_full_album_download = bool(spotify_album and download_payload.get('result_type') == 'album') if is_full_album_download: # This logic for full album downloads is correct and remains unchanged. started_count = _start_album_download_tasks(download_payload, spotify_artist, spotify_album) if started_count > 0: return jsonify({"success": True, "message": f"Queued {started_count} tracks for matched album download."}) else: return jsonify({"success": False, "error": "Failed to queue any tracks from the album."}), 500 else: # This block handles BOTH regular singles AND individual tracks from an album card. username = download_payload.get('username') filename = download_payload.get('filename') size = download_payload.get('size', 0) if not username or not filename: return jsonify({"success": False, "error": "Missing username or filename"}), 400 parsed_meta = _parse_filename_metadata(filename) download_payload['title'] = parsed_meta.get('title') or download_payload.get('title') download_payload['artist'] = parsed_meta.get('artist') or download_payload.get('artist') download_id = asyncio.run(soulseek_client.download(username, filename, size)) if download_id: context_key = f"{username}::{filename}" with matched_context_lock: # THE FIX: We preserve the spotify_album context if it was provided. # For a regular single, spotify_album will be None. # For an album track, it will contain the album's data. matched_downloads_context[context_key] = { "spotify_artist": spotify_artist, "spotify_album": spotify_album, # PRESERVE album context "original_search_result": download_payload, "is_album_download": False # It's a single track download, not a full album job. } return jsonify({"success": True, "message": "Matched download started"}) else: return jsonify({"success": False, "error": "Failed to start download via slskd"}), 500 except Exception as e: import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 def _parse_filename_metadata(filename: str) -> dict: """ A direct port of the metadata parsing logic from the GUI's soulseek_client.py. This is the crucial missing step that cleans filenames BEFORE Spotify matching. """ import re import os metadata = { 'artist': None, 'title': None, 'album': None, 'track_number': None } # Get just the filename without extension and path base_name = os.path.splitext(os.path.basename(filename))[0] # --- Logic from soulseek_client.py --- patterns = [ # Pattern: 01 - Artist - Title r'^(?P\d{1,2})\s*[-\.]\s*(?P.+?)\s*[-โ€“]\s*(?P.+)$', # Pattern: Artist - Title r'^(?P<artist>.+?)\s*[-โ€“]\s*(?P<title>.+)$', # Pattern: 01 - Title r'^(?P<track_number>\d{1,2})\s*[-\.]\s*(?P<title>.+)$', ] for pattern in patterns: match = re.match(pattern, base_name) if match: match_dict = match.groupdict() metadata['track_number'] = int(match_dict['track_number']) if match_dict.get('track_number') else None metadata['artist'] = match_dict.get('artist', '').strip() or None metadata['title'] = match_dict.get('title', '').strip() or None break # Stop after first successful match # If title is still missing, use the whole base_name if not metadata['title']: metadata['title'] = base_name.strip() # Fallback for underscore formats like 'Artist_Album_01_Title' if not metadata['artist'] and '_' in base_name: parts = base_name.split('_') if len(parts) >= 3: # A common pattern is Artist_Album_TrackNum_Title if parts[-2].isdigit(): metadata['artist'] = parts[0].strip() metadata['title'] = parts[-1].strip() metadata['track_number'] = int(parts[-2]) metadata['album'] = parts[1].strip() # Final cleanup on title if it contains the artist if metadata['artist'] and metadata['title'] and metadata['artist'].lower() in metadata['title'].lower(): metadata['title'] = metadata['title'].replace(metadata['artist'], '').lstrip(' -โ€“_').strip() # Try to extract album from the full directory path if '/' in filename or '\\' in filename: path_parts = filename.replace('\\', '/').split('/') if len(path_parts) >= 2: # The parent directory is often the album potential_album = path_parts[-2] # Clean common prefixes like '2024 - ' cleaned_album = re.sub(r'^\d{4}\s*-\s*', '', potential_album).strip() metadata['album'] = cleaned_album print(f"๐Ÿง  Parsed Filename '{base_name}': Artist='{metadata['artist']}', Title='{metadata['title']}', Album='{metadata['album']}', Track#='{metadata['track_number']}'") return metadata # =================================================================== # NEW POST-PROCESSING HELPERS (Ported from downloads.py) # =================================================================== def _sanitize_filename(filename: str) -> str: """Sanitize filename for file system compatibility.""" import re sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename) sanitized = re.sub(r'\s+', ' ', sanitized).strip() return sanitized[:200] def _clean_track_title(track_title: str, artist_name: str) -> str: """Clean up track title by removing artist prefix and other noise.""" import re original = track_title.strip() cleaned = original cleaned = re.sub(r'^\d{1,2}[\.\s\-]+', '', cleaned) artist_pattern = re.escape(artist_name) + r'\s*-\s*' cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE) cleaned = re.sub(r'^[A-Za-z0-9\.]+\s*-\s*\d{1,2}\s*-\s*', '', cleaned) quality_patterns = [r'\s*[\[\(][0-9]+\s*kbps[\]\)]\s*', r'\s*[\[\(]flac[\]\)]\s*', r'\s*[\[\(]mp3[\]\)]\s*'] for pattern in quality_patterns: cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) cleaned = re.sub(r'^[-\s\.]+', '', cleaned) cleaned = re.sub(r'[-\s\.]+$', '', cleaned) cleaned = re.sub(r'\s+', ' ', cleaned).strip() return cleaned if cleaned else original def _extract_track_number_from_filename(filename: str, title: str = None) -> int: """Extract track number from filename or title, returns 1 if not found.""" import re import os text_to_check = f"{title or ''} {os.path.splitext(os.path.basename(filename))[0]}" match = re.match(r'^\d{1,2}', text_to_check.strip()) if match: return int(match.group(0)) return 1 def _search_track_in_album_context(original_search: dict, artist: dict) -> dict: """ Searches for a track within its album context to avoid matching promotional singles. This is a direct port from downloads.py for web server use. """ try: album_name = original_search.get('album') track_title = original_search.get('title') if not all([album_name, track_title, artist]): return None clean_album = _clean_track_title(album_name, artist['name']) # Use track cleaner for album too clean_track = _clean_track_title(track_title, artist['name']) album_query = f"album:\"{clean_album}\" artist:\"{artist['name']}\"" albums = spotify_client.search_albums(album_query, limit=1) if not albums: return None spotify_album = albums[0] album_tracks_data = spotify_client.get_album_tracks(spotify_album.id) if not album_tracks_data or 'items' not in album_tracks_data: return None for track_data in album_tracks_data['items']: similarity = matching_engine.similarity_score( matching_engine.normalize_string(clean_track), matching_engine.normalize_string(track_data['name']) ) if similarity > 0.7: print(f"โœ… Found track in album context: '{track_data['name']}'") return { 'is_album': True, 'album_name': spotify_album.name, 'track_number': track_data['track_number'], 'clean_track_name': track_data['name'], 'album_image_url': spotify_album.image_url } return None except Exception as e: print(f"โŒ Error in _search_track_in_album_context: {e}") return None def _detect_album_info_web(context: dict, artist: dict) -> dict: """ This is the final, corrected version that ensures the official Spotify track number from the context is always prioritized for matched album downloads, fixing the track numbering issue by mirroring the logic from downloads.py. """ try: original_search = context.get("original_search_result", {}) spotify_album_context = context.get("spotify_album") is_album_download = context.get("is_album_download", False) # --- THIS IS THE CRITICAL FIX --- # If this is part of a matched album download, we TRUST the context data completely. # This is the exact logic from downloads.py. if is_album_download and spotify_album_context: print("โœ… Matched Album context found. Prioritizing pre-matched Spotify data.") # We exclusively use the track number and title that were matched # *before* the download started. We do not try to re-parse the filename. track_number = original_search.get('track_number', 1) clean_track_name = original_search.get('title', 'Unknown Track') print(f" -> Using pre-matched Track #{track_number} and Title '{clean_track_name}'") return { 'is_album': True, 'album_name': spotify_album_context['name'], 'track_number': track_number, 'clean_track_name': clean_track_name, 'album_image_url': spotify_album_context.get('image_url') } # This fallback block handles single tracks. It was already working correctly. # It performs a live Spotify search to determine if a single is part of an album. print("โ„น๏ธ Single track context. Performing live Spotify search for album info.") cleaned_title = _clean_track_title(original_search.get('title', ''), artist['name']) query = f"artist:\"{artist['name']}\" track:\"{cleaned_title}\"" tracks = spotify_client.search_tracks(query, limit=1) if not tracks: print("โš ๏ธ No Spotify match found, defaulting to single.") return {'is_album': False, 'clean_track_name': cleaned_title, 'album_name': cleaned_title, 'track_number': 1} best_match = tracks[0] detailed_track = spotify_client.get_track_details(best_match.id) if not detailed_track: print("โš ๏ธ Could not get detailed track info, defaulting to single.") return {'is_album': False, 'clean_track_name': best_match.name, 'album_name': best_match.name, 'track_number': 1} api_album = detailed_track.get('album', {}) album_type = api_album.get('album_type', 'single') total_tracks = api_album.get('total_tracks', 1) is_album = (album_type == 'album' and total_tracks > 1 and matching_engine.similarity_score(api_album.get('name'), best_match.name) < 0.9) album_image_url = api_album.get('images', [{}])[0].get('url') if api_album.get('images') else None return { 'is_album': is_album, 'album_name': api_album.get('name', best_match.name), 'track_number': detailed_track.get('track_number', 1), 'clean_track_name': best_match.name, 'album_image_url': album_image_url } except Exception as e: print(f"โŒ Error in _detect_album_info_web: {e}") clean_title = _clean_track_title(context.get("original_search_result", {}).get('title', 'Unknown'), artist.get('name', '')) return {'is_album': False, 'clean_track_name': clean_title, 'album_name': clean_title, 'track_number': 1} def _cleanup_empty_directories(download_path, moved_file_path): """Cleans up empty directories after a file move, ignoring hidden files.""" import os try: current_dir = os.path.dirname(moved_file_path) while current_dir != download_path and current_dir.startswith(download_path): is_empty = not any(not f.startswith('.') for f in os.listdir(current_dir)) if is_empty: print(f"Removing empty directory: {current_dir}") os.rmdir(current_dir) current_dir = os.path.dirname(current_dir) else: break except Exception as e: print(f"Warning: An error occurred during directory cleanup: {e}") # =================================================================== # METADATA & COVER ART HELPERS (Ported from downloads.py) # =================================================================== from mutagen import File as MutagenFile from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, TRCK, TCON, TPE2, TPOS, TXXX, APIC from mutagen.flac import FLAC, Picture from mutagen.mp4 import MP4, MP4Cover from mutagen.oggvorbis import OggVorbis import urllib.request def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict) -> bool: """ Core function to enhance audio file metadata using Spotify data. """ if not config_manager.get('metadata_enhancement.enabled', True): print("๐ŸŽต Metadata enhancement disabled in config.") return True print(f"๐ŸŽต Enhancing metadata for: {os.path.basename(file_path)}") try: audio_file = MutagenFile(file_path, easy=True) if audio_file is None: audio_file = MutagenFile(file_path) # Try non-easy mode if audio_file is None: print(f"โŒ Could not load audio file with Mutagen: {file_path}") return False metadata = _extract_spotify_metadata(context, artist, album_info) if not metadata: print("โš ๏ธ Could not extract Spotify metadata, preserving original tags.") return True # Use 'easy' tags for broad compatibility first audio_file['title'] = metadata.get('title', '') audio_file['artist'] = metadata.get('artist', '') audio_file['albumartist'] = metadata.get('album_artist', '') audio_file['album'] = metadata.get('album', '') if metadata.get('date'): audio_file['date'] = metadata['date'] if metadata.get('genre'): audio_file['genre'] = metadata['genre'] track_num_str = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}" audio_file['tracknumber'] = track_num_str if metadata.get('disc_number'): audio_file['discnumber'] = str(metadata.get('disc_number')) audio_file.save() # Embed album art if enabled if config_manager.get('metadata_enhancement.embed_album_art', True): # Re-open in non-easy mode for embedding art audio_file_art = MutagenFile(file_path) _embed_album_art_metadata(audio_file_art, metadata) audio_file_art.save() print("โœ… Metadata enhanced successfully.") return True except Exception as e: print(f"โŒ Error enhancing metadata for {file_path}: {e}") return False def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> dict: """Extracts a comprehensive metadata dictionary from the provided context.""" metadata = {} original_search = context.get("original_search_result", {}) spotify_album = context.get("spotify_album") metadata['title'] = album_info.get('clean_track_name', original_search.get('title', '')) metadata['artist'] = artist.get('name', '') metadata['album_artist'] = artist.get('name', '') # Crucial for library organization if album_info.get('is_album'): metadata['album'] = album_info.get('album_name', 'Unknown Album') metadata['track_number'] = album_info.get('track_number', 1) metadata['total_tracks'] = spotify_album.get('total_tracks', 1) if spotify_album else 1 else: metadata['album'] = metadata['title'] # For singles, album is the title metadata['track_number'] = 1 metadata['total_tracks'] = 1 if spotify_album and spotify_album.get('release_date'): metadata['date'] = spotify_album['release_date'][:4] if artist.get('genres'): metadata['genre'] = ', '.join(artist['genres'][:2]) metadata['album_art_url'] = album_info.get('album_image_url') return metadata def _embed_album_art_metadata(audio_file, metadata: dict): """Downloads and embeds high-quality Spotify album art into the file.""" try: art_url = metadata.get('album_art_url') if not art_url: print("๐ŸŽจ No album art URL available for embedding.") return with urllib.request.urlopen(art_url, timeout=10) as response: image_data = response.read() mime_type = response.info().get_content_type() if not image_data: print("โŒ Failed to download album art data.") return # MP3 (ID3) if isinstance(audio_file.tags, ID3): audio_file.tags.add(APIC(encoding=3, mime=mime_type, type=3, desc='Cover', data=image_data)) # FLAC elif isinstance(audio_file, FLAC): picture = Picture() picture.data = image_data picture.type = 3 picture.mime = mime_type picture.width = 640 picture.height = 640 picture.depth = 24 audio_file.add_picture(picture) # MP4/M4A elif isinstance(audio_file, MP4): fmt = MP4Cover.FORMAT_JPEG if 'jpeg' in mime_type else MP4Cover.FORMAT_PNG audio_file['covr'] = [MP4Cover(image_data, imageformat=fmt)] print("๐ŸŽจ Album art successfully embedded.") except Exception as e: print(f"โŒ Error embedding album art: {e}") def _download_cover_art(album_info: dict, target_dir: str): """Downloads cover.jpg into the specified directory.""" try: cover_path = os.path.join(target_dir, "cover.jpg") if os.path.exists(cover_path): return art_url = album_info.get('album_image_url') if not art_url: print("๐Ÿ“ท No cover art URL available for download.") return with urllib.request.urlopen(art_url, timeout=10) as response: image_data = response.read() with open(cover_path, 'wb') as f: f.write(image_data) print(f"โœ… Cover art downloaded to: {cover_path}") except Exception as e: print(f"โŒ Error downloading cover.jpg: {e}") def _get_spotify_album_tracks(spotify_album: dict) -> list: """Fetches all tracks for a given Spotify album ID.""" if not spotify_album or not spotify_album.get('id'): return [] try: tracks_data = spotify_client.get_album_tracks(spotify_album['id']) if tracks_data and 'items' in tracks_data: return [{ 'name': item.get('name'), 'track_number': item.get('track_number'), 'id': item.get('id') } for item in tracks_data['items']] return [] except Exception as e: print(f"โŒ Error fetching Spotify album tracks: {e}") return [] def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -> dict: """ Intelligently matches a Soulseek track to a track from the official Spotify tracklist using track numbers and title similarity. Returns the matched Spotify track data. """ if not spotify_tracks: return slsk_track_meta # Return original if no list to match against # Priority 1: Match by track number if slsk_track_meta.get('track_number'): track_num = slsk_track_meta['track_number'] for sp_track in spotify_tracks: if sp_track.get('track_number') == track_num: print(f"โœ… Matched track by number ({track_num}): '{slsk_track_meta['title']}' -> '{sp_track['name']}'") # Return a new dict with the corrected title and number return { 'title': sp_track['name'], 'artist': slsk_track_meta.get('artist'), 'album': slsk_track_meta.get('album'), 'track_number': sp_track['track_number'] } # Priority 2: Match by title similarity (if track number fails) best_match = None best_score = 0.6 # Require a decent similarity for sp_track in spotify_tracks: score = matching_engine.similarity_score( matching_engine.normalize_string(slsk_track_meta.get('title', '')), matching_engine.normalize_string(sp_track.get('name', '')) ) if score > best_score: best_score = score best_match = sp_track if best_match: print(f"โœ… Matched track by title similarity ({best_score:.2f}): '{slsk_track_meta['title']}' -> '{best_match['name']}'") return { 'title': best_match['name'], 'artist': slsk_track_meta.get('artist'), 'album': slsk_track_meta.get('album'), 'track_number': best_match['track_number'] } print(f"โš ๏ธ Could not confidently match track '{slsk_track_meta['title']}'. Using original metadata.") return slsk_track_meta # Fallback to original # --- Post-Processing Logic --- def _post_process_matched_download(context_key, context, file_path): """ This is the final, corrected post-processing function. It now mirrors the GUI's logic by trusting the pre-matched context for album downloads, which solves the track numbering issue. """ try: import os import shutil import time from pathlib import Path # --- GUI PARITY FIX: Add a delay to prevent file lock race conditions --- # The GUI app waits 1 second to ensure the file handle is released by # the download client before attempting to move or modify it. print(f"โณ Waiting 1 second for file handle release for: {os.path.basename(file_path)}") time.sleep(1) # --- END OF FIX --- print(f"๐ŸŽฏ Starting robust post-processing for: {context_key}") spotify_artist = context.get("spotify_artist") if not spotify_artist: print(f"โŒ Post-processing failed: Missing spotify_artist context.") return is_album_download = context.get("is_album_download", False) if is_album_download: # For matched album downloads, we build album_info directly from the # trusted context, bypassing the problematic _detect_album_info_web function. print("โœ… Matched Album context found. Building info directly from context.") original_search = context.get("original_search_result", {}) spotify_album = context.get("spotify_album", {}) album_info = { 'is_album': True, 'album_name': spotify_album.get('name'), 'track_number': original_search.get('track_number', 1), 'clean_track_name': original_search.get('title', 'Unknown Track'), 'album_image_url': spotify_album.get('image_url') } else: # For singles, we still need to detect if they belong to an album. album_info = _detect_album_info_web(context, spotify_artist) # 1. Get transfer path and create artist directory transfer_dir = config_manager.get('soulseek.transfer_path', './Transfer') artist_name_sanitized = _sanitize_filename(spotify_artist["name"]) artist_dir = os.path.join(transfer_dir, artist_name_sanitized) os.makedirs(artist_dir, exist_ok=True) file_ext = os.path.splitext(file_path)[1] # 2. Build the final path (this logic is now correct because album_info is correct) if album_info and album_info['is_album']: album_name_sanitized = _sanitize_filename(album_info['album_name']) final_track_name_sanitized = _sanitize_filename(album_info['clean_track_name']) track_number = album_info['track_number'] # Fix: Handle None track_number if track_number is None: print(f"โš ๏ธ Track number is None, extracting from filename: {os.path.basename(file_path)}") track_number = _extract_track_number_from_filename(file_path) print(f" -> Extracted track number: {track_number}") # Ensure track_number is valid if not isinstance(track_number, int) or track_number < 1: print(f"โš ๏ธ Invalid track number ({track_number}), defaulting to 1") track_number = 1 album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}" album_dir = os.path.join(artist_dir, album_folder_name) os.makedirs(album_dir, exist_ok=True) new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}" final_path = os.path.join(album_dir, new_filename) else: final_track_name_sanitized = _sanitize_filename(album_info['clean_track_name']) single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}" single_dir = os.path.join(artist_dir, single_folder_name) os.makedirs(single_dir, exist_ok=True) new_filename = f"{final_track_name_sanitized}{file_ext}" final_path = os.path.join(single_dir, new_filename) # 3. Enhance metadata, move file, download art, and cleanup _enhance_file_metadata(file_path, context, spotify_artist, album_info) print(f"๐Ÿšš Moving '{os.path.basename(file_path)}' to '{final_path}'") if os.path.exists(final_path): os.remove(final_path) shutil.move(file_path, final_path) _download_cover_art(album_info, os.path.dirname(final_path)) downloads_path = config_manager.get('soulseek.download_path', './downloads') _cleanup_empty_directories(downloads_path, file_path) print(f"โœ… Post-processing complete for: {final_path}") except Exception as e: import traceback print(f"\nโŒ CRITICAL ERROR in post-processing for {context_key}: {e}") traceback.print_exc() # Remove from processed set so it can be retried if context_key in _processed_download_ids: _processed_download_ids.remove(context_key) print(f"๐Ÿ”„ Removed {context_key} from processed set - will retry on next check") # Re-add to matched context for retry with matched_context_lock: if context_key not in matched_downloads_context: matched_downloads_context[context_key] = context print(f"โ™ป๏ธ Re-added {context_key} to context for retry") # Keep track of processed downloads to avoid re-processing _processed_download_ids = set() @app.route('/api/version-info', methods=['GET']) def get_version_info(): """ Returns version information and release notes, matching the GUI's VersionInfoModal content. This provides the same data that the GUI version modal displays. """ version_data = { "version": "0.65", "title": "What's New in SoulSync", "subtitle": "Version 0.65 - Tidal Playlist Integration", "sections": [ { "title": "๐ŸŽต Complete Tidal Playlist Integration", "description": "Full Tidal playlist support with seamless workflow integration matching YouTube/Spotify functionality", "features": [ "โ€ข Native Tidal API client with OAuth 2.0 authentication and automatic token management", "โ€ข Tidal playlist tab positioned between Spotify and YouTube with identical UI/UX patterns", "โ€ข Advanced playlist card system with persistent state tracking across all phases", "โ€ข Complete discovery workflow: discovering โ†’ discovered โ†’ syncing โ†’ downloading phases", "โ€ข Intelligent track matching using existing Spotify-based algorithms for compatibility", "โ€ข Smart modal routing with proper state persistence (close/cancel behavior)", "โ€ข Full refresh functionality with comprehensive worker cleanup and modal management" ], "usage_note": "Configure Tidal in Settings โ†’ Connections, then discover and sync your Tidal playlists just like Spotify!" }, { "title": "โš™๏ธ Advanced Workflow Features", "description": "Sophisticated state management and user experience improvements", "features": [ "โ€ข Identical workflow behavior across all playlist sources (Spotify, YouTube, Tidal)", "โ€ข Smart refresh system that cancels all active operations and preserves playlist names", "โ€ข Phase-aware card clicking: routes to discovery, sync progress, or download modals appropriately", "โ€ข Proper modal state persistence: closing download modals preserves discovery state", "โ€ข Cancel operations reset playlists to fresh state for updated playlist data", "โ€ข Multi-server compatibility: works with both Plex and Jellyfin automatically" ] }, { "title": "๐Ÿ”ง Technical Implementation Details", "description": "Robust architecture ensuring reliable playlist management across all sources", "features": [ "โ€ข Implemented comprehensive state tracking system with playlist card hub architecture", "โ€ข Added PKCE (Proof Key for Code Exchange) OAuth flow for enhanced Tidal security", "โ€ข Created unified modal system supporting YouTube, Spotify, and Tidal workflows", "โ€ข Enhanced worker cancellation system for proper resource cleanup during operations", "โ€ข JSON:API response parsing for Tidal's complex relationship-based data structure", "โ€ข Future-ready architecture for additional music streaming service integrations" ] } ] } return jsonify(version_data) def _simple_monitor_task(): """The actual monitoring task that runs in the background thread.""" print("๐Ÿ”„ Simple background monitor started") while True: try: with matched_context_lock: pending_count = len(matched_downloads_context) if pending_count > 0: # Use app_context to safely call endpoint logic from a thread with app.app_context(): get_download_status() time.sleep(1) except Exception as e: print(f"โŒ Simple monitor error: {e}") time.sleep(10) def start_simple_background_monitor(): """Starts the simple background monitor thread.""" monitor_thread = threading.Thread(target=_simple_monitor_task) monitor_thread.daemon = True monitor_thread.start() # =============================== # == DATABASE UPDATER API == # =============================== def _db_update_progress_callback(current_item, processed, total, percentage): with db_update_lock: db_update_state.update({ "current_item": current_item, "processed": processed, "total": total, "progress": percentage }) def _db_update_phase_callback(phase): with db_update_lock: db_update_state["phase"] = phase def _db_update_finished_callback(total_artists, total_albums, total_tracks, successful, failed): with db_update_lock: db_update_state["status"] = "finished" db_update_state["phase"] = f"Completed: {successful} successful, {failed} failed." def _db_update_error_callback(error_message): with db_update_lock: db_update_state["status"] = "error" db_update_state["error_message"] = error_message def _run_db_update_task(full_refresh, server_type): """The actual function that runs in the background thread.""" global db_update_worker media_client = None if server_type == "plex": media_client = plex_client elif server_type == "jellyfin": media_client = jellyfin_client if not media_client: _db_update_error_callback(f"Media client for '{server_type}' not available.") return with db_update_lock: db_update_worker = DatabaseUpdateWorker( media_client=media_client, full_refresh=full_refresh, server_type=server_type ) # Connect signals to callbacks db_update_worker.progress_updated.connect(_db_update_progress_callback) db_update_worker.phase_changed.connect(_db_update_phase_callback) db_update_worker.finished.connect(_db_update_finished_callback) db_update_worker.error.connect(_db_update_error_callback) # This is a blocking call that runs the QThread's logic db_update_worker.run() @app.route('/api/database/stats', methods=['GET']) def get_database_stats(): """Endpoint to get current database statistics.""" try: # This logic is adapted from DatabaseStatsWorker db = get_database() stats = db.get_database_info_for_server() return jsonify(stats) except Exception as e: print(f"Error getting database stats: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/database/update', methods=['POST']) def start_database_update(): """Endpoint to start the database update process.""" global db_update_worker with db_update_lock: if db_update_state["status"] == "running": return jsonify({"success": False, "error": "An update is already in progress."}), 409 data = request.get_json() full_refresh = data.get('full_refresh', False) active_server = config_manager.get_active_media_server() db_update_state.update({ "status": "running", "phase": "Initializing...", "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "" }) # Submit the worker function to the executor db_update_executor.submit(_run_db_update_task, full_refresh, active_server) return jsonify({"success": True, "message": "Database update started."}) @app.route('/api/database/update/status', methods=['GET']) def get_database_update_status(): """Endpoint to poll for the current update status.""" with db_update_lock: return jsonify(db_update_state) @app.route('/api/database/update/stop', methods=['POST']) def stop_database_update(): """Endpoint to stop the current database update.""" global db_update_worker with db_update_lock: if db_update_worker and db_update_state["status"] == "running": db_update_worker.stop() db_update_state["status"] = "finished" db_update_state["phase"] = "Update stopped by user." return jsonify({"success": True, "message": "Stop request sent."}) else: return jsonify({"success": False, "error": "No update is currently running."}), 404 # =============================== # == SYNC PAGE API == # =============================== def _load_sync_status_file(): """Helper function to read the sync status JSON file.""" # Storage folder is at the same level as web_server.py status_file = os.path.join(os.path.dirname(__file__), 'storage', 'sync_status.json') print(f"๐Ÿ” Loading sync status from: {status_file}") if not os.path.exists(status_file): print(f"โŒ Sync status file does not exist: {status_file}") return {} try: with open(status_file, 'r') as f: content = f.read() if not content: print(f"โš ๏ธ Sync status file is empty") return {} data = json.loads(content) print(f"โœ… Loaded {len(data)} sync statuses from file") for playlist_id, status in list(data.items())[:3]: # Show first 3 print(f" - {playlist_id}: {status.get('name', 'N/A')} -> {status.get('last_synced', 'N/A')}") return data except (json.JSONDecodeError, FileNotFoundError) as e: print(f"โŒ Error loading sync status: {e}") return {} def _save_sync_status_file(sync_statuses): """Helper function to save the sync status JSON file.""" try: # Storage folder is at the same level as web_server.py storage_dir = os.path.join(os.path.dirname(__file__), 'storage') os.makedirs(storage_dir, exist_ok=True) status_file = os.path.join(storage_dir, 'sync_status.json') with open(status_file, 'w') as f: json.dump(sync_statuses, f, indent=4) print(f"โœ… Sync status saved to {status_file}") except Exception as e: print(f"โŒ Error saving sync status: {e}") def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, snapshot_id): """Updates the sync status for a given playlist and saves to file (same logic as GUI).""" try: # Load existing sync statuses sync_statuses = _load_sync_status_file() # Update this playlist's sync status from datetime import datetime now = datetime.now() sync_statuses[playlist_id] = { 'name': playlist_name, 'owner': playlist_owner, 'snapshot_id': snapshot_id, 'last_synced': now.isoformat() } # Save to file _save_sync_status_file(sync_statuses) print(f"๐Ÿ”„ Updated sync status for playlist '{playlist_name}' (ID: {playlist_id})") except Exception as e: print(f"โŒ Error updating sync status for {playlist_id}: {e}") @app.route('/api/spotify/playlists', methods=['GET']) def get_spotify_playlists(): """Fetches all user playlists from Spotify and enriches them with local sync status.""" if not spotify_client or not spotify_client.is_authenticated(): return jsonify({"error": "Spotify not authenticated."}), 401 try: playlists = spotify_client.get_user_playlists_metadata_only() sync_statuses = _load_sync_status_file() playlist_data = [] for p in playlists: status_info = sync_statuses.get(p.id, {}) sync_status = "Never Synced" # Handle snapshot_id safely - may not exist in core Playlist class playlist_snapshot = getattr(p, 'snapshot_id', '') print(f"๐Ÿ” Processing playlist: {p.name} (ID: {p.id})") print(f" - Playlist snapshot: '{playlist_snapshot}'") print(f" - Status info: {status_info}") if 'last_synced' in status_info: stored_snapshot = status_info.get('snapshot_id') last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M') print(f" - Stored snapshot: '{stored_snapshot}'") print(f" - Snapshots match: {playlist_snapshot == stored_snapshot}") if playlist_snapshot != stored_snapshot: sync_status = f"Last Sync: {last_sync_time}" print(f" - Result: Needs Sync (showing: {sync_status})") else: sync_status = f"Synced: {last_sync_time}" print(f" - Result: {sync_status}") else: print(f" - No last_synced found - Never Synced") playlist_data.append({ "id": p.id, "name": p.name, "owner": p.owner, "track_count": p.total_tracks, "image_url": getattr(p, 'image_url', None), "sync_status": sync_status, "snapshot_id": playlist_snapshot }) return jsonify(playlist_data) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/spotify/playlist/<playlist_id>', methods=['GET']) def get_playlist_tracks(playlist_id): """Fetches full track details for a specific playlist.""" if not spotify_client or not spotify_client.is_authenticated(): return jsonify({"error": "Spotify not authenticated."}), 401 try: # This reuses the robust track fetching logic from your GUI's sync.py full_playlist = spotify_client.get_playlist_by_id(playlist_id) if not full_playlist: return jsonify({}) # Convert playlist to dict manually since core class doesn't have to_dict method playlist_dict = { 'id': full_playlist.id, 'name': full_playlist.name, 'description': full_playlist.description, 'owner': full_playlist.owner, 'public': full_playlist.public, 'collaborative': full_playlist.collaborative, 'track_count': full_playlist.total_tracks, 'image_url': getattr(full_playlist, 'image_url', None), 'snapshot_id': getattr(full_playlist, 'snapshot_id', ''), 'tracks': [{'id': t.id, 'name': t.name, 'artists': t.artists, 'album': t.album, 'duration_ms': t.duration_ms, 'popularity': t.popularity} for t in full_playlist.tracks] } return jsonify(playlist_dict) except Exception as e: return jsonify({"error": str(e)}), 500 # Add these new endpoints to the end of web_server.py def _run_sync_task(playlist_id, playlist_name, tracks_json): """The actual sync function that runs in the background thread.""" global sync_states, sync_service print(f"๐Ÿš€ _run_sync_task STARTED for playlist '{playlist_name}' (ID: {playlist_id})") print(f"๐Ÿ“Š Received {len(tracks_json)} tracks from frontend") try: # Recreate a Playlist object from the JSON data sent by the frontend # This avoids needing to re-fetch it from Spotify print(f"๐Ÿ”„ Converting JSON tracks to SpotifyTrack objects...") tracks = [] for i, t in enumerate(tracks_json): # Create SpotifyTrack objects with proper default values for missing fields track = SpotifyTrack( id=t.get('id', ''), # Provide default empty string name=t.get('name', ''), artists=t.get('artists', []), album=t.get('album', ''), duration_ms=t.get('duration_ms', 0), popularity=t.get('popularity', 0), # Default value preview_url=t.get('preview_url'), external_urls=t.get('external_urls') ) tracks.append(track) if i < 3: # Log first 3 tracks for debugging print(f" Track {i+1}: '{track.name}' by {track.artists}") print(f"โœ… Created {len(tracks)} SpotifyTrack objects") playlist = SpotifyPlaylist( id=playlist_id, name=playlist_name, description=None, # Not needed for sync owner="web_user", # Placeholder public=False, # Default collaborative=False, # Default tracks=tracks, total_tracks=len(tracks) ) print(f"โœ… Created SpotifyPlaylist object: '{playlist.name}' with {playlist.total_tracks} tracks") def progress_callback(progress): """Callback to update the shared state.""" print(f"โšก PROGRESS CALLBACK: {progress.current_step} - {progress.current_track}") print(f" ๐Ÿ“Š Progress: {progress.progress}% ({progress.matched_tracks}/{progress.total_tracks} matched, {progress.failed_tracks} failed)") with sync_lock: sync_states[playlist_id] = { "status": "syncing", "progress": progress.__dict__ # Convert dataclass to dict } print(f" โœ… Updated sync_states for {playlist_id}") except Exception as setup_error: print(f"โŒ SETUP ERROR in _run_sync_task: {setup_error}") import traceback traceback.print_exc() with sync_lock: sync_states[playlist_id] = { "status": "error", "error": f"Setup error: {str(setup_error)}" } return try: print(f"๐Ÿ”ง Setting up sync service...") print(f" sync_service available: {sync_service is not None}") if sync_service is None: raise Exception("sync_service is None - not initialized properly") # Check sync service components print(f" spotify_client: {sync_service.spotify_client is not None}") print(f" plex_client: {sync_service.plex_client is not None}") print(f" jellyfin_client: {sync_service.jellyfin_client is not None}") # Check media server connection before starting from config.settings import config_manager active_server = config_manager.get_active_media_server() print(f" Active media server: {active_server}") media_client, server_type = sync_service._get_active_media_client() print(f" Media client available: {media_client is not None}") if media_client: is_connected = media_client.is_connected() print(f" Media client connected: {is_connected}") # Check database access try: from database.music_database import MusicDatabase db = MusicDatabase() print(f" Database initialized: {db is not None}") except Exception as db_error: print(f" โŒ Database initialization failed: {db_error}") print(f"๐Ÿ”„ Attaching progress callback...") # Attach the progress callback sync_service.set_progress_callback(progress_callback, playlist.name) print(f"โœ… Progress callback attached for playlist: {playlist.name}") # CRITICAL FIX: Add database-only fallback for web context # If media client is not connected, patch the sync service to use database-only matching if media_client is None or not media_client.is_connected(): print(f"โš ๏ธ Media client not connected - patching sync service for database-only matching") # Store original method original_find_track = sync_service._find_track_in_media_server # Create database-only replacement method async def database_only_find_track(spotify_track): print(f"๐Ÿ—ƒ๏ธ Database-only search for: '{spotify_track.name}' by {spotify_track.artists}") try: from database.music_database import MusicDatabase from config.settings import config_manager db = MusicDatabase() active_server = config_manager.get_active_media_server() original_title = spotify_track.name # Try each artist (same logic as original) for artist in spotify_track.artists: artist_name = artist if isinstance(artist, str) else str(artist) db_track, confidence = db.check_track_exists( original_title, artist_name, confidence_threshold=0.7, server_source=active_server ) if db_track and confidence >= 0.7: print(f"โœ… Database match: '{db_track.title}' (confidence: {confidence:.2f})") # Create mock track object for playlist creation class DatabaseTrackMock: def __init__(self, db_track): self.ratingKey = db_track.id self.title = db_track.title self.id = db_track.id # Add any other attributes needed for playlist creation return DatabaseTrackMock(db_track), confidence print(f"โŒ No database match found for: '{original_title}'") return None, 0.0 except Exception as e: print(f"โŒ Database search error: {e}") return None, 0.0 # Patch the method sync_service._find_track_in_media_server = database_only_find_track print(f"โœ… Patched sync service to use database-only matching") print(f"๐Ÿš€ Starting actual sync process with asyncio.run()...") # Run the sync (this is a blocking call within this thread) result = asyncio.run(sync_service.sync_playlist(playlist, download_missing=False)) print(f"โœ… Sync process completed! Result type: {type(result)}") print(f" Result details: matched={getattr(result, 'matched_tracks', 'N/A')}, total={getattr(result, 'total_tracks', 'N/A')}") # Update final state on completion with sync_lock: sync_states[playlist_id] = { "status": "finished", "result": result.__dict__ # Convert dataclass to dict } print(f"๐Ÿ Sync finished for {playlist_id} - state updated") # Save sync status to storage/sync_status.json (same as GUI) # Handle snapshot_id safely - may not exist in all playlist objects snapshot_id = getattr(playlist, 'snapshot_id', None) _update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id) except Exception as e: print(f"โŒ SYNC FAILED for {playlist_id}: {e}") import traceback traceback.print_exc() with sync_lock: sync_states[playlist_id] = { "status": "error", "error": str(e) } finally: print(f"๐Ÿงน Cleaning up progress callback for {playlist.name}") # Clean up the callback if sync_service: sync_service.clear_progress_callback(playlist.name) print(f"โœ… Cleanup completed for {playlist_id}") @app.route('/api/sync/start', methods=['POST']) def start_playlist_sync(): """Starts a new sync process for a given playlist.""" data = request.get_json() playlist_id = data.get('playlist_id') playlist_name = data.get('playlist_name') tracks_json = data.get('tracks') # Pass the full track list if not all([playlist_id, playlist_name, tracks_json]): return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400 with sync_lock: if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done(): return jsonify({"success": False, "error": "Sync is already in progress for this playlist."}), 409 # Initial state sync_states[playlist_id] = {"status": "starting", "progress": {}} # Submit the task to the thread pool future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json) active_sync_workers[playlist_id] = future return jsonify({"success": True, "message": "Sync started."}) @app.route('/api/sync/status/<playlist_id>', methods=['GET']) def get_sync_status(playlist_id): """Polls for the status of an ongoing sync.""" with sync_lock: state = sync_states.get(playlist_id) if not state: return jsonify({"status": "not_found"}), 404 # If the task is finished but the state hasn't been updated, check the future if state['status'] not in ['finished', 'error'] and playlist_id in active_sync_workers: if active_sync_workers[playlist_id].done(): # The task might have finished between polls, trigger final state update # This is handled by the _run_sync_task itself pass return jsonify(state) @app.route('/api/sync/cancel', methods=['POST']) def cancel_playlist_sync(): """Cancels an ongoing sync process.""" data = request.get_json() playlist_id = data.get('playlist_id') if not playlist_id: return jsonify({"success": False, "error": "Missing playlist_id."}), 400 with sync_lock: future = active_sync_workers.get(playlist_id) if not future or future.done(): return jsonify({"success": False, "error": "Sync not running or already complete."}), 404 # The GUI's sync_service has a cancel_sync method. We'll replicate that idea. # Since we can't easily stop the thread, we'll set a flag. # The elegant solution is to have the sync_service check for a cancellation flag. # Your `sync_service.py` already has this logic with `self._cancelled`. sync_service.cancel_sync() # We can't guarantee immediate stop, but we can update the state sync_states[playlist_id] = {"status": "cancelled"} # It's best practice to let the task finish and clean itself up. # We don't use future.cancel() as it may not work if the task is already running. return jsonify({"success": True, "message": "Sync cancellation requested."}) @app.route('/api/sync/test-database', methods=['GET']) def test_database_access(): """Test endpoint to verify database connectivity for sync operations""" try: print(f"๐Ÿงช Testing database access for sync operations...") # Test database initialization from database.music_database import MusicDatabase db = MusicDatabase() print(f" โœ… Database initialized: {db is not None}") # Test basic database query stats = db.get_database_info_for_server() print(f" โœ… Database stats retrieved: {stats}") # Test track existence check (like sync service does) db_track, confidence = db.check_track_exists("test track", "test artist", confidence_threshold=0.7) print(f" โœ… Track existence check works: found={db_track is not None}, confidence={confidence}") # Test config manager from config.settings import config_manager active_server = config_manager.get_active_media_server() print(f" โœ… Active media server: {active_server}") # Test media clients print(f" Media clients status:") print(f" plex_client: {plex_client is not None}") if plex_client: print(f" plex_client.is_connected(): {plex_client.is_connected()}") print(f" jellyfin_client: {jellyfin_client is not None}") if jellyfin_client: print(f" jellyfin_client.is_connected(): {jellyfin_client.is_connected()}") return jsonify({ "success": True, "message": "Database access test successful", "details": { "database_initialized": db is not None, "database_stats": stats, "active_server": active_server, "plex_connected": plex_client.is_connected() if plex_client else False, "jellyfin_connected": jellyfin_client.is_connected() if jellyfin_client else False, } }) except Exception as e: print(f" โŒ Database test failed: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e), "message": "Database access test failed" }), 500 # --- Main Execution --- if __name__ == '__main__': print("๐Ÿš€ Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:5001") # Start simple background monitor when server starts print("๐Ÿ”ง Starting simple background monitor...") start_simple_background_monitor() print("โœ… Simple background monitor started") app.run(host='0.0.0.0', port=5001, debug=True)