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'
Please visit this URL to authenticate:
After authentication, return to the app.
' + else: + return "Could not initialize Spotify client. Check your credentials.
", 400 + except Exception as e: + print(f"π΄ Error starting Spotify auth: {e}") + return f"{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 "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 "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 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'Please visit this URL to authenticate:
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"{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'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'{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'{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'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'{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'{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 @@