404 lines
17 KiB
Python
404 lines
17 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 network detection for a given server type (plex, jellyfin, slskd).
|
|
This is a blocking function that scans the network.
|
|
"""
|
|
# This is a simplified version of the logic in your QThreads.
|
|
# A full implementation would be more extensive.
|
|
# For demonstration, we'll check common local addresses.
|
|
print(f"Running detection for {server_type}...")
|
|
common_ips = ["localhost", "127.0.0.1"]
|
|
ports = {
|
|
'plex': 32400,
|
|
'jellyfin': 8096,
|
|
'slskd': 5030
|
|
}
|
|
port = ports.get(server_type)
|
|
if not port:
|
|
return None
|
|
|
|
for ip in common_ips:
|
|
url = f"http://{ip}:{port}"
|
|
try:
|
|
if server_type == 'slskd':
|
|
# slskd check is different
|
|
response = requests.get(f"{url}/api/v0/session", timeout=1)
|
|
if response.status_code in [200, 401]:
|
|
print(f"Found {server_type} at {url}")
|
|
return url
|
|
else:
|
|
response = requests.get(url, timeout=1)
|
|
# A simple 200 OK is a good sign
|
|
if response.status_code == 200:
|
|
print(f"Found {server_type} at {url}")
|
|
return url
|
|
except requests.RequestException:
|
|
continue
|
|
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():
|
|
# Placeholder: simulates a music search
|
|
data = request.get_json()
|
|
query = data.get('query', '')
|
|
print(f"Simulating search for: {query}")
|
|
# In a real implementation, you would call soulseek_client.search()
|
|
mock_results = [
|
|
{"title": "Bohemian Rhapsody", "artist": "Queen", "album": "A Night at the Opera", "type": "track", "quality": "FLAC", "username": "user1", "filename": "Queen - Bohemian Rhapsody.flac", "file_size": 35000000},
|
|
{"title": "A Night at the Opera", "artist": "Queen", "type": "album", "track_count": 12, "size_mb": 350, "username": "user2"}
|
|
]
|
|
return jsonify({"results": mock_results})
|
|
|
|
@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})
|
|
|
|
|
|
# --- 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)
|