From c4b4adf7fb668df807719cc0f86838a618adc637 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 11 Sep 2025 13:24:15 -0700 Subject: [PATCH] Add web-based Spotify and Tidal OAuth flows Introduces web server routes and UI buttons for initiating Spotify and Tidal OAuth authentication flows, with dedicated callback servers for token exchange. Updates Docker ports for OAuth callbacks and refines PKCE handling for Tidal. Improves user experience by allowing authentication directly from the web UI. --- core/spotify_client.py | 2 +- core/tidal_client.py | 28 +++++ docker-compose.yml | 4 +- web_server.py | 276 ++++++++++++++++++++++++++++++++++++----- webui/index.html | 3 + webui/static/script.js | 14 +++ 6 files changed, 297 insertions(+), 30 deletions(-) diff --git a/core/spotify_client.py b/core/spotify_client.py index 41c4e5c9..7c04c23c 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -174,7 +174,7 @@ class SpotifyClient: client_secret=config['client_secret'], redirect_uri="http://127.0.0.1:8888/callback", scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path='.spotify_cache' + cache_path='config/.spotify_cache' ) self.sp = spotipy.Spotify(auth_manager=auth_manager) diff --git a/core/tidal_client.py b/core/tidal_client.py index ade893b4..87234008 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -272,6 +272,12 @@ class TidalClient: def _start_callback_server(self): """Start HTTP server to receive OAuth callback""" + # Skip starting server in Docker/production mode - web server handles callbacks + import os + if os.getenv('FLASK_ENV') == 'production' or os.path.exists('/.dockerenv'): + logger.info("Docker/WebUI mode detected - skipping TidalClient callback server (web server handles callbacks)") + return + # Store reference to self for the callback handler tidal_client_ref = self @@ -412,6 +418,28 @@ class TidalClient: logger.error(f"Error refreshing Tidal token: {e}") return False + def fetch_token_from_code(self, auth_code: str) -> bool: + """Exchange authorization code for access tokens (for web server callback)""" + try: + logger.info(f"Starting token exchange with code: {auth_code[:20]}...") + logger.info(f"Using code_verifier: {self.code_verifier[:20] if self.code_verifier else 'None'}...") + logger.info(f"Using redirect_uri: {self.redirect_uri}") + + self.auth_code = auth_code + result = self._exchange_code_for_tokens() + + if result: + logger.info("βœ… Token exchange successful") + else: + logger.error("❌ Token exchange failed") + + return result + except Exception as e: + logger.error(f"Error in fetch_token_from_code: {e}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + return False + def _ensure_valid_token(self): """Ensure we have a valid access token""" if not self.access_token: diff --git a/docker-compose.yml b/docker-compose.yml index 294951cd..51bd20dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,9 @@ services: build: . container_name: soulsync-webui ports: - - "8008:8008" + - "8008:8008" # Main web app + - "8888:8888" # Spotify OAuth callback + - "8889:8889" # Tidal OAuth callback volumes: # Persistent data volumes - ./config:/app/config diff --git a/web_server.py b/web_server.py index 75322306..836f1e93 100644 --- a/web_server.py +++ b/web_server.py @@ -90,6 +90,14 @@ stream_lock = threading.Lock() # Prevent race conditions stream_background_task = None stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time +# --- Global OAuth State Management --- +# Store PKCE values for Tidal OAuth flow +tidal_oauth_state = { + "code_verifier": None, + "code_challenge": None +} +tidal_oauth_lock = threading.Lock() + db_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DBUpdate") db_update_worker = None db_update_state = { @@ -1628,38 +1636,76 @@ def detect_soulseek_endpoint(): add_activity_item("❌", "Auto-Detect Failed", "No slskd server found", "Now") return jsonify({"success": False, "error": "No slskd server found on common local addresses."}) -# --- Full Tidal Authentication Flow --- +# --- Authentication Routes --- + +@app.route('/auth/spotify') +def auth_spotify(): + """ + Initiates Spotify OAuth authentication flow + """ + try: + # Create a fresh spotify client to trigger OAuth + temp_spotify_client = SpotifyClient() + if temp_spotify_client.sp and temp_spotify_client.sp.auth_manager: + # Get the authorization URL + auth_url = temp_spotify_client.sp.auth_manager.get_authorize_url() + add_activity_item("πŸ”", "Spotify Auth Started", "Please complete OAuth in browser", "Now") + return f'

