You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/web_server.py

2861 lines
122 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 "<h1>✅ Tidal Authentication Successful!</h1><p>You can now close this window and return to the SoulSync application.</p>"
else:
return "<h1>❌ Tidal Authentication Failed</h1><p>Please check the console output of the server for a login URL and follow the instructions.</p>", 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"<h1>Tidal Authentication Failed</h1><p>Error: {error}</p><p>{error_description}</p><p>Please close this window and try again.</p>", 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 "<h1>✅ Tidal Authentication Successful!</h1><p>You can now close this window and return to the SoulSync application.</p>"
else:
return "<h1>❌ Tidal Authentication Failed</h1><p>Could not exchange authorization code for a token. Please try again.</p>", 400
except Exception as e:
print(f"🔴 Error during Tidal token exchange: {e}")
return f"<h1>❌ An Error Occurred</h1><p>An unexpected error occurred during the authentication process: {e}</p>", 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<track_number>\d{1,2})\s*[-\.]\s*(?P<artist>.+?)\s*[-]\s*(?P<title>.+)$',
# 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)