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;