πŸ” Spotify Authentication

Please visit this URL to authenticate:

{auth_url}

After authentication, return to the app.

' + else: + return "

❌ Spotify Authentication Failed

Could not initialize Spotify client. Check your credentials.

", 400 + except Exception as e: + print(f"πŸ”΄ Error starting Spotify auth: {e}") + return f"

❌ Spotify Authentication Error

{str(e)}

", 500 @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. + Initiates Tidal OAuth authentication flow """ - # 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.") - - # Add activity for authentication start - add_activity_item("πŸ”", "Tidal Auth Started", "Initiating authentication flow", "Now") - - if temp_tidal_client.authenticate(): - # Re-initialize the main client instance after successful auth - global tidal_client - tidal_client = TidalClient() - add_activity_item("βœ…", "Tidal Auth Complete", "Successfully authenticated with Tidal", "Now") - return "

βœ… Tidal Authentication Successful!

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

" - else: - add_activity_item("❌", "Tidal Auth Failed", "Authentication with Tidal failed", "Now") - return "

❌ Tidal Authentication Failed

Please check the console output of the server for a login URL and follow the instructions.

", 400 + print("πŸ”πŸ”πŸ” TIDAL AUTH ROUTE CALLED πŸ”πŸ”πŸ”") + try: + # Create a fresh tidal client to get OAuth URL + from core.tidal_client import TidalClient + temp_tidal_client = TidalClient() + + if not temp_tidal_client.client_id: + return "

❌ Tidal Authentication Failed

Tidal client ID not configured. Check your credentials.

", 400 + + # Generate PKCE challenge and store globally + temp_tidal_client._generate_pkce_challenge() + + # Store PKCE values globally for callback use + global tidal_oauth_state + with tidal_oauth_lock: + tidal_oauth_state["code_verifier"] = temp_tidal_client.code_verifier + tidal_oauth_state["code_challenge"] = temp_tidal_client.code_challenge + + print(f"πŸ” Stored PKCE - verifier: {temp_tidal_client.code_verifier[:20]}... challenge: {temp_tidal_client.code_challenge[:20]}...") + + # Create OAuth URL + import urllib.parse + params = { + 'response_type': 'code', + 'client_id': temp_tidal_client.client_id, + 'redirect_uri': temp_tidal_client.redirect_uri, + 'scope': 'user.read playlists.read', + 'code_challenge': temp_tidal_client.code_challenge, + 'code_challenge_method': 'S256' + } + + auth_url = f"{temp_tidal_client.auth_url}?" + urllib.parse.urlencode(params) + + print(f"πŸ”— Generated Tidal OAuth URL: {auth_url}") + print(f"πŸ”— Redirect URI in URL: {params['redirect_uri']}") + + add_activity_item("πŸ”", "Tidal Auth Started", "Please complete OAuth in browser", "Now") + return f'

πŸ” Tidal Authentication

Please visit this URL to authenticate:

{auth_url}

After authentication, return to the app.

' + + except Exception as e: + print(f"πŸ”΄ Error starting Tidal auth: {e}") + import traceback + print(f"πŸ”΄ Full traceback: {traceback.format_exc()}") + return f"

❌ Tidal Authentication Error

{str(e)}

