From 1dee0e80c51a47a22ab933623b70156d00ade490 Mon Sep 17 00:00:00 2001 From: Praneel Pothukanuri Date: Wed, 26 Aug 2026 04:47:58 +0000 Subject: [PATCH] Propagate backend errors as structured JSON instead of swallowing them --- .../components/SpotifyPlaylistGenerator.tsx | 51 ++- backend/app.py | 31 +- backend/config.py | 25 +- backend/errors.py | 74 ++++ backend/routes/auth.py | 148 ++++--- backend/routes/playlist.py | 377 +++++++++--------- backend/services/ai.py | 73 +++- backend/services/spotify.py | 263 +++++++----- run.py | 4 - 9 files changed, 621 insertions(+), 425 deletions(-) create mode 100644 backend/errors.py diff --git a/UI_Front/src/components/SpotifyPlaylistGenerator.tsx b/UI_Front/src/components/SpotifyPlaylistGenerator.tsx index 5b90511..9045c96 100644 --- a/UI_Front/src/components/SpotifyPlaylistGenerator.tsx +++ b/UI_Front/src/components/SpotifyPlaylistGenerator.tsx @@ -16,6 +16,7 @@ import { import { authService, playlistService } from "@/lib/api"; import type { Track } from "@/lib/api"; import { usePreferences } from "@/context/PreferencesContext"; +import { useToast } from "@/hooks/use-toast"; type AppStep = "landing" | "auth" | "preferences" | "preview" | "success"; @@ -42,6 +43,7 @@ const LOCAL_STORAGE_KEY = "spotify_playlist_generator_state"; const SpotifyPlaylistGenerator = () => { const { preferences, setPreferences } = usePreferences(); + const { toast } = useToast(); const [currentStep, setCurrentStep] = useState("landing"); const [isAuthenticated, setIsAuthenticated] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(true); @@ -121,6 +123,12 @@ const SpotifyPlaylistGenerator = () => { } catch (error) { console.error("Auth check failed:", error); setIsAuthenticated(false); + setCurrentStep("landing"); + toast({ + variant: "destructive", + title: "Could not verify your Spotify session", + description: error instanceof Error ? error.message : "Please log in again.", + }); } finally { setIsCheckingAuth(false); } @@ -154,24 +162,22 @@ const SpotifyPlaylistGenerator = () => { }; const handleGeneratePlaylist = (playlist: GeneratedPlaylist) => { - try { - console.log("Received generated playlist:", playlist); - // The API call is already made in PreferenceInput component - // This function just updates the state with the received playlist - if (!playlist || !playlist.Tracks) { - console.error("Invalid playlist data received:", playlist); - return; - } - setGeneratedPlaylist(playlist); - setCurrentStep("preview"); - } catch (error) { - console.error("Error handling generated playlist:", error); - // Don't throw - the playlist was already created successfully + // The API call is already made in PreferenceInput component + // This function just updates the state with the received playlist + if (!playlist || !playlist.Tracks) { + console.error("Invalid playlist data received:", playlist); + toast({ + variant: "destructive", + title: "Could not load the generated playlist", + description: "The server returned an unexpected response. Please try generating again.", + }); + return; } + setGeneratedPlaylist(playlist); + setCurrentStep("preview"); }; const handleSavePlaylist = async (name: string, description: string, tracks: Track[]) => { - console.log("Saving playlist:", name, tracks); try { const uris = tracks.map(t => t.uri); const response = await playlistService.createPlaylist(name, description, uris); @@ -182,7 +188,11 @@ const SpotifyPlaylistGenerator = () => { setCurrentStep("success"); } catch (error) { console.error("Failed to save playlist:", error); - // Handle error (show toast?) + toast({ + variant: "destructive", + title: "Failed to save playlist", + description: error instanceof Error ? error.message : "Please try again.", + }); } }; @@ -206,7 +216,16 @@ const SpotifyPlaylistGenerator = () => { }; const handleBackHome = async () => { - await authService.logout(); + try { + await authService.logout(); + } catch (error) { + console.error("Logout failed:", error); + toast({ + variant: "destructive", + title: "Logout failed on the server", + description: "Your local session was cleared, but you may still be signed in to Spotify.", + }); + } setIsAuthenticated(false); setCurrentStep("landing"); diff --git a/backend/app.py b/backend/app.py index e3b7e65..c216da9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,17 +1,40 @@ +import logging +import os + from flask import Flask from flask_session import Session from flask_cors import CORS + from .config import Config +from .errors import register_error_handlers + +logger = logging.getLogger(__name__) + def create_app(): + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + app = Flask(__name__) app.config.from_object(Config) + # Surface misconfiguration at startup instead of on the first request. + missing = Config.missing_settings() + if missing: + logger.error( + "Missing required environment variables: %s. " + "Login and playlist generation will fail until they are set.", + ", ".join(missing), + ) + + os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True) + # Initialize Session Session(app) - + # Initialize CORS - # Allowing specific origins as per main2.py but simplified CORS(app, resources={r"/*": {"origins": ["http://localhost:8080", "http://127.0.0.1:8080", "http://localhost:8081", "http://127.0.0.1:8081"]}}, supports_credentials=True) @@ -22,7 +45,9 @@ def create_app(): app.register_blueprint(auth_bp) app.register_blueprint(playlist_bp) - + + register_error_handlers(app) + @app.route('/') def health_check(): return {"status": "ok", "service": "Spotify AI Backend"} diff --git a/backend/config.py b/backend/config.py index f577914..6725952 100644 --- a/backend/config.py +++ b/backend/config.py @@ -3,6 +3,8 @@ from datetime import timedelta from dotenv import load_dotenv +from .errors import ConfigError + load_dotenv() class Config: @@ -44,14 +46,23 @@ class Config: # AI / Gemini GENAI_API_KEY = os.getenv("GENAI_API_KEY") + @classmethod + def missing_settings(cls): + """Return the names of required environment variables that are unset.""" + required = { + "CLIENT_ID": cls.SPOTIFY_CLIENT_ID, + "CLIENT_SECRET": cls.SPOTIFY_CLIENT_SECRET, + "REDIRECT_URI": cls.SPOTIFY_REDIRECT_URI, + "GENAI_API_KEY": cls.GENAI_API_KEY, + } + return [name for name, value in required.items() if not value] + @classmethod def validate(cls): """Ensure critical config exists""" - missing = [] - if not cls.SPOTIFY_CLIENT_ID: missing.append("CLIENT_ID") - if not cls.SPOTIFY_CLIENT_SECRET: missing.append("CLIENT_SECRET") - if not cls.SPOTIFY_REDIRECT_URI: missing.append("REDIRECT_URI") - if not cls.GENAI_API_KEY: missing.append("GENAI_API_KEY") - + missing = cls.missing_settings() if missing: - raise ValueError(f"Missing required environment variables: {', '.join(missing)}") + raise ConfigError( + "Server misconfiguration", + details=f"Missing required environment variables: {', '.join(missing)}", + ) diff --git a/backend/errors.py b/backend/errors.py new file mode 100644 index 0000000..e26ac69 --- /dev/null +++ b/backend/errors.py @@ -0,0 +1,74 @@ +"""Application error types and JSON error handlers.""" + +import logging + +from flask import jsonify +from werkzeug.exceptions import HTTPException + +logger = logging.getLogger(__name__) + + +class AppError(Exception): + """Base class for errors that map onto an HTTP JSON response.""" + + status_code = 500 + message = "Internal server error" + + def __init__(self, message=None, status_code=None, details=None): + super().__init__(message or self.message) + if message: + self.message = message + if status_code: + self.status_code = status_code + self.details = details + + def to_dict(self): + payload = {"error": self.message} + if self.details: + payload["details"] = self.details + return payload + + +class ConfigError(AppError): + status_code = 500 + message = "Server misconfiguration" + + +class SpotifyAuthError(AppError): + """The Spotify access token is missing, expired or rejected.""" + + status_code = 401 + message = "Spotify authentication expired, please log in again" + + +class SpotifyRateLimitError(AppError): + status_code = 429 + message = "Rate limited by Spotify, please retry shortly" + + +class SpotifyAPIError(AppError): + status_code = 502 + message = "Spotify request failed" + + +class AIServiceError(AppError): + status_code = 502 + message = "AI playlist generation failed" + + +def register_error_handlers(app): + @app.errorhandler(AppError) + def handle_app_error(err): + logger.warning( + "%s: %s (details=%s)", type(err).__name__, err.message, err.details + ) + return jsonify(err.to_dict()), err.status_code + + @app.errorhandler(HTTPException) + def handle_http_error(err): + return jsonify({"error": err.description}), err.code + + @app.errorhandler(Exception) + def handle_unexpected_error(err): + logger.exception("Unhandled exception while serving request: %s", err) + return jsonify({"error": "Internal server error"}), 500 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index ec507cb..3bb1ab9 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,134 +1,130 @@ -from flask import Blueprint, request, session, redirect, jsonify, current_app -from ..config import Config -from ..services.spotify import SpotifyService +import logging import secrets -import time from datetime import datetime +from urllib.parse import urlencode + +from flask import Blueprint, request, session, redirect, jsonify + +from ..config import Config +from ..errors import AppError, SpotifyAuthError +from ..services.spotify import SpotifyService + +logger = logging.getLogger(__name__) auth_bp = Blueprint('auth', __name__) + @auth_bp.route('/login') def login(): - # Verify config + # Raises ConfigError (500 JSON) when required credentials are missing. Config.validate() - - # Check redirect URI validity (security best practice) - redirect_uri = Config.SPOTIFY_REDIRECT_URI - if not redirect_uri: - return jsonify({"error": "Server misconfiguration"}), 500 # Create State state = secrets.token_urlsafe(16) session['oauth_state'] = state - - # Store where to go after login (defaulting to frontend) - # In main2.py this was hardcoded or parameter driven. - # Adapting to safe defaults. - # If the user is on localhost:8080 (dev), we redirect there. + + # Store where to go after login (defaulting to the dev frontend). frontend_url = request.args.get('redirect', 'http://127.0.0.1:8080/preferences') session['frontend_redirect'] = frontend_url - + # Force session save session.modified = True - # Construct Auth URL scope = "user-read-email playlist-modify-public playlist-modify-private ugc-image-upload" params = { "client_id": Config.SPOTIFY_CLIENT_ID, "response_type": "code", "scope": scope, - "redirect_uri": redirect_uri, + "redirect_uri": Config.SPOTIFY_REDIRECT_URI, "state": state, "show_dialog": "true" } - - # Manual query string construction to ensure encoding - from urllib.parse import urlencode - auth_url = f"https://accounts.spotify.com/authorize?{urlencode(params)}" - - return redirect(auth_url) + + return redirect(f"https://accounts.spotify.com/authorize?{urlencode(params)}") + @auth_bp.route('/callback') def callback(): - # detailed logging available in backend logs if needed - # Validate State stored_state = session.get('oauth_state') received_state = request.args.get('state') - + if not stored_state or stored_state != received_state: - return jsonify({"error": "Invalid state parameter", "details": "Session may have expired or cross-site request detected."}), 400 - + raise AppError( + "Invalid state parameter", + status_code=400, + details="Session may have expired or cross-site request detected.", + ) + error = request.args.get('error') if error: - return jsonify({"error": error}), 400 - + raise AppError(error, status_code=400, details="Spotify denied the authorization request") + code = request.args.get('code') if not code: - return jsonify({"error": "No code provided"}), 400 - - # Exchange Code + raise AppError("No code provided", status_code=400) + + # Exchange Code - failures propagate as JSON errors with the upstream detail. spotify = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) - try: - token_info = spotify.exchange_code_for_token(code, Config.SPOTIFY_REDIRECT_URI) - except Exception as e: - return jsonify({"error": "Token exchange failed", "details": str(e)}), 500 - - # Store Session - session['access_token'] = token_info.get('access_token') + token_info = spotify.exchange_code_for_token(code, Config.SPOTIFY_REDIRECT_URI) + + session['access_token'] = token_info['access_token'] session['refresh_token'] = token_info.get('refresh_token') - expires_in = token_info.get('expires_in', 3600) - session['expires_at'] = datetime.now().timestamp() + expires_in - - # Get User Profile immediately to store ID - try: - profile = spotify.get_user_profile(session['access_token']) - session['spotify_user_id'] = profile.get('id') - session['spotify_display_name'] = profile.get('display_name') - except Exception as e: - print(f"Warning: Failed to fetch profile on login: {e}") - + session['expires_at'] = datetime.now().timestamp() + token_info.get('expires_in', 3600) + + # The profile is required for playlist creation, so a failure here is fatal + # rather than a login that looks successful but cannot create playlists. + profile = spotify.get_user_profile(session['access_token']) + session['spotify_user_id'] = profile['id'] + session['spotify_display_name'] = profile.get('display_name') + session.modified = True - - # Redirect back to frontend + redirect_url = session.get('frontend_redirect', 'http://127.0.0.1:8080/') return redirect(redirect_url) + @auth_bp.route('/auth/status') def auth_status(): if 'access_token' not in session: return jsonify({"authenticated": False}), 401 - - # Check expiry + expires_at = session.get('expires_at', 0) if datetime.now().timestamp() > expires_at: - # Try refresh - if 'refresh_token' in session: - try: - spotify = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) - new_tokens = spotify.refresh_token(session['refresh_token']) - - session['access_token'] = new_tokens.get('access_token') - # Refresh token might not always be returned in a refresh flow, keep old one if so - if 'refresh_token' in new_tokens: - session['refresh_token'] = new_tokens.get('refresh_token') - - session['expires_at'] = datetime.now().timestamp() + new_tokens.get('expires_in', 3600) - session.modified = True - except Exception as e: - print(f"Refresh failed: {e}") - session.clear() - return jsonify({"authenticated": False}), 401 - else: + refresh_token = session.get('refresh_token') + if not refresh_token: + session.clear() + return jsonify({ + "authenticated": False, + "reason": "Session expired and no refresh token is available", + }), 401 + + spotify = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + try: + new_tokens = spotify.refresh_token(refresh_token) + except AppError as err: + # A failed refresh is reported to the client with its cause so the + # frontend can distinguish "log in again" from "Spotify is down". + logger.warning("Token refresh failed: %s (%s)", err.message, err.details) session.clear() - return jsonify({"authenticated": False}), 401 - + raise SpotifyAuthError( + "Session expired, please log in again", details=err.details + ) from err + + session['access_token'] = new_tokens['access_token'] + # A refresh response does not always include a new refresh token. + if new_tokens.get('refresh_token'): + session['refresh_token'] = new_tokens['refresh_token'] + session['expires_at'] = datetime.now().timestamp() + new_tokens.get('expires_in', 3600) + session.modified = True + return jsonify({ "authenticated": True, "user": session.get('spotify_display_name'), "expires_at": session.get('expires_at') }) + @auth_bp.route('/logout', methods=['POST']) def logout(): session.clear() diff --git a/backend/routes/playlist.py b/backend/routes/playlist.py index 778bf3b..2c357d6 100644 --- a/backend/routes/playlist.py +++ b/backend/routes/playlist.py @@ -1,246 +1,243 @@ -from flask import Blueprint, request, session, jsonify import concurrent.futures +import logging + +from flask import Blueprint, request, session, jsonify + from ..config import Config +from ..errors import AppError, SpotifyAPIError, SpotifyAuthError, SpotifyRateLimitError from ..services.spotify import SpotifyService from ..services.ai import AIService +logger = logging.getLogger(__name__) + playlist_bp = Blueprint('playlist', __name__) +DEFAULT_PLAYLIST_LENGTH = 20 +MAX_PLAYLIST_LENGTH = 100 +PLACEHOLDER_IMAGE = "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=100&h=100&fit=crop" + + +def _require_access_token(): + token = session.get('access_token') + if not token: + raise SpotifyAuthError("Not authenticated") + return token + + +def _parse_playlist_length(raw_length): + if isinstance(raw_length, list): + if not raw_length: + return DEFAULT_PLAYLIST_LENGTH + raw_length = raw_length[0] + + if raw_length is None: + return DEFAULT_PLAYLIST_LENGTH + + try: + length = int(raw_length) + except (TypeError, ValueError): + raise AppError( + "Invalid playlistLength", + status_code=400, + details=f"Expected an integer, got {raw_length!r}", + ) + + if length < 1 or length > MAX_PLAYLIST_LENGTH: + raise AppError( + "Invalid playlistLength", + status_code=400, + details=f"playlistLength must be between 1 and {MAX_PLAYLIST_LENGTH}", + ) + return length + + +def _format_track(track): + image = PLACEHOLDER_IMAGE + images = track.get('album', {}).get('images') + if images: + image = images[0]['url'] + + duration_ms = track.get('duration_ms', 0) + return { + "id": track.get('id'), + "uri": track.get('uri'), + "title": track.get('name'), + "artist": track['artists'][0]['name'] if track.get('artists') else "Unknown artist", + "album": track.get('album', {}).get('name'), + "duration": f"{int(duration_ms / 60000)}:{int((duration_ms % 60000) / 1000):02d}", + "image": image, + "preview_url": track.get('preview_url'), + } + + @playlist_bp.route('/Playlist_Generator', methods=['POST']) @playlist_bp.route('/Generate_Preview', methods=['POST']) def generate_preview(): - # 1. Auth Check - if 'access_token' not in session: - return jsonify({"error": "Not authenticated", "redirect": "/login"}), 401 - - # 2. Get Params - data = request.get_json() or {} + access_token = _require_access_token() + + data = request.get_json(silent=True) or {} preferences = data.get('preferences') if not preferences: - return jsonify({"error": "No preferences provided"}), 400 - - playlist_length = preferences.get("playlistLength", 20) - # Handle if frontend sends a list (legacy support) - if isinstance(playlist_length, list): - playlist_length = playlist_length[0] - - try: - playlist_length = int(playlist_length) - except: - playlist_length = 20 - - # 3. AI Generation - # Request extra songs to buffer against those not found on Spotify - # Increase buffer to 100% of requested length or at least 10 songs to be safe - buffer_count = max(10, int(playlist_length * 1.0)) + raise AppError("No preferences provided", status_code=400) + + playlist_length = _parse_playlist_length(preferences.get("playlistLength")) + + # Request extra songs to buffer against those not found on Spotify. + buffer_count = max(10, playlist_length) target_ai_count = playlist_length + buffer_count - + ai_service = AIService() spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) - - try: - # Generate raw song list with buffer - ai_songs = ai_service.generate_playlist_params(preferences, count=target_ai_count) - except Exception as e: - return jsonify({"error": "AI Generation failed", "details": str(e)}), 500 - # 4. Spotify Search (Parallelized) + # AI and Spotify failures propagate to the JSON error handlers, which keeps + # the status code meaningful (401 re-auth, 429 rate limit, 502 upstream). + ai_songs = ai_service.generate_playlist_params(preferences, count=target_ai_count) + found_tracks = [] - - # Extract token from session in the main thread - access_token = session['access_token'] - + search_errors = [] + def search_worker(token, song_info): return spotify_service.search_track( - token, - song_info.get('name'), + token, + song_info.get('name'), song_info.get('artist') ) - # Use ThreadPool to search faster with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: - # Pass access_token explicitely to avoid context issues - future_to_song = {executor.submit(search_worker, access_token, song): song for song in ai_songs} - + future_to_song = { + executor.submit(search_worker, access_token, song): song + for song in ai_songs + } + for future in concurrent.futures.as_completed(future_to_song): + song = future_to_song[future] try: track = future.result() - if track: - found_tracks.append(track) - except Exception as e: - print(f"Search error: {e}") + except AppError as err: + logger.warning( + "Spotify search failed for %s by %s: %s", + song.get('name'), song.get('artist'), err.message + ) + search_errors.append(err) + continue + except Exception as err: + logger.exception( + "Unexpected error searching for %s by %s", + song.get('name'), song.get('artist') + ) + search_errors.append( + SpotifyAPIError("Spotify search failed", details=str(err)) + ) + continue + + if track: + found_tracks.append(track) + + if not found_tracks and search_errors: + # Every search failed: surface the upstream cause instead of pretending + # Spotify simply had no matches. + auth_error = next( + (e for e in search_errors if isinstance(e, SpotifyAuthError)), None + ) + rate_limited = next( + (e for e in search_errors if isinstance(e, SpotifyRateLimitError)), None + ) + raise auth_error or rate_limited or search_errors[0] if not found_tracks: - return jsonify({"error": "No songs found on Spotify matching the criteria"}), 404 - - # Trim to requested length if we found extra + raise AppError( + "No songs found on Spotify matching the criteria", status_code=404 + ) + found_tracks = found_tracks[:playlist_length] - # Return preview data (no playlist created yet) - track_previews = [] - for t in found_tracks: - # Check if we have image - image = "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=100&h=100&fit=crop" # Default - if t.get('album', {}).get('images'): - image = t['album']['images'][0]['url'] - - track_previews.append({ - "id": t['id'], - "uri": t['uri'], - "title": t['name'], - "artist": t['artists'][0]['name'], - "album": t['album']['name'], - "duration": f"{int(t['duration_ms']/60000)}:{int((t['duration_ms']%60000)/1000):02d}", - "image": image, - "preview_url": t.get('preview_url') - }) - - return jsonify({ - "tracks": track_previews, + response = { + "tracks": [_format_track(t) for t in found_tracks], "count": len(found_tracks), - "totalDuration": "Calculating..." # Frontend can calc precise duration - }) + "totalDuration": "Calculating...", # Frontend calculates the precise duration + } + if search_errors: + response["warning"] = ( + f"{len(search_errors)} of {len(ai_songs)} track searches failed" + ) + return jsonify(response) + @playlist_bp.route('/Create_Playlist', methods=['POST']) def create_playlist(): - if 'access_token' not in session: - return jsonify({"error": "Not authenticated", "redirect": "/login"}), 401 + access_token = _require_access_token() - data = request.get_json() or {} + data = request.get_json(silent=True) or {} name = data.get('name', 'AI Generated Bundle') description = data.get('description', 'Generated by PlaylistAI') uris = data.get('uris', []) - image = data.get('image') # Base64 string + image = data.get('image') # Base64 string if not uris: - return jsonify({"error": "No tracks provided"}), 400 + raise AppError("No tracks provided", status_code=400) + if not isinstance(uris, list): + raise AppError( + "Invalid tracks payload", + status_code=400, + details="'uris' must be a list of Spotify track URIs", + ) spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) - try: - user_id = session.get('spotify_user_id') - if not user_id: - # Try to fetch if missing - profile = spotify_service.get_user_profile(session['access_token']) - user_id = profile['id'] - session['spotify_user_id'] = user_id - - playlist = spotify_service.create_playlist( - session['access_token'], - user_id, - name=name, - description=description, - public=True - ) - - # Add Tracks - spotify_service.add_tracks_to_playlist( - session['access_token'], - playlist['id'], - uris - ) + user_id = session.get('spotify_user_id') + if not user_id: + user_id = spotify_service.get_user_profile(access_token)['id'] + session['spotify_user_id'] = user_id + + playlist = spotify_service.create_playlist( + access_token, + user_id, + name=name, + description=description, + public=True + ) + + spotify_service.add_tracks_to_playlist(access_token, playlist['id'], uris) + + response = { + "playlist_id": playlist['id'], + "message": "Playlist created successfully", + } + + if image: + # A failed cover upload must not discard an otherwise created playlist, + # but the client is told about it instead of it being swallowed. + try: + spotify_service.upload_playlist_cover(access_token, playlist['id'], image) + response["cover_uploaded"] = True + except AppError as err: + logger.warning("Cover upload failed for playlist %s: %s", playlist['id'], err.message) + response["cover_uploaded"] = False + response["warning"] = f"Cover image upload failed: {err.message}" + except Exception as err: + logger.exception("Unexpected error uploading cover for playlist %s", playlist['id']) + response["cover_uploaded"] = False + response["warning"] = f"Cover image upload failed: {err}" + + return jsonify(response) - # Upload Image if provided - if image: - try: - # Add slight delay to ensure playlist is ready? - # Spotify API usually handles it fine, but sometimes it takes a moment. - spotify_service.upload_playlist_cover( - session['access_token'], - playlist['id'], - image - ) - except Exception as img_err: - print(f"Failed to upload image: {img_err}") - # Don't fail the whole request, just log it - - return jsonify({ - "playlist_id": playlist['id'], - "message": "Playlist created successfully" - }) - - except Exception as e: - print(f"Playlist creation failed: {e}") - return jsonify({"error": "Failed to create playlist on Spotify", "details": str(e)}), 500 @playlist_bp.route('/Search_Track', methods=['GET']) def search_spotify_track(): - if 'access_token' not in session: - return jsonify({"error": "Not authenticated"}), 401 + access_token = _require_access_token() query = request.args.get('q') if not query: - return jsonify({"error": "Missing query"}), 400 + raise AppError("Missing query", status_code=400) spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) - - # We'll use a direct search here, reusing search_track logic or calling raw search - # Since search_track in service is specific to track/artist, let's just do a raw search here or add a general search to service. - # For now, let's use the service's existing base URL but we need a general search. - # Actually, let's just use requests here for simplicity or extend service. - # Extending service is cleaner. - - try: - # Reuse search_track logic but treat query as song name and ignore artist if not provided? - # Or better, just call Spotify Search API directly here for flexible search - params = { - "q": query, - "type": "track", - "market": "US", - "limit": 10 - } - - response = requests.get( - f"{SpotifyService.BASE_URL}/search", - headers=spotify_service.get_auth_headers(session['access_token']), - params=params - ) - - if response.status_code != 200: - return jsonify({"error": "Spotify search failed"}), response.status_code - - data = response.json() - tracks = data.get('tracks', {}).get('items', []) - - # Format for frontend - results = [] - for t in tracks: - image = "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=100&h=100&fit=crop" - if t.get('album', {}).get('images'): - image = t['album']['images'][0]['url'] - - results.append({ - "id": t['id'], - "uri": t['uri'], - "title": t['name'], - "artist": t['artists'][0]['name'], - "album": t['album']['name'], - "duration": f"{int(t['duration_ms']/60000)}:{int((t['duration_ms']%60000)/1000):02d}", - "image": image - }) - - return jsonify(results) - - except Exception as e: - return jsonify({"error": str(e)}), 500 + tracks = spotify_service.search_tracks(access_token, query, limit=10) + return jsonify([_format_track(t) for t in tracks]) + @playlist_bp.route('/Get_Playlists', methods=['GET']) def get_playlists(): - if 'access_token' not in session: - return jsonify({"error": "Not authenticated"}), 401 - + access_token = _require_access_token() + spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) - try: - # Simplified for now, just getting user's playlists - # Logic from main2.py could be adapted if specific playlist fetching is needed - # But this route seemed generic in main2.py - - params = {"limit": 50} - response = requests.get( - f"{SpotifyService.BASE_URL}/me/playlists", - headers=spotify_service.get_auth_headers(session['access_token']), - params=params - ) - return jsonify(response.json()) - except Exception as e: - return jsonify({"error": str(e)}), 500 + return jsonify(spotify_service.get_user_playlists(access_token)) diff --git a/backend/services/ai.py b/backend/services/ai.py index ff7d612..3e7ae9a 100644 --- a/backend/services/ai.py +++ b/backend/services/ai.py @@ -1,7 +1,13 @@ -import os import json +import logging + import google.generativeai as genai + from ..config import Config +from ..errors import AIServiceError, ConfigError + +logger = logging.getLogger(__name__) + class AIService: def __init__(self): @@ -17,7 +23,7 @@ def generate_playlist_params(self, preferences, count=20, exclude_tracks=None): Returns a list of dictionaries: [{"name": "Song Name", "artist": "Artist Name"}] """ if not self.model: - raise Exception("AI Service not configured (missing API Key)") + raise ConfigError("AI service is not configured (missing GENAI_API_KEY)") exclude_text = "" if exclude_tracks: @@ -33,25 +39,54 @@ def generate_playlist_params(self, preferences, count=20, exclude_tracks=None): Do not include markdown formatting like ```json ... ```. Just return the raw JSON array. """ - + try: - # Using generation_config to enforce JSON if possible, or just relying on the prompt response = self.model.generate_content( prompt, generation_config={"response_mime_type": "application/json"} ) - - if not response.text: - raise Exception("Empty response from AI") - - songs = json.loads(response.text) - - # basic validation - if not isinstance(songs, list): - raise ValueError("AI did not return a list") - - return songs - - except Exception as e: - print(f"AI Generation Error: {e}") - raise Exception(f"Failed to generate playlist: {str(e)}") + except Exception as exc: + logger.exception("Gemini request failed") + raise AIServiceError( + "AI request failed", details=str(exc) + ) from exc + + try: + # The SDK raises when the candidate was blocked or truncated. + text = response.text + except ValueError as exc: + raise AIServiceError( + "AI returned no usable content", details=str(exc) + ) from exc + + if not text: + raise AIServiceError("AI returned an empty response") + + try: + songs = json.loads(text) + except json.JSONDecodeError as exc: + logger.error("AI returned non-JSON payload: %s", text[:500]) + raise AIServiceError( + "AI returned a malformed response", details=str(exc) + ) from exc + + if not isinstance(songs, list): + raise AIServiceError( + "AI returned a malformed response", + details=f"Expected a JSON array, got {type(songs).__name__}", + ) + + valid_songs = [ + song for song in songs + if isinstance(song, dict) and song.get("name") and song.get("artist") + ] + skipped = len(songs) - len(valid_songs) + if skipped: + logger.warning("Discarded %d malformed song entries from AI response", skipped) + if not valid_songs: + raise AIServiceError( + "AI returned no usable songs", + details=f"Received {len(songs)} entries, none with both 'name' and 'artist'", + ) + + return valid_songs diff --git a/backend/services/spotify.py b/backend/services/spotify.py index 5c6f496..b7706d5 100644 --- a/backend/services/spotify.py +++ b/backend/services/spotify.py @@ -1,7 +1,14 @@ -import time -import requests import base64 -from urllib.parse import urlencode +import logging + +import requests + +from ..errors import SpotifyAPIError, SpotifyAuthError, SpotifyRateLimitError + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 10 + class SpotifyService: BASE_URL = "https://api.spotify.com/v1" @@ -17,170 +24,206 @@ def get_auth_headers(self, access_token): "Content-Type": "application/json" } - def exchange_code_for_token(self, code, redirect_uri): - auth_header = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode("ascii") - headers = { + def _basic_auth_headers(self): + auth_header = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode("ascii") + return { "Authorization": f"Basic {auth_header}", "Content-Type": "application/x-www-form-urlencoded" } + + def _request(self, method, url, **kwargs): + """Perform a Spotify API call, translating failures into AppErrors.""" + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + try: + response = requests.request(method, url, **kwargs) + except requests.RequestException as exc: + raise SpotifyAPIError( + "Could not reach Spotify", details=str(exc) + ) from exc + + if response.status_code == 401: + raise SpotifyAuthError(details=_error_detail(response)) + if response.status_code == 429: + raise SpotifyRateLimitError(details=_error_detail(response)) + if response.status_code >= 400: + raise SpotifyAPIError( + f"Spotify request failed with status {response.status_code}", + details=_error_detail(response), + ) + return response + + def exchange_code_for_token(self, code, redirect_uri): data = { 'code': code, 'redirect_uri': redirect_uri, 'grant_type': "authorization_code" } - - response = requests.post(self.AUTH_URL, data=data, headers=headers) - if response.status_code != 200: - raise Exception(f"Token exchange failed: {response.text}") - - return response.json() + response = self._request( + "POST", self.AUTH_URL, data=data, headers=self._basic_auth_headers() + ) + token_info = response.json() + if not token_info.get("access_token"): + raise SpotifyAuthError( + "Spotify did not return an access token", + details="Missing 'access_token' in token response", + ) + return token_info def refresh_token(self, refresh_token): - auth_header = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode("ascii") - headers = { - "Authorization": f"Basic {auth_header}", - "Content-Type": "application/x-www-form-urlencoded" - } data = { 'grant_type': 'refresh_token', 'refresh_token': refresh_token } - - response = requests.post(self.AUTH_URL, data=data, headers=headers) - if response.status_code != 200: - raise Exception(f"Token refresh failed: {response.text}") - - return response.json() + response = self._request( + "POST", self.AUTH_URL, data=data, headers=self._basic_auth_headers() + ) + token_info = response.json() + if not token_info.get("access_token"): + raise SpotifyAuthError( + "Spotify did not return a refreshed access token", + details="Missing 'access_token' in refresh response", + ) + return token_info def search_track(self, access_token, song_name, artist_name): """ - Search for a track by name and artist. - Returns the first match or None. + Search for a track by name and artist. + Returns the first match, or None when Spotify has no match. + + Authentication and rate limit failures are raised so callers can + distinguish "no match" from "the search never happened". """ - # Sanitize inputs if not song_name or not artist_name: return None - - query = f"track:{song_name} artist:{artist_name}" + params = { - "q": query, + "q": f"track:{song_name} artist:{artist_name}", "type": "track", "market": "US", "limit": 1 } - - try: - # 1. Try strict search first - response = requests.get( - f"{self.BASE_URL}/search", - headers=self.get_auth_headers(access_token), - params=params, - timeout=5 - ) - - if response.status_code == 200: - data = response.json() - items = data.get("tracks", {}).get("items", []) - if items: - return items[0] - elif response.status_code == 429: - print("Rate limited by Spotify") - return None - - # 2. Fallback: Relaxed search (just string matching) - # Sometimes AI gives "Title - Remastered" or slightly off artist names - relaxed_query = f"{song_name} {artist_name}" - params["q"] = relaxed_query - - response = requests.get( - f"{self.BASE_URL}/search", - headers=self.get_auth_headers(access_token), - params=params, - timeout=5 - ) - - if response.status_code == 200: - data = response.json() - items = data.get("tracks", {}).get("items", []) - if items: - print(f"Fallback search successful for: {song_name}") - return items[0] - - return None - except Exception as e: - print(f"Error searching for {song_name} by {artist_name}: {e}") - return None + headers = self.get_auth_headers(access_token) + search_url = f"{self.BASE_URL}/search" + + response = self._request("GET", search_url, headers=headers, params=params) + items = response.json().get("tracks", {}).get("items", []) + if items: + return items[0] + + # Fallback: relaxed search, since the AI may return titles such as + # "Title - Remastered" or slightly different artist spellings. + params["q"] = f"{song_name} {artist_name}" + response = self._request("GET", search_url, headers=headers, params=params) + items = response.json().get("tracks", {}).get("items", []) + if items: + logger.debug("Fallback search succeeded for %s", song_name) + return items[0] + + logger.info("No Spotify match for %s by %s", song_name, artist_name) + return None + + def search_tracks(self, access_token, query, limit=10): + params = { + "q": query, + "type": "track", + "market": "US", + "limit": limit + } + response = self._request( + "GET", + f"{self.BASE_URL}/search", + headers=self.get_auth_headers(access_token), + params=params, + ) + return response.json().get("tracks", {}).get("items", []) def create_playlist(self, access_token, user_id, name, description="Generated by Jam Genie", public=True): - url = f"{self.BASE_URL}/users/{user_id}/playlists" data = { "name": name, "description": description, "public": public } - - response = requests.post( - url, + response = self._request( + "POST", + f"{self.BASE_URL}/users/{user_id}/playlists", headers=self.get_auth_headers(access_token), - json=data + json=data, ) - response.raise_for_status() - return response.json() + playlist = response.json() + if not playlist.get("id"): + raise SpotifyAPIError( + "Spotify created a playlist without returning its id" + ) + return playlist def add_tracks_to_playlist(self, access_token, playlist_id, uris): if not uris: - return - - # Spotify API limit is 100 tracks per request - # split into chunks if needed (though our AI limits are usually lower) + return None + + # Spotify accepts at most 100 track URIs per request. + responses = [] + headers = self.get_auth_headers(access_token) url = f"{self.BASE_URL}/playlists/{playlist_id}/tracks" - - data = {"uris": uris} - response = requests.post( - url, + for start in range(0, len(uris), 100): + chunk = uris[start:start + 100] + response = self._request("POST", url, headers=headers, json={"uris": chunk}) + responses.append(response.json()) + return responses + + def get_user_playlists(self, access_token, limit=50): + response = self._request( + "GET", + f"{self.BASE_URL}/me/playlists", headers=self.get_auth_headers(access_token), - json=data + params={"limit": limit}, ) - response.raise_for_status() return response.json() def get_user_profile(self, access_token): - response = requests.get( + response = self._request( + "GET", f"{self.BASE_URL}/me", - headers=self.get_auth_headers(access_token) + headers=self.get_auth_headers(access_token), ) - if response.status_code != 200: - raise Exception(f"Failed to fetch profile: {response.text}") - return response.json() + profile = response.json() + if not profile.get("id"): + raise SpotifyAPIError("Spotify profile response contained no user id") + return profile def upload_playlist_cover(self, access_token, playlist_id, image_b64): """ Uploads a custom cover image to a playlist. image_b64: Base64 encoded JPEG image data (max 256KB) """ - url = f"{self.BASE_URL}/playlists/{playlist_id}/items" - - # NOTE: the API wants the raw Base64 string in the body - # Strip header if present (e.g. data:image/jpeg;base64,...) + # The API expects the raw Base64 string in the body, so strip any + # data URI prefix (e.g. data:image/jpeg;base64,...). if "," in image_b64: image_b64 = image_b64.split(",")[1] - - # Per user request: + headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "image/jpeg", "Accept": "application/json" } - - response = requests.put( - url, + self._request( + "PUT", + f"{self.BASE_URL}/playlists/{playlist_id}/items", headers=headers, - data=image_b64 + data=image_b64, ) - - if response.status_code == 202: - print(" Cover image uploaded!") - return True - else: - print(f" Upload failed: {response.status_code} - {response.text}") - return False + return True + + +def _error_detail(response): + try: + payload = response.json() + except ValueError: + return response.text[:500] + error = payload.get("error") + if isinstance(error, dict): + return error.get("message") or str(error) + if isinstance(error, str): + return payload.get("error_description") or error + return str(payload)[:500] diff --git a/run.py b/run.py index ec0455c..27cc865 100644 --- a/run.py +++ b/run.py @@ -1,10 +1,6 @@ from backend.app import create_app -import os app = create_app() if __name__ == "__main__": - # Ensure session dir exists - os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True) - # Run app.run(host='0.0.0.0', port=5000, debug=True)