diff --git a/.gitignore b/.gitignore index da59f65..253802f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/UI_Front/src/components/PreferenceInput.tsx b/UI_Front/src/components/PreferenceInput.tsx index 57c4eb9..cf0c9da 100644 --- a/UI_Front/src/components/PreferenceInput.tsx +++ b/UI_Front/src/components/PreferenceInput.tsx @@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Music, Zap, Clock, Users, ArrowRight, Shuffle, Plus, Waves, Activity, Sparkles, X, PenTool } from "lucide-react"; import { playlistService } from "@/lib/api"; import type { Preferences } from "@/lib/api"; +import { generatePlaylistName } from "@/lib/playlist"; import { SonicIdentity } from "./SonicIdentity"; interface PreferenceInputProps { @@ -145,26 +146,6 @@ export const PreferenceInput: React.FC = ({ onGenerate }) } }; - const generatePlaylistName = (preferences: Preferences): string => { - const moodName = preferences.moods[0] || "Amazing"; - const genreName = preferences.genres[0] || "Music"; - const names = [ - `${moodName} ${genreName} Vibes`, - `My ${moodName} Mix`, - `${genreName} Discovery`, - `${moodName} ${genreName} Journey`, - `Perfect ${moodName} Playlist`, - ]; - return names[Math.floor(Math.random() * names.length)]; - }; - - const calculateDuration = (trackCount: number): string => { - const totalMinutes = trackCount * 3; - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:00` : `${minutes}:00`; - }; - return (
diff --git a/UI_Front/src/components/SpotifyPlaylistGenerator.tsx b/UI_Front/src/components/SpotifyPlaylistGenerator.tsx index 5b90511..64804fa 100644 --- a/UI_Front/src/components/SpotifyPlaylistGenerator.tsx +++ b/UI_Front/src/components/SpotifyPlaylistGenerator.tsx @@ -224,28 +224,6 @@ const SpotifyPlaylistGenerator = () => { clearPersistedState(); }; - const generatePlaylistName = (prefs: Preferences): string => { - const moodName = prefs.moods[0] || "Amazing"; - const genreName = prefs.genres[0] || "Music"; - const names = [ - `${moodName} ${genreName} Vibes`, - `My ${moodName} Mix`, - `${genreName} Discovery`, - `${moodName} ${genreName} Journey`, - `Perfect ${moodName} Playlist`, - ]; - return names[Math.floor(Math.random() * names.length)]; - }; - - // NEW: Helper to calculate duration - const calculateDuration = (trackCount: number): string => { - const totalMinutes = trackCount * 3; // Estimate 3 minutes per track - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return hours > 0 - ? `${hours}:${minutes.toString().padStart(2, '0')}:00` - : `${minutes}:00`; - }; // ---- Show loading while checking auth ---- if (isCheckingAuth) { return ( diff --git a/UI_Front/src/lib/playlist.ts b/UI_Front/src/lib/playlist.ts new file mode 100644 index 0000000..a38b4c9 --- /dev/null +++ b/UI_Front/src/lib/playlist.ts @@ -0,0 +1,26 @@ +interface NamingPreferences { + moods: string[]; + genres: string[]; +} + +export const generatePlaylistName = (preferences: NamingPreferences): string => { + const moodName = preferences.moods[0] || "Amazing"; + const genreName = preferences.genres[0] || "Music"; + const names = [ + `${moodName} ${genreName} Vibes`, + `My ${moodName} Mix`, + `${genreName} Discovery`, + `${moodName} ${genreName} Journey`, + `Perfect ${moodName} Playlist`, + ]; + return names[Math.floor(Math.random() * names.length)]; +}; + +export const calculateDuration = (trackCount: number): string => { + const totalMinutes = trackCount * 3; // Estimate 3 minutes per track + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return hours > 0 + ? `${hours}:${minutes.toString().padStart(2, "0")}:00` + : `${minutes}:00`; +}; diff --git a/backend/routes/auth.py b/backend/routes/auth.py index ec507cb..718ff17 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, session, redirect, jsonify, current_app from ..config import Config -from ..services.spotify import SpotifyService +from ..utils import get_spotify_service, store_token_session import secrets import time from datetime import datetime @@ -68,17 +68,14 @@ def callback(): return jsonify({"error": "No code provided"}), 400 # Exchange Code - spotify = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + spotify = get_spotify_service() 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') - session['refresh_token'] = token_info.get('refresh_token') - expires_in = token_info.get('expires_in', 3600) - session['expires_at'] = datetime.now().timestamp() + expires_in + store_token_session(token_info) # Get User Profile immediately to store ID try: @@ -105,16 +102,10 @@ def auth_status(): # Try refresh if 'refresh_token' in session: try: - spotify = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + spotify = get_spotify_service() 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 + store_token_session(new_tokens) except Exception as e: print(f"Refresh failed: {e}") session.clear() diff --git a/backend/routes/playlist.py b/backend/routes/playlist.py index 778bf3b..1e561e1 100644 --- a/backend/routes/playlist.py +++ b/backend/routes/playlist.py @@ -1,19 +1,15 @@ from flask import Blueprint, request, session, jsonify import concurrent.futures -from ..config import Config -from ..services.spotify import SpotifyService from ..services.ai import AIService +from ..utils import require_spotify_auth, get_spotify_service, format_track playlist_bp = Blueprint('playlist', __name__) @playlist_bp.route('/Playlist_Generator', methods=['POST']) @playlist_bp.route('/Generate_Preview', methods=['POST']) +@require_spotify_auth def generate_preview(): - # 1. Auth Check - if 'access_token' not in session: - return jsonify({"error": "Not authenticated", "redirect": "/login"}), 401 - - # 2. Get Params + # 1. Get Params data = request.get_json() or {} preferences = data.get('preferences') if not preferences: @@ -29,14 +25,14 @@ def generate_preview(): except: playlist_length = 20 - # 3. AI Generation + # 2. 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)) target_ai_count = playlist_length + buffer_count ai_service = AIService() - spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + spotify_service = get_spotify_service() try: # Generate raw song list with buffer @@ -44,7 +40,7 @@ def generate_preview(): except Exception as e: return jsonify({"error": "AI Generation failed", "details": str(e)}), 500 - # 4. Spotify Search (Parallelized) + # 3. Spotify Search (Parallelized) found_tracks = [] # Extract token from session in the main thread @@ -77,23 +73,7 @@ def search_worker(token, song_info): 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') - }) + track_previews = [format_track(t) for t in found_tracks] return jsonify({ "tracks": track_previews, @@ -102,10 +82,8 @@ def search_worker(token, song_info): }) @playlist_bp.route('/Create_Playlist', methods=['POST']) +@require_spotify_auth def create_playlist(): - if 'access_token' not in session: - return jsonify({"error": "Not authenticated", "redirect": "/login"}), 401 - data = request.get_json() or {} name = data.get('name', 'AI Generated Bundle') description = data.get('description', 'Generated by PlaylistAI') @@ -115,7 +93,7 @@ def create_playlist(): if not uris: return jsonify({"error": "No tracks provided"}), 400 - spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + spotify_service = get_spotify_service() try: user_id = session.get('spotify_user_id') @@ -164,83 +142,26 @@ def create_playlist(): return jsonify({"error": "Failed to create playlist on Spotify", "details": str(e)}), 500 @playlist_bp.route('/Search_Track', methods=['GET']) +@require_spotify_auth def search_spotify_track(): - if 'access_token' not in session: - return jsonify({"error": "Not authenticated"}), 401 - query = request.args.get('q') if not query: return jsonify({"error": "Missing query"}), 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) + spotify_service = get_spotify_service() + try: + tracks = spotify_service.search_tracks(session['access_token'], query, limit=10) + return jsonify([format_track(t) for t in tracks]) except Exception as e: return jsonify({"error": str(e)}), 500 @playlist_bp.route('/Get_Playlists', methods=['GET']) +@require_spotify_auth def get_playlists(): - if 'access_token' not in session: - return jsonify({"error": "Not authenticated"}), 401 - - spotify_service = SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + spotify_service = get_spotify_service() 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()) + playlists = spotify_service.get_user_playlists(session['access_token']) + return jsonify(playlists) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/backend/services/spotify.py b/backend/services/spotify.py index 5c6f496..7073277 100644 --- a/backend/services/spotify.py +++ b/backend/services/spotify.py @@ -6,6 +6,7 @@ class SpotifyService: BASE_URL = "https://api.spotify.com/v1" AUTH_URL = "https://accounts.spotify.com/api/token" + RATE_LIMITED = object() def __init__(self, client_id, client_secret): self.client_id = client_id @@ -17,40 +18,58 @@ def get_auth_headers(self, access_token): "Content-Type": "application/json" } - def exchange_code_for_token(self, code, redirect_uri): + def _token_request(self, data, error_label): + """POST to the Spotify token endpoint with Basic client credentials.""" 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" } + + response = requests.post(self.AUTH_URL, data=data, headers=headers) + if response.status_code != 200: + raise Exception(f"{error_label} failed: {response.text}") + + return response.json() + + 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() + return self._token_request(data, "Token exchange") 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) + return self._token_request(data, "Token refresh") + + def _search_request(self, access_token, query, limit=1, timeout=None): + """Run a track search against the Spotify API and return matching items.""" + params = { + "q": query, + "type": "track", + "market": "US", + "limit": limit + } + + response = requests.get( + f"{self.BASE_URL}/search", + headers=self.get_auth_headers(access_token), + params=params, + timeout=timeout + ) + + if response.status_code == 429: + print("Rate limited by Spotify") + return self.RATE_LIMITED if response.status_code != 200: - raise Exception(f"Token refresh failed: {response.text}") - - return response.json() + return None + + return response.json().get("tracks", {}).get("items", []) def search_track(self, access_token, song_name, artist_name): """ @@ -61,56 +80,46 @@ def search_track(self, access_token, song_name, artist_name): if not song_name or not artist_name: return None - query = f"track:{song_name} artist:{artist_name}" - params = { - "q": query, - "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 + items = self._search_request( + access_token, f"track:{song_name} artist:{artist_name}", 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") + if items is self.RATE_LIMITED: return None + if items: + return items[0] # 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 + items = self._search_request( + access_token, f"{song_name} {artist_name}", 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] - + if items is not self.RATE_LIMITED and 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 + def search_tracks(self, access_token, query, limit=10): + """Free-text track search. Returns a list of raw track objects.""" + items = self._search_request(access_token, query, limit=limit) + if items is None or items is self.RATE_LIMITED: + raise Exception("Spotify search failed") + return items + + def get_user_playlists(self, access_token, limit=50): + response = requests.get( + f"{self.BASE_URL}/me/playlists", + headers=self.get_auth_headers(access_token), + params={"limit": limit} + ) + response.raise_for_status() + return response.json() + 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 = { diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..58fb986 --- /dev/null +++ b/backend/utils.py @@ -0,0 +1,57 @@ +from functools import wraps +from datetime import datetime + +from flask import session, jsonify + +from .config import Config +from .services.spotify import SpotifyService + +DEFAULT_TRACK_IMAGE = "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=100&h=100&fit=crop" + + +def require_spotify_auth(f): + """Reject the request with a 401 if there is no Spotify access token in the session.""" + @wraps(f) + def wrapper(*args, **kwargs): + if 'access_token' not in session: + return jsonify({"error": "Not authenticated", "redirect": "/login"}), 401 + return f(*args, **kwargs) + return wrapper + + +def get_spotify_service(): + """Build a SpotifyService from the app config.""" + return SpotifyService(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET) + + +def store_token_session(token_info): + """Persist Spotify token info (access/refresh tokens and expiry) in the session.""" + session['access_token'] = token_info.get('access_token') + if 'refresh_token' in token_info: + session['refresh_token'] = token_info.get('refresh_token') + expires_in = token_info.get('expires_in', 3600) + session['expires_at'] = datetime.now().timestamp() + expires_in + session.modified = True + + +def format_duration(duration_ms): + """Format a millisecond duration as m:ss.""" + return f"{int(duration_ms / 60000)}:{int((duration_ms % 60000) / 1000):02d}" + + +def format_track(track): + """Convert a raw Spotify track object into the shape the frontend expects.""" + image = DEFAULT_TRACK_IMAGE + if track.get('album', {}).get('images'): + image = track['album']['images'][0]['url'] + + return { + "id": track['id'], + "uri": track['uri'], + "title": track['name'], + "artist": track['artists'][0]['name'], + "album": track['album']['name'], + "duration": format_duration(track['duration_ms']), + "image": image, + "preview_url": track.get('preview_url') + }