", 500 @app.route('/tidal/callback') @@ -11194,10 +11240,184 @@ class WebMetadataUpdateWorker: # --- Main Execution --- +def start_oauth_callback_servers(): + """Start dedicated OAuth callback servers for Spotify and Tidal""" + import threading + from http.server import HTTPServer, BaseHTTPRequestHandler + import urllib.parse + + # Spotify callback server + class SpotifyCallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + print(f"🎡 Spotify callback received: {self.path}") + parsed_url = urllib.parse.urlparse(self.path) + query_params = urllib.parse.parse_qs(parsed_url.query) + + if 'code' in query_params: + auth_code = query_params['code'][0] + print(f"🎡 Received Spotify authorization code: {auth_code[:10]}...") + + # Manually trigger the token exchange using spotipy's auth manager + try: + from core.spotify_client import SpotifyClient + from spotipy.oauth2 import SpotifyOAuth + from config.settings import config_manager + + # Get Spotify config + config = config_manager.get_spotify_config() + + # Create auth manager and exchange code for token + auth_manager = SpotifyOAuth( + client_id=config['client_id'], + client_secret=config['client_secret'], + redirect_uri="http://127.0.0.1:8888/callback", + scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", + cache_path='config/.spotify_cache' + ) + + # Extract the authorization code and exchange it for tokens + token_info = auth_manager.get_access_token(auth_code, as_dict=True) + + if token_info: + # Reinitialize the global client with new tokens + global spotify_client + spotify_client = SpotifyClient() + + if spotify_client.is_authenticated(): + add_activity_item("βœ…", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'

Spotify Authentication Successful!

You can close this window.

') + else: + raise Exception("Token exchange succeeded but authentication validation failed") + else: + raise Exception("Failed to exchange authorization code for access token") + except Exception as e: + print(f"πŸ”΄ Spotify token processing error: {e}") + add_activity_item("❌", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(f'

Spotify Authentication Failed

{str(e)}

'.encode()) + else: + error = query_params.get('error', ['Unknown error'])[0] + print(f"πŸ”΄ Spotify OAuth error: {error}") + print(f"πŸ”΄ Full Spotify callback URL: {self.path}") + print(f"πŸ”΄ All query params: {query_params}") + + # Only show error toast if it's not just a spurious request + if 'error' in query_params: + add_activity_item("❌", "Spotify Auth Failed", f"OAuth error: {error}", "Now") + else: + print("πŸ”΄ Spurious Spotify callback without code or error - ignoring") + + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(f'

Spotify Authentication Failed

{error}

'.encode()) + + def log_message(self, format, *args): + pass # Suppress server logs + + # Start Spotify callback server + def run_spotify_server(): + try: + spotify_server = HTTPServer(('0.0.0.0', 8888), SpotifyCallbackHandler) + print("🎡 Started Spotify OAuth callback server on port 8888") + spotify_server.serve_forever() + except Exception as e: + print(f"πŸ”΄ Failed to start Spotify callback server: {e}") + + # Tidal callback server + class TidalCallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + print("🎢🎢🎢 TIDAL CALLBACK SERVER RECEIVED REQUEST 🎢🎢🎢") + parsed_url = urllib.parse.urlparse(self.path) + query_params = urllib.parse.parse_qs(parsed_url.query) + print(f"🎢 Callback path: {self.path}") + + if 'code' in query_params: + auth_code = query_params['code'][0] + print(f"🎢 Received Tidal authorization code: {auth_code[:10]}...") + + # Exchange the authorization code for tokens + try: + from core.tidal_client import TidalClient + + # Create a temporary client and set the stored PKCE values + temp_client = TidalClient() + + # Restore the PKCE values from the auth request + global tidal_oauth_state + with tidal_oauth_lock: + temp_client.code_verifier = tidal_oauth_state["code_verifier"] + temp_client.code_challenge = tidal_oauth_state["code_challenge"] + + print(f"πŸ” Restored PKCE - verifier: {temp_client.code_verifier[:20] if temp_client.code_verifier else 'None'}... challenge: {temp_client.code_challenge[:20] if temp_client.code_challenge else 'None'}...") + + success = temp_client.fetch_token_from_code(auth_code) + + if success: + # Reinitialize the global tidal client with new tokens + global tidal_client + tidal_client = TidalClient() + + add_activity_item("βœ…", "Tidal Auth Complete", "Successfully authenticated with Tidal", "Now") + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'

Tidal Authentication Successful!

You can close this window.

') + else: + raise Exception("Failed to exchange authorization code for tokens") + + except Exception as e: + print(f"πŸ”΄ Tidal token processing error: {e}") + add_activity_item("❌", "Tidal Auth Failed", f"Token processing failed: {str(e)}", "Now") + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(f'

Tidal Authentication Failed

{str(e)}

'.encode()) + else: + error = query_params.get('error', ['Unknown error'])[0] + print(f"πŸ”΄ Tidal OAuth error: {error}") + add_activity_item("❌", "Tidal Auth Failed", f"OAuth error: {error}", "Now") + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(f'

Tidal Authentication Failed

{error}

'.encode()) + + def log_message(self, format, *args): + pass # Suppress server logs + + def run_tidal_server(): + try: + tidal_server = HTTPServer(('0.0.0.0', 8889), TidalCallbackHandler) + print("🎢 Started Tidal OAuth callback server on port 8889") + print(f"🎢 Tidal server listening on all interfaces, port 8889") + tidal_server.serve_forever() + except Exception as e: + print(f"πŸ”΄ Failed to start Tidal callback server: {e}") + import traceback + print(f"πŸ”΄ Full error: {traceback.format_exc()}") + + # Start both servers in background threads + spotify_thread = threading.Thread(target=run_spotify_server, daemon=True) + tidal_thread = threading.Thread(target=run_tidal_server, daemon=True) + + spotify_thread.start() + tidal_thread.start() + + print("βœ… OAuth callback servers started") + if __name__ == '__main__': print("πŸš€ Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:8008") + # Start OAuth callback servers + print("πŸ”§ Starting OAuth callback servers...") + start_oauth_callback_servers() + # Start simple background monitor when server starts print("πŸ”§ Starting simple background monitor...") start_simple_background_monitor() @@ -11223,4 +11443,4 @@ if __name__ == '__main__': # Add a test activity to verify the system is working add_activity_item("πŸ”§", "Debug Test", "Activity feed system test", "Now") - app.run(host='0.0.0.0', port=8008, debug=True) + app.run(host='0.0.0.0', port=8008, debug=False) diff --git a/webui/index.html b/webui/index.html index 73aad4e5..9150d1c6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -657,6 +657,9 @@
http://127.0.0.1:8888/callback
Add this URL to your Spotify app's 'Redirect URIs' in the Spotify Developer Dashboard
+
+ +
diff --git a/webui/static/script.js b/webui/static/script.js index c01311be..4b49f4ce 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1638,6 +1638,19 @@ function updateStatusDisplays() { }); } +async function authenticateSpotify() { + try { + showLoadingOverlay('Starting Spotify authentication...'); + showToast('Spotify authentication started', 'success'); + window.open('/auth/spotify', '_blank'); + } catch (error) { + console.error('Error authenticating Spotify:', error); + showToast('Failed to start Spotify authentication', 'error'); + } finally { + hideLoadingOverlay(); + } +} + async function authenticateTidal() { try { showLoadingOverlay('Starting Tidal authentication...'); @@ -5816,6 +5829,7 @@ window.autoDetectPlex = autoDetectPlex; window.autoDetectJellyfin = autoDetectJellyfin; window.autoDetectSlskd = autoDetectSlskd; window.toggleServer = toggleServer; +window.authenticateSpotify = authenticateSpotify; window.authenticateTidal = authenticateTidal; window.browsePath = browsePath; window.selectResult = selectResult;