soulsync/web_server.py
2025-08-22 15:20:39 -07:00

1203 lines
50 KiB
Python

import os
import json
import asyncio
import requests
import socket
import ipaddress
import subprocess
import platform
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect
# --- Core Application Imports ---
# Import the same core clients and config manager used by the GUI app
from config.settings import config_manager
from core.spotify_client import SpotifyClient
from core.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
from core.tidal_client import TidalClient # Added import for Tidal
# --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__))
# Correctly point to the 'webui' directory for templates and static files
app = Flask(
__name__,
template_folder=os.path.join(base_dir, 'webui'),
static_folder=os.path.join(base_dir, 'webui', 'static')
)
# --- Initialize Core Application Components ---
print("🚀 Initializing SoulSync services for Web UI...")
try:
spotify_client = SpotifyClient()
plex_client = PlexClient()
jellyfin_client = JellyfinClient()
soulseek_client = SoulseekClient()
tidal_client = TidalClient()
print("✅ Core service clients initialized.")
except Exception as e:
print(f"🔴 FATAL: Error initializing service clients: {e}")
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = None
# --- Refactored Logic from GUI Threads ---
# This logic is extracted from your QThread classes to be used directly by Flask.
def run_service_test(service, test_config):
"""
Performs the actual connection test for a given service.
This logic is adapted from your ServiceTestThread.
It temporarily modifies the config, runs the test, then restores the config.
"""
original_config = {}
try:
# 1. Save original config for the specific service
original_config = config_manager.get(service, {})
# 2. Temporarily set the new config for the test
for key, value in test_config.items():
config_manager.set(f"{service}.{key}", value)
# 3. Run the test with the temporary config
if service == "spotify":
temp_client = SpotifyClient()
if temp_client.is_authenticated():
return True, "Spotify connection successful!"
else:
return False, "Spotify authentication failed. Check credentials and complete OAuth flow in browser if prompted."
elif service == "tidal":
temp_client = TidalClient()
if temp_client.is_authenticated():
user_info = temp_client.get_user_info()
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
return True, f"Tidal connection successful! Connected as: {username}"
else:
return False, "Tidal authentication failed. Please use the 'Authenticate' button and complete the flow in your browser."
elif service == "plex":
temp_client = PlexClient()
if temp_client.is_connected():
return True, f"Successfully connected to Plex server: {temp_client.server.friendlyName}"
else:
return False, "Could not connect to Plex. Check URL and Token."
elif service == "jellyfin":
temp_client = JellyfinClient()
if temp_client.is_connected():
# FIX: Check if server_info exists before accessing it.
server_name = "Unknown Server"
if hasattr(temp_client, 'server_info') and temp_client.server_info:
server_name = temp_client.server_info.get('ServerName', 'Unknown Server')
return True, f"Successfully connected to Jellyfin server: {server_name}"
else:
return False, "Could not connect to Jellyfin. Check URL and API Key."
elif service == "soulseek":
temp_client = SoulseekClient()
async def check():
return await temp_client.check_connection()
if asyncio.run(check()):
return True, "Successfully connected to slskd."
else:
return False, "Could not connect to slskd. Check URL and API Key."
return False, "Unknown service."
except AttributeError as e:
# This specifically catches the error you reported for Jellyfin
if "'JellyfinClient' object has no attribute 'server_info'" in str(e):
return False, "Connection failed. Please check your Jellyfin URL and API Key."
else:
return False, f"An unexpected error occurred: {e}"
except Exception as e:
import traceback
traceback.print_exc()
return False, str(e)
finally:
# 4. CRITICAL: Restore the original config
if original_config:
for key, value in original_config.items():
config_manager.set(f"{service}.{key}", value)
print(f"✅ Restored original config for '{service}' after test.")
def run_detection(server_type):
"""
Performs comprehensive network detection for a given server type (plex, jellyfin, slskd).
This implements the same scanning logic as the GUI's detection threads.
"""
print(f"Running comprehensive detection for {server_type}...")
def get_network_info():
"""Get comprehensive network information with subnet detection"""
try:
# Get local IP using socket method
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
# Try to get actual subnet mask
try:
if platform.system() == "Windows":
# Windows: Use netsh to get subnet info
result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'],
capture_output=True, text=True, timeout=3)
# Parse output for subnet mask (simplified)
subnet_mask = "255.255.255.0" # Default fallback
else:
# Linux/Mac: Try to parse network interfaces
result = subprocess.run(['ip', 'route', 'show'],
capture_output=True, text=True, timeout=3)
subnet_mask = "255.255.255.0" # Default fallback
except:
subnet_mask = "255.255.255.0" # Default /24
# Calculate network range
network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False)
return str(network.network_address), str(network.netmask), local_ip, network
except Exception as e:
# Fallback to original method
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
# Default to /24 network
network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False)
return str(network.network_address), "255.255.255.0", local_ip, network
def test_plex_server(ip, port=32400):
"""Test if a Plex server is running at the given IP and port"""
try:
url = f"http://{ip}:{port}/web/index.html"
response = requests.get(url, timeout=2, allow_redirects=True)
# Check for Plex-specific indicators
if response.status_code == 200:
# Check if it's actually Plex
if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers):
return f"http://{ip}:{port}"
# Also try the API endpoint
api_url = f"http://{ip}:{port}/identity"
api_response = requests.get(api_url, timeout=1)
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
return f"http://{ip}:{port}"
except:
pass
return None
def test_jellyfin_server(ip, port=8096):
"""Test if a Jellyfin server is running at the given IP and port"""
try:
# Try the system info endpoint first
url = f"http://{ip}:{port}/System/Info"
response = requests.get(url, timeout=2, allow_redirects=True)
if response.status_code == 200:
# Check if response contains Jellyfin-specific content
if 'jellyfin' in response.text.lower() or 'ServerName' in response.text:
return f"http://{ip}:{port}"
# Also try the web interface
web_url = f"http://{ip}:{port}/web/index.html"
web_response = requests.get(web_url, timeout=1)
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
return f"http://{ip}:{port}"
except:
pass
return None
def test_slskd_server(ip, port=5030):
"""Test if a slskd server is running at the given IP and port"""
try:
# slskd specific API endpoint
url = f"http://{ip}:{port}/api/v0/session"
response = requests.get(url, timeout=2)
# slskd returns 401 when not authenticated, which is still a valid response
if response.status_code in [200, 401]:
return f"http://{ip}:{port}"
except:
pass
return None
try:
network_addr, netmask, local_ip, network = get_network_info()
# Select the appropriate test function
test_functions = {
'plex': test_plex_server,
'jellyfin': test_jellyfin_server,
'slskd': test_slskd_server
}
test_func = test_functions.get(server_type)
if not test_func:
return None
# Priority 1: Test localhost first
print(f"Testing localhost for {server_type}...")
localhost_result = test_func("localhost")
if localhost_result:
print(f"Found {server_type} at localhost!")
return localhost_result
# Priority 2: Test local IP
print(f"Testing local IP {local_ip} for {server_type}...")
local_result = test_func(local_ip)
if local_result:
print(f"Found {server_type} at {local_ip}!")
return local_result
# Priority 3: Test common IPs (router gateway, etc.)
common_ips = [
local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway
local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway
local_ip.rsplit('.', 1)[0] + '.100', # Common static IP
]
print(f"Testing common IPs for {server_type}...")
for ip in common_ips:
print(f" Checking {ip}...")
result = test_func(ip)
if result:
print(f"Found {server_type} at {ip}!")
return result
# Priority 4: Scan the network range (limited to reasonable size)
network_hosts = list(network.hosts())
if len(network_hosts) > 50:
# Limit scan to reasonable size for performance
step = max(1, len(network_hosts) // 50)
network_hosts = network_hosts[::step]
print(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...")
# Use ThreadPoolExecutor for concurrent scanning (limited for web context)
with ThreadPoolExecutor(max_workers=5) as executor:
# Submit all tasks
future_to_ip = {executor.submit(test_func, str(ip)): str(ip)
for ip in network_hosts}
try:
for future in as_completed(future_to_ip):
ip = future_to_ip[future]
try:
result = future.result()
if result:
print(f"Found {server_type} at {ip}!")
# Cancel all pending futures before returning
for f in future_to_ip:
if not f.done():
f.cancel()
return result
except Exception as e:
print(f"Error testing {ip}: {e}")
continue
except Exception as e:
print(f"Error in concurrent scanning: {e}")
print(f"No {server_type} server found on network")
return None
except Exception as e:
print(f"Error during {server_type} detection: {e}")
return None
# --- Web UI Routes ---
@app.route('/')
def index():
return render_template('index.html')
# --- API Endpoints ---
@app.route('/status')
def get_status():
if not all([spotify_client, plex_client, jellyfin_client, soulseek_client, config_manager]):
return jsonify({"error": "Core services not initialized."}), 500
try:
active_server = config_manager.get_active_media_server()
media_server_status = False
if active_server == "plex":
media_server_status = plex_client.is_connected()
elif active_server == "jellyfin":
media_server_status = jellyfin_client.is_connected()
status_data = {
'spotify': spotify_client.is_authenticated(),
'media_server': media_server_status,
'soulseek': soulseek_client.is_configured(),
'active_media_server': active_server
}
return jsonify(status_data)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/settings', methods=['GET', 'POST'])
def handle_settings():
global tidal_client # Declare that we might modify the global instance
if not config_manager:
return jsonify({"error": "Server configuration manager is not initialized."}), 500
if request.method == 'POST':
try:
new_settings = request.get_json()
if not new_settings:
return jsonify({"success": False, "error": "No data received."}), 400
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'playlist_sync', 'tidal']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
print("✅ Settings saved successfully via Web UI.")
spotify_client._setup_client()
plex_client.server = None
jellyfin_client.server = None
soulseek_client._setup_client()
# FIX: Re-instantiate the global tidal_client to pick up new settings
tidal_client = TidalClient()
print("✅ Service clients re-initialized with new settings.")
return jsonify({"success": True, "message": "Settings saved successfully."})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
else: # GET request
try:
return jsonify(config_manager.config_data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/test-connection', methods=['POST'])
def test_connection_endpoint():
data = request.get_json()
service = data.get('service')
if not service:
return jsonify({"success": False, "error": "No service specified."}), 400
print(f"Received test connection request for: {service}")
# Get the current settings from the main config manager to test with
test_config = config_manager.get(service, {})
# For media servers, the service name might be 'server'
if service == 'server':
active_server = config_manager.get_active_media_server()
test_config = config_manager.get(active_server, {})
service = active_server # use the actual server name for the test
success, message = run_service_test(service, test_config)
return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""})
@app.route('/api/detect-media-server', methods=['POST'])
def detect_media_server_endpoint():
data = request.get_json()
server_type = data.get('server_type')
print(f"Received auto-detect request for: {server_type}")
found_url = run_detection(server_type)
if found_url:
return jsonify({"success": True, "found_url": found_url})
else:
return jsonify({"success": False, "error": f"No {server_type} server found on common local addresses."})
@app.route('/api/detect-soulseek', methods=['POST'])
def detect_soulseek_endpoint():
print("Received auto-detect request for slskd")
found_url = run_detection('slskd')
if found_url:
return jsonify({"success": True, "found_url": found_url})
else:
return jsonify({"success": False, "error": "No slskd server found on common local addresses."})
# --- Full Tidal Authentication Flow ---
@app.route('/auth/tidal')
def auth_tidal():
"""
Initiates the Tidal OAuth authentication flow by calling the client's
authenticate method, which should handle opening the browser.
This now mirrors the GUI's approach.
"""
# FIX: Create a fresh client instance to ensure it uses the latest settings from config.json
temp_tidal_client = TidalClient()
if not temp_tidal_client:
return "Tidal client could not be initialized on the server.", 500
# The authenticate() method in your GUI likely opens a browser and blocks.
# The web server will also block here until authentication is complete.
# The user will see the URL to visit in the console where the server is running.
print(" tidal_client.authenticate() to start the flow.")
print("Please follow the instructions in the console to log in to Tidal.")
if temp_tidal_client.authenticate():
# Re-initialize the main client instance after successful auth
global tidal_client
tidal_client = TidalClient()
return "<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():
"""
Perform real Soulseek search using the actual soulseek_client.
Returns progressive search results matching the GUI's SearchThread implementation.
"""
if not soulseek_client:
return jsonify({"error": "Soulseek client not initialized"}), 500
data = request.get_json()
query = data.get('query', '').strip()
if not query:
return jsonify({"error": "No search query provided"}), 400
print(f"🔍 Starting Soulseek search for: '{query}'")
try:
import asyncio
# Create new event loop for this request
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Perform the actual search using soulseek_client
results = loop.run_until_complete(soulseek_client.search(query))
# Process results to match frontend expectations
if isinstance(results, tuple) and len(results) == 2:
tracks, albums = results
else:
# Fallback for backward compatibility
tracks = results if isinstance(results, list) else []
albums = []
# Convert track results to JSON-serializable format
tracks_json = []
for track in tracks:
tracks_json.append({
"type": "track",
"title": getattr(track, 'title', 'Unknown Title'),
"artist": getattr(track, 'artist', 'Unknown Artist'),
"album": getattr(track, 'album', 'Unknown Album'),
"quality": getattr(track, 'quality', 'Unknown'),
"bitrate": getattr(track, 'bitrate', None),
"duration": getattr(track, 'duration', None),
"filename": getattr(track, 'filename', ''),
"username": getattr(track, 'username', ''),
"file_size": getattr(track, 'file_size', 0),
"search_result_data": {
# Store the original object data for download purposes
"filename": getattr(track, 'filename', ''),
"username": getattr(track, 'username', ''),
"file_size": getattr(track, 'file_size', 0),
}
})
# Convert album results to JSON-serializable format
albums_json = []
for album in albums:
albums_json.append({
"type": "album",
"title": getattr(album, 'album_name', getattr(album, 'title', 'Unknown Album')),
"artist": getattr(album, 'artist', 'Unknown Artist'),
"track_count": getattr(album, 'track_count', 0),
"username": getattr(album, 'username', ''),
"size_mb": getattr(album, 'total_size', 0) / (1024 * 1024) if hasattr(album, 'total_size') else 0,
"tracks": getattr(album, 'tracks', []),
"search_result_data": {
# Store the original object data for download purposes
"album_name": getattr(album, 'album_name', getattr(album, 'title', '')),
"artist": getattr(album, 'artist', ''),
"username": getattr(album, 'username', ''),
"tracks": getattr(album, 'tracks', [])
}
})
total_results = len(tracks_json) + len(albums_json)
print(f"✅ Search completed: {len(tracks_json)} tracks, {len(albums_json)} albums ({total_results} total)")
return jsonify({
"success": True,
"results": {
"tracks": tracks_json,
"albums": albums_json,
"total_tracks": len(tracks_json),
"total_albums": len(albums_json),
"query": query
}
})
finally:
# Clean up the event loop
try:
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
except Exception as e:
print(f"Error cleaning up search event loop: {e}")
except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Search failed: {e}")
return jsonify({"error": f"Search failed: {str(e)}"}), 500
@app.route('/api/search/cancel', methods=['POST'])
def cancel_search():
"""Cancel any active search operations"""
# Note: In a full implementation, you would track active search operations
# and cancel them here. For now, this is a placeholder.
print("🛑 Search cancellation requested")
return jsonify({"success": True, "message": "Search cancellation requested"})
# Global download tracking
active_downloads = {} # Dict to track active downloads
completed_downloads = [] # List to store completed downloads
@app.route('/api/downloads/start', methods=['POST'])
def start_download():
"""
Start a regular download using the soulseek_client.
This matches the GUI's start_download functionality.
"""
if not soulseek_client:
return jsonify({"error": "Soulseek client not initialized"}), 500
data = request.get_json()
if not data:
return jsonify({"error": "No download data provided"}), 400
try:
# Extract search result data
search_data = data.get('search_result_data', data)
filename = search_data.get('filename')
username = search_data.get('username')
if not filename or not username:
return jsonify({"error": "Missing required download parameters (filename, username)"}), 400
print(f"⬇️ Starting download: '{filename}' from '{username}'")
# Create download item for tracking
download_id = f"{username}_{filename}_{len(active_downloads)}"
download_item = {
"id": download_id,
"title": data.get('title', filename),
"artist": data.get('artist', 'Unknown Artist'),
"filename": filename,
"username": username,
"status": "queued",
"progress": 0,
"file_size": data.get('file_size', 0),
"download_speed": 0,
"eta": None,
"start_time": None,
"spotify_matched": False
}
active_downloads[download_id] = download_item
# Start the actual download using asyncio
import asyncio
import threading
def download_worker():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# This would call the actual soulseek_client.download method
# For now, we'll simulate the download process
result = loop.run_until_complete(simulate_download(download_item))
print(f"✅ Download completed: {download_id}")
except Exception as e:
print(f"❌ Download failed: {download_id} - {e}")
download_item["status"] = "failed"
download_item["error"] = str(e)
finally:
loop.close()
# Start download in background thread
thread = threading.Thread(target=download_worker)
thread.daemon = True
thread.start()
return jsonify({
"success": True,
"download_id": download_id,
"message": f"Download started for '{filename}'"
})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": f"Failed to start download: {str(e)}"}), 500
@app.route('/api/downloads/start-matched', methods=['POST'])
def start_matched_download():
"""
Start a download with confirmed Spotify match data.
This matches the GUI's start_matched_download functionality.
"""
if not soulseek_client:
return jsonify({"error": "Soulseek client not initialized"}), 500
data = request.get_json()
if not data:
return jsonify({"error": "No download data provided"}), 400
try:
# Extract search result data and Spotify match data
search_data = data.get('search_result_data', data)
spotify_match = data.get('spotify_match', {})
filename = search_data.get('filename')
username = search_data.get('username')
if not filename or not username:
return jsonify({"error": "Missing required download parameters"}), 400
matched_artist = spotify_match.get('artist', {})
matched_album = spotify_match.get('album', {})
print(f"⬇️🎵 Starting matched download: '{filename}' from '{username}'")
print(f" 🎤 Matched Artist: {matched_artist.get('name', 'Unknown')}")
print(f" 💿 Matched Album: {matched_album.get('name', 'Unknown')}")
# Create download item for tracking with Spotify match info
download_id = f"{username}_{filename}_{len(active_downloads)}_matched"
download_item = {
"id": download_id,
"title": data.get('title', filename),
"artist": data.get('artist', 'Unknown Artist'),
"filename": filename,
"username": username,
"status": "queued",
"progress": 0,
"file_size": data.get('file_size', 0),
"download_speed": 0,
"eta": None,
"start_time": None,
"spotify_matched": True,
"matched_artist": matched_artist,
"matched_album": matched_album
}
active_downloads[download_id] = download_item
# Start the actual download using asyncio
import asyncio
import threading
def matched_download_worker():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# This would call the actual soulseek_client.download method
# and then apply metadata enhancement with the Spotify match
result = loop.run_until_complete(simulate_matched_download(download_item))
print(f"✅ Matched download completed: {download_id}")
except Exception as e:
print(f"❌ Matched download failed: {download_id} - {e}")
download_item["status"] = "failed"
download_item["error"] = str(e)
finally:
loop.close()
# Start download in background thread
thread = threading.Thread(target=matched_download_worker)
thread.daemon = True
thread.start()
return jsonify({
"success": True,
"download_id": download_id,
"message": f"Matched download started for '{filename}'"
})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": f"Failed to start matched download: {str(e)}"}), 500
@app.route('/api/downloads/status', methods=['GET'])
def get_download_status():
"""
Get the current status of all downloads (active and completed).
This matches the GUI's download queue functionality.
"""
try:
# Get real download status from soulseek_client if available
real_downloads = []
if soulseek_client:
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# This would call soulseek_client.get_all_downloads()
# For now, we'll use our tracked downloads
pass
finally:
loop.close()
except Exception as e:
print(f"Error getting real download status: {e}")
# Separate active and completed downloads
active = []
completed = []
for download_id, download in active_downloads.items():
if download["status"] in ["downloading", "queued"]:
active.append(download)
else:
completed.append(download)
# Add any completed downloads from our completed list
completed.extend(completed_downloads)
return jsonify({
"success": True,
"downloads": {
"active": active,
"completed": completed,
"active_count": len(active),
"completed_count": len(completed)
}
})
except Exception as e:
print(f"Error getting download status: {e}")
return jsonify({"error": f"Failed to get download status: {str(e)}"}), 500
@app.route('/api/downloads/cancel/<download_id>', methods=['POST'])
def cancel_download(download_id):
"""Cancel a specific download"""
if download_id in active_downloads:
download = active_downloads[download_id]
download["status"] = "cancelled"
print(f"🛑 Download cancelled: {download_id}")
return jsonify({"success": True, "message": f"Download {download_id} cancelled"})
else:
return jsonify({"error": "Download not found"}), 404
@app.route('/api/downloads/clear-completed', methods=['POST'])
def clear_completed_downloads():
"""Clear all completed downloads from the queue"""
global completed_downloads, active_downloads
# Remove completed downloads from active_downloads
to_remove = [did for did, download in active_downloads.items()
if download["status"] in ["completed", "failed", "cancelled"]]
for download_id in to_remove:
del active_downloads[download_id]
# Clear completed downloads list
cleared_count = len(completed_downloads)
completed_downloads.clear()
print(f"🗑️ Cleared {cleared_count + len(to_remove)} completed downloads")
return jsonify({
"success": True,
"message": f"Cleared {cleared_count + len(to_remove)} completed downloads"
})
# Helper functions for simulating downloads (replace with real implementations)
async def simulate_download(download_item):
"""Simulate a download process - replace with real soulseek_client.download()"""
import asyncio
import time
download_item["status"] = "downloading"
download_item["start_time"] = time.time()
# Simulate download progress
for progress in range(0, 101, 10):
download_item["progress"] = progress
download_item["download_speed"] = 1024 * 1024 # 1 MB/s simulation
await asyncio.sleep(0.1) # Simulate time
if download_item["status"] == "cancelled":
return False
download_item["status"] = "completed"
download_item["progress"] = 100
# Move to completed downloads
global completed_downloads
completed_downloads.append(download_item.copy())
return True
async def simulate_matched_download(download_item):
"""Simulate a matched download with metadata enhancement"""
# First do the regular download
result = await simulate_download(download_item)
if result and download_item.get("spotify_matched"):
print(f"🎵 Applying metadata enhancement for: {download_item['title']}")
# Here you would apply the Spotify metadata enhancement
# using the matched_artist and matched_album data
download_item["metadata_enhanced"] = True
return result
# ===== SPOTIFY INTEGRATION ENDPOINTS =====
@app.route('/api/spotify/search-artist', methods=['POST'])
def spotify_search_artist():
"""
Search for artists using Spotify API for the matching modal.
This matches the GUI's ArtistSearchThread functionality.
"""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify client not available or not authenticated"}), 500
data = request.get_json()
query = data.get('query', '').strip()
if not query:
return jsonify({"error": "No search query provided"}), 400
try:
print(f"🎵 Searching Spotify for artist: '{query}'")
# Perform artist search using spotify_client
artists = spotify_client.search_artists(query, limit=6) # Limit to 6 for modal display
# Convert artists to JSON format matching frontend expectations
artists_json = []
for artist in artists:
artist_data = {
"id": artist.id,
"name": artist.name,
"genres": getattr(artist, 'genres', []),
"popularity": getattr(artist, 'popularity', 0),
"follower_count": getattr(artist, 'follower_count', 0),
"image_url": getattr(artist, 'image_url', None),
"spotify_url": getattr(artist, 'spotify_url', None),
}
artists_json.append(artist_data)
print(f"✅ Found {len(artists_json)} artists for '{query}'")
return jsonify({
"success": True,
"artists": artists_json,
"query": query
})
except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Spotify artist search failed: {e}")
return jsonify({"error": f"Artist search failed: {str(e)}"}), 500
@app.route('/api/spotify/search-album', methods=['POST'])
def spotify_search_album():
"""
Search for albums by a specific artist using Spotify API.
This matches the GUI's AlbumSearchThread functionality.
"""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify client not available or not authenticated"}), 500
data = request.get_json()
artist_id = data.get('artist_id')
query = data.get('query', '').strip()
if not artist_id:
return jsonify({"error": "No artist ID provided"}), 400
try:
print(f"💿 Searching albums for artist ID: {artist_id}")
# Get albums by artist using spotify_client
albums = spotify_client.get_artist_albums(artist_id, limit=10)
# If query is provided, filter albums by query
if query:
filtered_albums = []
query_lower = query.lower()
for album in albums:
if query_lower in album.name.lower():
filtered_albums.append(album)
albums = filtered_albums
# Convert albums to JSON format
albums_json = []
for album in albums:
album_data = {
"id": album.id,
"name": album.name,
"release_date": getattr(album, 'release_date', ''),
"total_tracks": getattr(album, 'total_tracks', 0),
"album_type": getattr(album, 'album_type', 'album'),
"image_url": getattr(album, 'image_url', None),
"spotify_url": getattr(album, 'spotify_url', None),
"artist": {
"id": artist_id,
"name": getattr(album, 'artist_name', 'Unknown Artist')
}
}
albums_json.append(album_data)
print(f"✅ Found {len(albums_json)} albums for artist {artist_id}")
return jsonify({
"success": True,
"albums": albums_json,
"artist_id": artist_id,
"query": query
})
except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Spotify album search failed: {e}")
return jsonify({"error": f"Album search failed: {str(e)}"}), 500
@app.route('/api/spotify/suggestions', methods=['POST'])
def spotify_generate_suggestions():
"""
Generate artist suggestions for a search result using Spotify API.
This matches the GUI's generate_auto_artist_suggestions functionality.
"""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify client not available or not authenticated"}), 500
data = request.get_json()
original_title = data.get('title', '').strip()
original_artist = data.get('artist', '').strip()
if not original_title and not original_artist:
return jsonify({"error": "No title or artist provided for suggestions"}), 400
try:
print(f"🎯 Generating Spotify suggestions for: '{original_title}' by '{original_artist}'")
suggestions = []
# Strategy 1: Search by artist name if available
if original_artist and original_artist.lower() != 'unknown artist':
try:
artist_results = spotify_client.search_artists(original_artist, limit=3)
suggestions.extend(artist_results)
print(f" Found {len(artist_results)} artist matches")
except Exception as e:
print(f" Artist search failed: {e}")
# Strategy 2: Search by track title to find artist
if original_title and len(suggestions) < 3:
try:
track_results = spotify_client.search_tracks(original_title, limit=5)
for track in track_results:
if hasattr(track, 'artist') and track.artist not in [s for s in suggestions]:
suggestions.append(track.artist)
if len(suggestions) >= 6: # Limit to 6 total suggestions
break
print(f" Found {len(suggestions)} total suggestions from track search")
except Exception as e:
print(f" Track search for suggestions failed: {e}")
# Strategy 3: Combined search if we still need more
if len(suggestions) < 3 and original_artist and original_title:
try:
combined_query = f"{original_artist} {original_title}"
combined_results = spotify_client.search_artists(combined_query, limit=3)
suggestions.extend(combined_results)
print(f" Added {len(combined_results)} from combined search")
except Exception as e:
print(f" Combined search failed: {e}")
# Remove duplicates and convert to JSON
seen_ids = set()
unique_suggestions = []
for artist in suggestions[:6]: # Limit to 6 suggestions
if artist.id not in seen_ids:
seen_ids.add(artist.id)
artist_data = {
"id": artist.id,
"name": artist.name,
"genres": getattr(artist, 'genres', []),
"popularity": getattr(artist, 'popularity', 0),
"follower_count": getattr(artist, 'follower_count', 0),
"image_url": getattr(artist, 'image_url', None),
"confidence_score": 0.8 if artist.name.lower() == original_artist.lower() else 0.6,
"match_reason": "Direct name match" if artist.name.lower() == original_artist.lower() else "Related artist"
}
unique_suggestions.append(artist_data)
print(f"✅ Generated {len(unique_suggestions)} unique suggestions")
return jsonify({
"success": True,
"suggestions": unique_suggestions,
"original_title": original_title,
"original_artist": original_artist
})
except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Spotify suggestions failed: {e}")
return jsonify({"error": f"Failed to generate suggestions: {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():
# Placeholder: simulates starting a media stream
data = request.get_json()
print(f"Simulating stream start for: {data.get('filename')}")
return jsonify({"success": True, "track": data})
@app.route('/api/stream/status')
def stream_status():
# Placeholder: simulates stream status
return jsonify({"status": "playing", "progress": 50, "track": {"title": "Bohemian Rhapsody"}})
@app.route('/api/stream/toggle', methods=['POST'])
def stream_toggle():
# Placeholder: simulates toggling play/pause
return jsonify({"playing": False})
@app.route('/api/stream/stop', methods=['POST'])
def stream_stop():
# Placeholder: simulates stopping a stream
return jsonify({"success": True})
@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)
# --- Main Execution ---
if __name__ == '__main__':
print("🚀 Starting SoulSync Web UI Server...")
print("Open your browser and navigate to http://127.0.0.1:5001")
app.run(host='0.0.0.0', port=5001, debug=True)