diff --git a/Backend/app.py b/Backend/app.py index 01319a2..3f218d5 100644 --- a/Backend/app.py +++ b/Backend/app.py @@ -23,6 +23,7 @@ get_spotify_redirect_uri, ) from Backend.playlist_processing import process_all + from Backend.grouping import normalize_feature_weights from Backend.helpers import generate_random_string from Backend.job_status_store import ( set_job_state, @@ -39,6 +40,7 @@ get_spotify_redirect_uri, ) from playlist_processing import process_all # type: ignore + from grouping import normalize_feature_weights # type: ignore from helpers import generate_random_string # type: ignore from job_status_store import ( # type: ignore set_job_state, @@ -120,9 +122,53 @@ def _missing_required_scopes() -> list[str]: JOB_STATUS_TTL_SECONDS = int(os.getenv("JOB_STATUS_TTL_SECONDS", "21600")) -def get_auth_token_from_request(): - """Return auth token from request cookies, falling back to server session.""" - return session.get("auth_token") or request.cookies.get("auth_token") +def _clear_auth_session(): + """Clear server-side auth/session values for a stale login state.""" + session.pop("uid", None) + session.pop("auth_token", None) + session.pop("refresh_token", None) + session.pop("auth_scopes", None) + + +def _unauthorized_session_response(message: str = "Spotify session expired. Please log in again."): + """Return standardized 401 payload for expired/missing auth sessions.""" + return ( + jsonify( + { + "Code": 401, + "Error": message, + "reauth": True, + } + ), + 401, + ) + + +def _resolve_active_auth_token(): + """ + Return a valid Spotify auth token for current request. + + Attempts refresh when session token is expired. Returns tuple: + (auth_token, error_response_or_none) + """ + auth_token = session.get("auth_token") + refresh_token = session.get("refresh_token") + + if not auth_token: + _clear_auth_session() + return None, _unauthorized_session_response("Authorization required.") + + if is_access_token_valid(auth_token): + return auth_token, None + + if refresh_token: + refreshed_token = refresh_access_token(refresh_token) + if refreshed_token: + session["auth_token"] = refreshed_token + return refreshed_token, None + + _clear_auth_session() + return None, _unauthorized_session_response() def _prune_old_jobs(): @@ -136,11 +182,54 @@ def _set_job_state(job_id: str, **fields): set_job_state(job_id, **fields) -def _run_process_playlist_job(job_id: str, auth_token: str, playlist_ids: list[str]): +def _run_process_playlist_job( + job_id: str, + auth_token: str, + playlist_ids: list[str], + feature_weights: dict[str, float] | None = None, + split_criterion: str | None = None, +): """Run playlist processing in background and persist status fields.""" + total_playlists = len(playlist_ids) + + def _emit_progress( + completed_playlists: int, + total_playlists: int, + failed_playlists: int = 0, + last_completed_playlist_id: str | None = None, + last_completed_playlist_name: str | None = None, + ): + safe_total = max(1, int(total_playlists)) + raw_percent = int(round((completed_playlists / safe_total) * 100)) + progress_percent = max(0, min(100, raw_percent)) + _set_job_state( + job_id, + completed_playlists=completed_playlists, + total_playlists=total_playlists, + failed_playlists=failed_playlists, + progress_percent=progress_percent, + last_completed_playlist_id=last_completed_playlist_id, + last_completed_playlist_name=last_completed_playlist_name, + ) + _set_job_state(job_id, status="running", started_at=time.time()) + _emit_progress( + completed_playlists=0, + total_playlists=total_playlists, + failed_playlists=0, + ) try: - process_all(auth_token, playlist_ids) + process_all( + auth_token, + playlist_ids, + feature_weights=feature_weights, + split_criterion=split_criterion, + progress_callback=_emit_progress, + ) + _emit_progress( + completed_playlists=total_playlists, + total_playlists=total_playlists, + ) _set_job_state( job_id, status="succeeded", @@ -193,6 +282,8 @@ def login_handler(): if not is_access_token_valid(auth_token): if refresh_token: new_auth_token = refresh_access_token(refresh_token) + if not new_auth_token: + return redirect_to_spotify_login() session["auth_token"] = new_auth_token auth_token = new_auth_token else: @@ -264,16 +355,17 @@ def callback_handler(): @app.route("/api/user-playlists") def get_playlist_handler(): """Return current user's Spotify playlists based on auth cookie token.""" - auth_token = get_auth_token_from_request() - - if not auth_token: - print(f"NO AUTH: {auth_token}") - return {"Code": 401, "Error": "Authorization token required"} + auth_token, auth_error = _resolve_active_auth_token() + if auth_error: + return auth_error playlists = get_all_playlists(auth_token) if not playlists: - return {"Code": 500, "Error": "Failed to get playlists"} + if not is_access_token_valid(auth_token): + _clear_auth_session() + return _unauthorized_session_response() + return jsonify({"Code": 502, "Error": "Failed to get playlists"}), 502 return jsonify(playlists) @@ -282,10 +374,9 @@ def get_playlist_handler(): @app.route("/api/process-playlist", methods=["POST"]) def process_playlist_handler(): """Start async processing job for selected playlists.""" - auth_token = get_auth_token_from_request() - - if not auth_token: - return "Authorization required", 401 + auth_token, auth_error = _resolve_active_auth_token() + if auth_error: + return auth_error missing_scopes = _missing_required_scopes() if missing_scopes: @@ -302,9 +393,31 @@ def process_playlist_handler(): assert request.json playlist_ids = request.json.get("playlistIds", []) + feature_weights_payload = request.json.get("featureWeights") + split_criterion_payload = request.json.get("splitCriterion") if not playlist_ids: return "No playlist IDs provided", 400 + if feature_weights_payload is not None and not isinstance(feature_weights_payload, dict): + return ( + jsonify( + { + "Code": 400, + "Error": "featureWeights must be an object keyed by feature name.", + } + ), + 400, + ) + feature_weights = ( + normalize_feature_weights(feature_weights_payload) + if isinstance(feature_weights_payload, dict) + else None + ) + split_criterion = ( + split_criterion_payload.strip().lower() + if isinstance(split_criterion_payload, str) and split_criterion_payload.strip() + else None + ) _prune_old_jobs() job_id = str(uuid.uuid4()) @@ -315,10 +428,18 @@ def process_playlist_handler(): finished_at=None, error=None, playlist_count=len(playlist_ids), + completed_playlists=0, + total_playlists=len(playlist_ids), + failed_playlists=0, + progress_percent=0, + last_completed_playlist_id=None, + last_completed_playlist_name=None, + feature_weights=feature_weights, + split_criterion=split_criterion, ) job_thread = threading.Thread( target=_run_process_playlist_job, - args=(job_id, auth_token, playlist_ids), + args=(job_id, auth_token, playlist_ids, feature_weights, split_criterion), daemon=True, ) job_thread.start() diff --git a/Backend/grouping.py b/Backend/grouping.py index 07d5016..e3ed6f3 100644 --- a/Backend/grouping.py +++ b/Backend/grouping.py @@ -1,6 +1,7 @@ """Clustering utilities for grouping tracks by audio feature similarity.""" import os +import math import numpy as np from sklearn.metrics import pairwise_distances @@ -40,6 +41,8 @@ "tempo": 0.85, "valence": 1.55, } +MIN_FEATURE_WEIGHT = 0.0 +MAX_FEATURE_WEIGHT = 3.0 def _env_positive_int(name: str, default_value: int) -> int: @@ -69,6 +72,26 @@ def _env_positive_int(name: str, default_value: int) -> int: REFINE_MAX_TRACKS = _env_positive_int("CLUSTER_REFINE_MAX_TRACKS", 600) +def normalize_feature_weights( + feature_weights: dict[str, float] | None, +) -> dict[str, float]: + """Return bounded feature weights merged with defaults.""" + if not isinstance(feature_weights, dict): + return dict(FEATURE_WEIGHTS) + + normalized = {} + for key, default_value in FEATURE_WEIGHTS.items(): + raw_value = feature_weights.get(key, default_value) + try: + candidate = float(raw_value) + except (TypeError, ValueError): + candidate = default_value + if not math.isfinite(candidate): + candidate = default_value + normalized[key] = min(MAX_FEATURE_WEIGHT, max(MIN_FEATURE_WEIGHT, candidate)) + return normalized + + def _merge_small_clusters( scaled_features: np.ndarray, labels: np.ndarray, min_cluster_size: int ) -> np.ndarray: @@ -251,7 +274,9 @@ def _refine_cluster_cohesion( return refined -def cluster_df(track_audio_features: list[dict]) -> pd.DataFrame: +def cluster_df( + track_audio_features: list[dict], feature_weights: dict[str, float] | None = None +) -> pd.DataFrame: """Return dataframe with track id and assigned GMM clusters.""" if not track_audio_features: return pd.DataFrame(columns=["id", "cluster"]) @@ -278,7 +303,8 @@ def cluster_df(track_audio_features: list[dict]) -> pd.DataFrame: scaler = StandardScaler() scaled = scaler.fit_transform(feature_frame) - weights = np.array([FEATURE_WEIGHTS.get(key, 1.0) for key in available_keys]) + effective_feature_weights = normalize_feature_weights(feature_weights) + weights = np.array([effective_feature_weights.get(key, 1.0) for key in available_keys]) weighted_scaled = scaled * weights track_count = len(feature_frame) diff --git a/Backend/playlist_processing.py b/Backend/playlist_processing.py index f3239cb..a21a65f 100644 --- a/Backend/playlist_processing.py +++ b/Backend/playlist_processing.py @@ -61,6 +61,20 @@ def _env_positive_int(name: str, default_value: int) -> int: CREATE_PLAYLIST_CONCURRENCY = _env_positive_int("PLAYLIST_CREATE_CONCURRENCY", 6) ADD_SONGS_CONCURRENCY = _env_positive_int("PLAYLIST_ADD_CONCURRENCY", 16) +SPLIT_CRITERION_LABELS = { + "balanced": "Balanced", + "energy": "Energy", + "valence": "Mood", + "danceability": "Danceability", + "tempo": "Tempo", + "acousticness": "Acousticness", + "instrumentalness": "Instrumental", + "speechiness": "Speechiness", + "liveness": "Liveness", + "loudness": "Loudness", + "custom": "Custom", +} + def log_step_time(step_name, start_time): """Print elapsed seconds for a named processing step.""" @@ -68,6 +82,16 @@ def log_step_time(step_name, start_time): print(f"{step_name} completed in {elapsed_time:.2f} seconds.") +def _resolve_split_criterion_label(split_criterion: str | None) -> str | None: + """Return friendly split criterion label for playlist naming and metadata.""" + if not split_criterion: + return None + normalized = split_criterion.strip().lower() + if not normalized: + return None + return SPLIT_CRITERION_LABELS.get(normalized, normalized.replace("_", " ").title()) + + async def get_playlist_track_ids(auth_token, playlist_id): """Return all track IDs from the playlist.""" start_time = time.time() @@ -118,11 +142,17 @@ async def fetch_page(offset): async def create_and_populate_cluster_playlists( - clustered_tracks, feature_by_track_id, user_id, auth_token, playlist_name + clustered_tracks, + feature_by_track_id, + user_id, + auth_token, + playlist_name, + split_criterion: str | None = None, ): """Create playlists for clusters and populate them with grouped tracks.""" start_time = time.time() tracks_by_cluster = defaultdict(list) + split_criterion_label = _resolve_split_criterion_label(split_criterion) for _, row in clustered_tracks.iterrows(): tracks_by_cluster[int(row["cluster"])].append(row["id"]) @@ -165,17 +195,26 @@ async def create_and_populate_cluster_playlists( cluster_trait_summary = build_cluster_trait_summary( cluster_means, global_means ) - playlist_title = f"{playlist_name} - {cluster_reason}" + if split_criterion_label: + playlist_title = f"{playlist_name} [{split_criterion_label}] - {cluster_reason}" + else: + playlist_title = f"{playlist_name} - {cluster_reason}" if len(playlist_title) > 100: playlist_title = playlist_title[:97] + "..." + criterion_sentence = ( + f"Primary split criterion: {split_criterion_label}. " + if split_criterion_label + else "" + ) cluster_candidates.append( { "cluster_id": cluster_id, "track_ids": valid_cluster_track_ids, "playlist_title": playlist_title, "playlist_description": ( - f"Grouped by audio similarity: {cluster_reason}. " + criterion_sentence + + f"Grouped by audio similarity: {cluster_reason}. " f"Trait drivers: {cluster_trait_summary}. " "Made using Splitify: https://splitifytool.com/" ), @@ -281,7 +320,13 @@ async def process_cluster(candidate): log_step_time("Creating and populating cluster playlists", start_time) -async def process_single_playlist(auth_token, playlist_id, user_id): +async def process_single_playlist( + auth_token, + playlist_id, + user_id, + feature_weights: dict[str, float] | None = None, + split_criterion: str | None = None, +): """Split one playlist into cluster playlists.""" start_time = time.time() print(f"Processing {playlist_id}...") @@ -300,7 +345,11 @@ async def process_single_playlist(auth_token, playlist_id, user_id): ) if not track_ids: print(f"No tracks found for playlist {playlist_id}") - return + return { + "playlist_id": playlist_id, + "playlist_name": playlist_name, + "result": "skipped_no_tracks", + } feature_fetch_start = time.time() audio_features, _reccobeats_diagnostics = await get_track_audio_features( @@ -309,7 +358,11 @@ async def process_single_playlist(auth_token, playlist_id, user_id): log_step_time(f"Resolve audio features ({playlist_id})", feature_fetch_start) if not audio_features: print(f"No audio features available for playlist {playlist_id}") - return + return { + "playlist_id": playlist_id, + "playlist_name": playlist_name, + "result": "skipped_no_audio_features", + } feature_by_track_id = { row["id"]: row for row in audio_features if isinstance(row, dict) and row.get("id") @@ -318,11 +371,17 @@ async def process_single_playlist(auth_token, playlist_id, user_id): removed = len(track_ids) - len(feature_by_track_id) print(f"Dropped {removed} tracks without usable unique audio features.") cluster_start = time.time() - clustered_tracks = await asyncio.to_thread(cluster_df, audio_features) + clustered_tracks = await asyncio.to_thread( + cluster_df, audio_features, feature_weights + ) log_step_time(f"Cluster tracks ({playlist_id})", cluster_start) if clustered_tracks.empty: print(f"Failed to cluster tracks for playlist {playlist_id}") - return + return { + "playlist_id": playlist_id, + "playlist_name": playlist_name, + "result": "failed_clustering", + } cluster_sizes = clustered_tracks["cluster"].value_counts().tolist() print( "Cluster distribution stats:", @@ -333,7 +392,12 @@ async def process_single_playlist(auth_token, playlist_id, user_id): playlist_write_start = time.time() await create_and_populate_cluster_playlists( - clustered_tracks, feature_by_track_id, user_id, auth_token, playlist_name + clustered_tracks, + feature_by_track_id, + user_id, + auth_token, + playlist_name, + split_criterion=split_criterion, ) log_step_time( f"Write clustered playlists ({playlist_id})", @@ -341,23 +405,96 @@ async def process_single_playlist(auth_token, playlist_id, user_id): ) log_step_time(f"Processing playlist {playlist_id}", start_time) + return { + "playlist_id": playlist_id, + "playlist_name": playlist_name, + "result": "succeeded", + } -async def process_playlists(auth_token, playlist_ids): +async def process_playlists( + auth_token, + playlist_ids, + feature_weights: dict[str, float] | None = None, + split_criterion: str | None = None, + progress_callback=None, +): """Process multiple playlists by splitting with K-means clustering.""" start_time = time.time() print(f"Processing {len(playlist_ids)} playlists...") user_id = get_user_id(auth_token) + total_playlists = len(playlist_ids) + completed_playlists = 0 + failed_playlists = 0 + + def emit_progress( + last_completed_playlist_id: str | None = None, + last_completed_playlist_name: str | None = None, + ): + if progress_callback is None: + return + progress_callback( + completed_playlists=completed_playlists, + total_playlists=total_playlists, + failed_playlists=failed_playlists, + last_completed_playlist_id=last_completed_playlist_id, + last_completed_playlist_name=last_completed_playlist_name, + ) + + emit_progress() tasks = [ - process_single_playlist(auth_token, playlist_id, user_id) + asyncio.create_task( + process_single_playlist( + auth_token, + playlist_id, + user_id, + feature_weights=feature_weights, + split_criterion=split_criterion, + ) + ) for playlist_id in playlist_ids ] - await asyncio.gather(*tasks) + for task in asyncio.as_completed(tasks): + last_completed_playlist_id = None + last_completed_playlist_name = None + try: + playlist_result = await task + if isinstance(playlist_result, dict): + last_completed_playlist_id = playlist_result.get("playlist_id") + last_completed_playlist_name = playlist_result.get("playlist_name") + except Exception as error: # pylint: disable=broad-exception-caught + failed_playlists += 1 + print(f"Playlist processing task failed: {error}") + finally: + completed_playlists += 1 + emit_progress( + last_completed_playlist_id=last_completed_playlist_id, + last_completed_playlist_name=last_completed_playlist_name, + ) + + if failed_playlists > 0: + raise RuntimeError( + f"{failed_playlists} playlist(s) failed during processing." + ) log_step_time("Processing all playlists", start_time) -def process_all(auth_token, playlist_ids): +def process_all( + auth_token, + playlist_ids, + feature_weights: dict[str, float] | None = None, + split_criterion: str | None = None, + progress_callback=None, +): """Run async playlist processing entrypoint from sync Flask handler.""" - asyncio.run(process_playlists(auth_token, playlist_ids)) + asyncio.run( + process_playlists( + auth_token, + playlist_ids, + feature_weights=feature_weights, + split_criterion=split_criterion, + progress_callback=progress_callback, + ) + ) diff --git a/Frontend/spotify-oauth/src/App.css b/Frontend/spotify-oauth/src/App.css index 7c65024..f70d5fd 100644 --- a/Frontend/spotify-oauth/src/App.css +++ b/Frontend/spotify-oauth/src/App.css @@ -7,7 +7,7 @@ align-items: center; justify-content: center; font-size: calc(10px + 2vmin); - color: white; + color: #e9f2ee; } .app-header { @@ -23,7 +23,7 @@ font-size: 2.5em; margin-bottom: 20px; /* Space between title and button */ font-weight: 500; /* Semi-bold font weight */ - color: #FFFFFF; + color: #f1f7f4; } .spotify-auth-btn { @@ -32,7 +32,7 @@ border: none; border-radius: 25px; /* Rounded edges */ background: linear-gradient(to right, #1db954, #1ed760); /* Gradient for depth */ - color: white; + color: #f6fbf8; font-weight: 700; /* Bold font weight */ font-size: 1.1em; letter-spacing: 1.2px; /* Slight letter spacing */ diff --git a/Frontend/spotify-oauth/src/LoginPage.css b/Frontend/spotify-oauth/src/LoginPage.css index f7e03c3..4f51344 100644 --- a/Frontend/spotify-oauth/src/LoginPage.css +++ b/Frontend/spotify-oauth/src/LoginPage.css @@ -4,7 +4,8 @@ flex-direction: column; align-items: center; justify-content: center; - height: 100vh; + height: 100vh; + color: #e8f1ed; } .login-page button { @@ -12,7 +13,7 @@ margin-top: 20px; cursor: pointer; background-color: #1DB954; /* Spotify green */ - color: white; + color: #f4faf7; border: none; border-radius: 25px; font-size: 1em; diff --git a/Frontend/spotify-oauth/src/PlaylistInputPage.css b/Frontend/spotify-oauth/src/PlaylistInputPage.css index 317698e..5ba1187 100644 --- a/Frontend/spotify-oauth/src/PlaylistInputPage.css +++ b/Frontend/spotify-oauth/src/PlaylistInputPage.css @@ -1,81 +1,621 @@ -.playlist-container { - width: 100%; - max-width: 1200px; /* Increased the width for larger layout */ +.playlist-page { + --brand-500: #2f8d5f; + --brand-600: #26764f; + --brand-100: #eaf4ee; + --surface-0: #f2f7f4; + --surface-1: #f6faf8; + --border-soft: #d8e2dc; + --text-strong: #1f2f28; + --text-muted: #4b6157; + width: min(1200px, 100%); margin: 0 auto; padding: 20px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + color: var(--text-strong); + font-size: 16px; + line-height: 1.35; + background: #eef4f1; + border: 1px solid var(--border-soft); + border-radius: 14px; } -h2 { +.playlist-title { text-align: center; - margin-bottom: 20px; + margin-bottom: 8px; + color: var(--text-strong); } -.playlist-list { - list-style-type: none; - padding: 0; +.playlist-subtitle { + margin: 0 auto 24px; + max-width: 760px; + text-align: center; + color: var(--text-muted); +} + +.auth-notice { + margin: 0 auto 16px; + max-width: 760px; + padding: 10px 12px; + border: 1px solid #dccaa7; + border-radius: 10px; + background: #f7f0df; + color: #5b4424; + font-size: 0.9rem; + font-weight: 600; +} + +.weights-panel { + margin-bottom: 24px; + padding: 18px; + border: 1px solid var(--border-soft); + border-radius: 14px; + background: linear-gradient(180deg, #f9fcfa 0%, var(--surface-1) 100%); +} + +.progress-panel { + margin-bottom: 18px; + padding: 14px 16px; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: #f9fcfa; +} + +.progress-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.progress-title { + margin: 0; + font-size: 1rem; +} + +.progress-status-pill { + border-radius: 999px; + padding: 4px 10px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text-strong); + background: #e6eee9; +} + +.progress-status-pill.queued { + background: #e5ebf4; + color: #2f4a67; +} + +.progress-status-pill.running { + background: #e7f3ec; + color: #2c5a43; +} + +.progress-status-pill.succeeded { + background: #ddecdf; + color: #255038; +} + +.progress-status-pill.failed { + background: #f4e4e4; + color: #6a3232; +} + +.progress-meta { + margin: 10px 0 8px; + color: var(--text-muted); + font-size: 0.9rem; +} + +.progress-track { + width: 100%; + height: 10px; + border-radius: 999px; + background: #dce5e0; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #5aa27b 0%, var(--brand-500) 100%); + transition: width 0.35s ease; +} + +.progress-percent { + margin: 6px 0 0; + color: #365446; + font-size: 0.85rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.progress-last-item { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 0.86rem; +} + +.progress-error { + margin: 8px 0 0; + color: #7a2f2f; + font-size: 0.86rem; + font-weight: 600; +} + +.progress-complete { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid #c8ddd0; + border-radius: 10px; + background: #eef7f1; +} + +.progress-complete-title { + margin: 0; + color: #2e5843; + font-size: 0.9rem; + font-weight: 700; +} + +.progress-complete-text { + margin: 4px 0 0; + color: #446355; + font-size: 0.85rem; +} + +.weights-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.weights-header h3 { + margin: 0; +} + +.weights-note { + margin: 0 0 14px; + color: var(--text-muted); + font-size: 0.92rem; +} + +.weights-reset-button { + border: 1px solid var(--brand-500); + background: var(--surface-0); + color: var(--brand-600); + border-radius: 999px; + padding: 8px 14px; + font-weight: 600; + cursor: pointer; +} + +.weights-reset-button:hover { + background: #f2f8f4; +} + +.weights-reset-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.criteria-row { display: grid; - grid-template-columns: repeat(2, 1fr); /* 2 columns layout */ - gap: 20px; /* Space between the grid items */ + grid-template-columns: repeat(auto-fit, minmax(165px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.criteria-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--surface-0); + cursor: pointer; + text-align: left; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +.criteria-button:hover { + border-color: var(--brand-500); + box-shadow: 0 1px 4px rgba(33, 70, 52, 0.08); +} + +.criteria-button.active { + border-color: var(--brand-500); + background: var(--brand-100); + box-shadow: 0 0 0 2px rgba(47, 141, 95, 0.15); +} + +.criteria-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.criteria-label { + font-weight: 700; } -.playlist-item { +.criteria-description { + color: var(--text-muted); + font-size: 0.9rem; +} + +.advanced-toggle-row { display: flex; align-items: center; - margin-bottom: 10px; - border: 1px solid #e1e1e1; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.advanced-toggle-button { + border: 1px solid var(--brand-500); + background: var(--surface-0); + color: var(--brand-600); + border-radius: 999px; + padding: 8px 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, box-shadow 0.2s ease; +} + +.advanced-toggle-button:hover { + background: #f0f7f3; +} + +.advanced-toggle-button.open { + background: #e8f3ec; + box-shadow: 0 0 0 2px rgba(47, 141, 95, 0.14); +} + +.advanced-toggle-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.advanced-summary { + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 600; +} + +.weights-list { + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; + list-style: none; +} + +.weight-item { + border: 1px solid #dde4df; + border-radius: 10px; + background: var(--surface-0); padding: 10px; +} + +.weight-label { + display: block; + margin-bottom: 8px; +} + +.weight-name { + display: block; + font-weight: 600; + margin-bottom: 2px; +} + +.weight-hint { + display: block; + color: var(--text-muted); + font-size: 0.86rem; +} + +.weight-input-row { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 46px 58px; + gap: 8px; + align-items: center; +} + +.weight-bound { + font-size: 0.82rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + min-width: 0; +} + +.weight-slider { + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + height: 8px; + margin: 0; + padding: 0; + border-radius: 999px; + background-color: #d7dfda; + background-image: linear-gradient(var(--brand-500), var(--brand-500)); + background-repeat: no-repeat; + background-size: var(--slider-fill) 100%; + outline: none; + cursor: pointer; + touch-action: pan-y; +} + +.weight-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid #edf4f0; + background: var(--brand-500); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15); +} + +.weight-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid #edf4f0; + background: var(--brand-500); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15); +} + +.weight-slider::-moz-range-track { + height: 8px; + border-radius: 999px; + background: #d7dfda; +} + +.weight-slider::-moz-range-progress { + height: 8px; + border-radius: 999px; + background: var(--brand-500); +} + +.weight-value { + font-weight: 700; + width: 58px; + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 1rem; + color: #2f4e40; +} + +.playlist-list { + list-style-type: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px; +} + +.playlist-card { + list-style-type: none; +} + +.playlist-card-button { + width: 100%; + aspect-ratio: 1 / 1; + border: 2px solid #d4ddd8; border-radius: 10px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + padding: 0; + cursor: pointer; + background: var(--surface-0); + text-align: left; + overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +.playlist-card-button:hover { + border-color: var(--brand-500); + box-shadow: 0 4px 10px rgba(32, 68, 50, 0.12); +} + +.playlist-card-button:focus-visible { + outline: 3px solid rgba(47, 141, 95, 0.28); + outline-offset: 1px; +} + +.playlist-image-frame { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 8px; + border: 2px solid transparent; } -.playlist-image-container { - margin-right: 10px; - flex-shrink: 0; +.playlist-card.selected .playlist-image-frame { + border-color: var(--brand-500); +} + +.playlist-card.selected .playlist-card-button { + border-color: var(--brand-500); + background: linear-gradient(180deg, #f4faf6 0%, #edf5f0 100%); + box-shadow: 0 0 0 2px rgba(47, 141, 95, 0.2), 0 6px 12px rgba(32, 68, 50, 0.14); } .playlist-image { - width: 200px; /* Increased the image width */ - height: 200px; /* Increased the image height */ - border-radius: 5px; - object-fit: cover; /* This ensures the images remain proportional */ + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: #121212; +} + +.playlist-fallback-image { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + font-size: 2rem; + font-weight: 700; + color: #f3f7f5; + background: linear-gradient(145deg, #4a4a4a, #2f2f2f); +} + +.playlist-selected-pill { + position: absolute; + top: 8px; + right: 8px; + background: var(--brand-600); + color: #f3f8f5; + border-radius: 999px; + padding: 4px 8px; + font-size: 0.72rem; + font-weight: 700; + opacity: 0; + transform: translateY(-4px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.playlist-card.selected .playlist-selected-pill { + opacity: 1; + transform: translateY(0); +} + +.playlist-name { + margin: 0; + font-weight: 600; + line-height: 1.2; + font-size: 0.9rem; + color: #f7fbf8; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55); + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.playlist-stats { + margin: 4px 0 0; + color: rgba(247, 251, 248, 0.93); + font-size: 0.8rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); +} + +.playlist-overlay { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 10px 8px 8px; + color: #f7fbf8; + background: linear-gradient( + to top, + rgba(16, 24, 20, 0.82) 0%, + rgba(16, 24, 20, 0.48) 50%, + rgba(0, 0, 0, 0) 100% + ); +} + +.playlist-actions-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 14px; +} + +.clear-selection-button { + border: 1px solid var(--border-soft); + background: var(--surface-0); + color: var(--text-muted); + border-radius: 999px; + padding: 8px 14px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; } -button { +.clear-selection-button:hover { + border-color: var(--brand-500); + background: #eff6f2; +} + +.clear-selection-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.selection-count { + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.selection-notice { + margin: 8px 0 0; + color: #6c3f2d; + background: #f7efe9; + border: 1px solid #e8d5c8; + border-radius: 8px; + padding: 8px 10px; + font-size: 0.86rem; + font-weight: 600; +} + +.process-button { display: block; margin: 20px auto; - padding: 20px 40px; /* Increased padding for a bigger button */ - font-size: 1.2em; /* Increased font size */ - background-color: #1DB954; /* Spotify Green */ - color: #FFFFFF; + padding: 20px 40px; + font-size: 1.2em; + background-color: var(--brand-500); + color: #f5faf7; border: none; border-radius: 20px; cursor: pointer; transition: background-color 0.3s ease; } -/* Media query for mobile view */ -@media (max-width: 768px) { - .playlist-list { - grid-template-columns: 1fr; /* Single column for mobile view */ - } +.process-button:hover { + background-color: var(--brand-600); } -button:hover { - background-color: #1AA34A; +.process-button:disabled { + opacity: 0.7; + cursor: not-allowed; } -li { - list-style-type: none; - cursor: pointer; - transition: border 0.3s ease; -} +@media (max-width: 768px) { + .playlist-page { + padding: 14px; + } -li.selected img { - border: 3px solid #1DB954; /* Spotify green border for selected playlists */ -} + .weights-header { + flex-direction: column; + align-items: flex-start; + } + + .progress-header-row { + flex-direction: column; + align-items: flex-start; + } -img { - border-radius: 5px; - transition: border 0.3s ease; -} \ No newline at end of file + .advanced-toggle-row { + flex-direction: column; + align-items: flex-start; + } + + .playlist-actions-row { + flex-direction: column; + align-items: flex-start; + } + + .process-button { + width: 100%; + } +} diff --git a/Frontend/spotify-oauth/src/PlaylistInputPage.jsx b/Frontend/spotify-oauth/src/PlaylistInputPage.jsx index 0c232d9..19b0304 100644 --- a/Frontend/spotify-oauth/src/PlaylistInputPage.jsx +++ b/Frontend/spotify-oauth/src/PlaylistInputPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './PlaylistInputPage.css'; const API_BASE_URL = @@ -6,35 +6,343 @@ const API_BASE_URL = import.meta.env.REACT_APP_API_BASE_URL || '/api'; +const DEFAULT_FEATURE_WEIGHTS = Object.freeze({ + acousticness: 1.10, + danceability: 1.35, + energy: 1.55, + instrumentalness: 0.85, + liveness: 0.70, + loudness: 0.75, + speechiness: 1.35, + tempo: 0.85, + valence: 1.55, +}); + +const FEATURE_CONTROLS = [ + { + key: 'energy', + label: 'Energy', + description: 'Push splits toward intensity and momentum.', + }, + { + key: 'valence', + label: 'Mood', + description: 'Separate bright, positive songs from darker vibes.', + }, + { + key: 'danceability', + label: 'Danceability', + description: 'Favor rhythmic, groove-focused tracks.', + }, + { + key: 'tempo', + label: 'Tempo', + description: 'Prioritize pace differences (slow vs fast).', + }, + { + key: 'acousticness', + label: 'Acousticness', + description: 'Emphasize unplugged and organic textures.', + }, + { + key: 'instrumentalness', + label: 'Instrumental', + description: 'Push songs with fewer vocals into their own clusters.', + }, + { + key: 'speechiness', + label: 'Speechiness', + description: 'Separate rap/spoken-word from melodic vocal tracks.', + }, + { + key: 'liveness', + label: 'Liveness', + description: 'Differentiate live-feel recordings from studio cuts.', + }, + { + key: 'loudness', + label: 'Loudness', + description: 'Use overall volume profile as a grouping signal.', + }, +]; + +const CRITERIA_OPTIONS = { + balanced: { + label: 'Balanced', + description: 'Use all criteria with default tuning.', + boosts: {}, + }, + energy: { + label: 'Energy', + description: 'Split mainly by intensity.', + boosts: { energy: 2.4, loudness: 1.8, tempo: 1.4 }, + }, + valence: { + label: 'Mood', + description: 'Split by emotional tone.', + boosts: { valence: 2.4, acousticness: 1.4 }, + }, + danceability: { + label: 'Danceability', + description: 'Split by groove and rhythm.', + boosts: { danceability: 2.4, energy: 1.6, tempo: 1.4 }, + }, + tempo: { + label: 'Tempo', + description: 'Split by pacing differences.', + boosts: { tempo: 2.5, energy: 1.7 }, + }, + acousticness: { + label: 'Acousticness', + description: 'Split by organic vs synthetic sound.', + boosts: { acousticness: 2.5, instrumentalness: 1.4 }, + }, + instrumentalness: { + label: 'Instrumental', + description: 'Split by vocal vs instrumental presence.', + boosts: { instrumentalness: 2.7, speechiness: 0.7 }, + }, + speechiness: { + label: 'Speechiness', + description: 'Split spoken/rap-heavy tracks.', + boosts: { speechiness: 2.5, danceability: 1.6 }, + }, + liveness: { + label: 'Liveness', + description: 'Split by live-performance feel.', + boosts: { liveness: 2.6, acousticness: 1.4 }, + }, + loudness: { + label: 'Loudness', + description: 'Split by overall perceived loudness.', + boosts: { loudness: 2.5, energy: 1.8 }, + }, +}; + +const MIN_WEIGHT_PERCENT = 0; +const MAX_WEIGHT_PERCENT = 300; +const WEIGHT_STEP_PERCENT = 1; +const AUTH_REDIRECT_DELAY_MS = 1200; + +function weightToPercent(weight) { + return Math.round(Number(weight) * 100); +} + +function clampPercent(percent) { + const numericPercent = Number(percent); + if (!Number.isFinite(numericPercent)) { + return MIN_WEIGHT_PERCENT; + } + return Math.max( + MIN_WEIGHT_PERCENT, + Math.min(MAX_WEIGHT_PERCENT, Math.round(numericPercent)) + ); +} + +function percentToWeight(percent) { + return Number((clampPercent(percent) / 100).toFixed(2)); +} + +function roundedWeights(weights) { + return Object.fromEntries( + Object.entries(weights).map(([key, value]) => [key, Number(Number(value).toFixed(2))]) + ); +} + +function weightsForCriterion(criterionKey) { + const criterion = CRITERIA_OPTIONS[criterionKey]; + if (!criterion) { + return { ...DEFAULT_FEATURE_WEIGHTS }; + } + return { + ...DEFAULT_FEATURE_WEIGHTS, + ...criterion.boosts, + }; +} + +function clampProgressPercent(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.max(0, Math.min(100, Math.round(numeric))); +} + +function statusLabel(status) { + switch (status) { + case 'queued': + return 'Queued'; + case 'running': + return 'Running'; + case 'succeeded': + return 'Completed'; + case 'failed': + return 'Failed'; + default: + return 'Working'; + } +} + +async function parseResponsePayload(response) { + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + try { + return await response.json(); + } catch { + return null; + } + } + + const text = await response.text(); + if (!text) { + return null; + } + return { Error: text }; +} + +function getApiErrorMessage(response, payload, fallbackMessage) { + const payloadError = ( + (payload && typeof payload === 'object' && (payload.Error || payload.error || payload.message)) + || '' + ); + if (payloadError) { + return String(payloadError); + } + return `${fallbackMessage} (${response.status} ${response.statusText})`; +} + +function shouldReauthenticate(response, payload) { + if (!response) { + return false; + } + if (response.status === 401) { + return true; + } + + const payloadCode = Number(payload?.Code); + if (payloadCode === 401) { + return true; + } + + if (payload?.reauth === true) { + return true; + } + + if (response.status === 403) { + if (Array.isArray(payload?.missingScopes) && payload.missingScopes.length > 0) { + return true; + } + const errorText = String(payload?.Error || payload?.error || '').toLowerCase(); + if (errorText.includes('re-login') || errorText.includes('reauth')) { + return true; + } + } + + return false; +} + function PlaylistInputPage() { const [playlists, setPlaylists] = useState([]); const [selectedPlaylists, setSelectedPlaylists] = useState([]); const [isProcessing, setIsProcessing] = useState(false); + const [featureWeights, setFeatureWeights] = useState({ ...DEFAULT_FEATURE_WEIGHTS }); + const [activeCriterion, setActiveCriterion] = useState('balanced'); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [jobProgress, setJobProgress] = useState(null); + const [selectionNotice, setSelectionNotice] = useState(''); + const [authNotice, setAuthNotice] = useState(''); + const authRedirectTimeoutRef = useRef(null); - useEffect(() => { + const playlistNameById = useMemo(() => ( + Object.fromEntries( + playlists + .filter(playlist => playlist && playlist.id) + .map(playlist => [playlist.id, playlist.name || playlist.id]) + ) + ), [playlists]); + const startReauthFlow = useCallback((message = 'Spotify session expired. Redirecting to login...') => { + setAuthNotice(message); + setSelectionNotice(''); + setIsProcessing(false); + if (authRedirectTimeoutRef.current) { + return; + } + authRedirectTimeoutRef.current = window.setTimeout(() => { + window.location.href = `${API_BASE_URL}/login`; + }, AUTH_REDIRECT_DELAY_MS); + }, []); + + useEffect(() => () => { + if (authRedirectTimeoutRef.current) { + window.clearTimeout(authRedirectTimeoutRef.current); + authRedirectTimeoutRef.current = null; + } + }, []); + + useEffect(() => { fetch(`${API_BASE_URL}/user-playlists`, { credentials: 'include', }) - .then(response => { + .then(async response => { + const payload = await parseResponsePayload(response); + if (shouldReauthenticate(response, payload)) { + startReauthFlow('Your Spotify session expired. Redirecting to login...'); + return null; + } if (!response.ok) { - throw new Error("Failed to fetch playlists"); + throw new Error( + getApiErrorMessage(response, payload, 'Failed to fetch playlists') + ); } - return response.json(); + return payload; }) .then(data => { + if (!data) { + return; + } if (data && data.items) { setPlaylists(data.items); } else { - console.error("Unexpected data structure from server:", data); + throw new Error('Unexpected data structure from server'); } }) .catch(error => { + if (authRedirectTimeoutRef.current) { + return; + } console.error("There was an error fetching the playlists:", error); + setSelectionNotice(error.message || 'Unable to load playlists right now.'); }); - }, []); + }, [startReauthFlow]); + + const applyCriterion = (criterionKey) => { + if (!CRITERIA_OPTIONS[criterionKey]) { + return; + } + setFeatureWeights(weightsForCriterion(criterionKey)); + setActiveCriterion(criterionKey); + }; + + const resetWeights = () => { + applyCriterion('balanced'); + }; + + const handleWeightChange = (featureKey, nextPercent) => { + setFeatureWeights(prev => ({ + ...prev, + [featureKey]: percentToWeight(nextPercent), + })); + setActiveCriterion('custom'); + }; + + const toggleAdvanced = () => { + setIsAdvancedOpen((prev) => !prev); + }; const handlePlaylistSelection = (id) => { + setSelectionNotice(''); if (selectedPlaylists.includes(id)) { setSelectedPlaylists(prev => prev.filter(playlistId => playlistId !== id)); } else { @@ -42,13 +350,31 @@ function PlaylistInputPage() { } }; + const clearSelectedPlaylists = () => { + setSelectionNotice(''); + setSelectedPlaylists([]); + }; + const handleProcessPlaylists = () => { console.log("Selected Playlists:", selectedPlaylists); if (!selectedPlaylists.length) { - alert("Select at least one playlist."); + setSelectionNotice('Select at least one playlist to start processing.'); return; } + setAuthNotice(''); + setSelectionNotice(''); setIsProcessing(true); + setJobProgress({ + status: 'queued', + jobId: null, + completedPlaylists: 0, + totalPlaylists: selectedPlaylists.length, + failedPlaylists: 0, + progressPercent: 0, + lastCompletedPlaylistId: null, + lastCompletedPlaylistName: null, + error: null, + }); fetch(`${API_BASE_URL}/process-playlist`, { method: "POST", @@ -56,40 +382,121 @@ function PlaylistInputPage() { "Content-Type": "application/json" }, credentials: 'include', - body: JSON.stringify({ playlistIds: selectedPlaylists }) + body: JSON.stringify({ + playlistIds: selectedPlaylists, + featureWeights: roundedWeights(featureWeights), + splitCriterion: activeCriterion, + }) }) .then(async response => { - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Request failed (${response.status} ${response.statusText}): ${errorText}` + const payload = await parseResponsePayload(response); + if (shouldReauthenticate(response, payload)) { + startReauthFlow( + response.status === 403 + ? 'Spotify permissions changed. Redirecting to login...' + : 'Spotify session expired. Redirecting to login...' ); + return null; } - return response.json(); + if (!response.ok) { + throw new Error(getApiErrorMessage(response, payload, 'Failed to start processing')); + } + return payload; }) .then(data => { + if (!data) { + return null; + } if (!data || !data.jobId) { throw new Error("Missing jobId from backend response"); } console.log("Started processing job:", data.jobId); + setJobProgress(prev => ({ + ...(prev || {}), + status: data.status || 'queued', + jobId: data.jobId, + })); return data.jobId; }) .then(jobId => { + if (!jobId) { + return null; + } const pollDelayMs = 4000; const pollJob = () => fetch( `${API_BASE_URL}/process-playlist-status/${jobId}`, { credentials: 'include' } ) - .then(response => response.json()) + .then(async response => { + const payload = await parseResponsePayload(response); + if (response.status === 404) { + setTimeout(pollJob, pollDelayMs); + return null; + } + if (shouldReauthenticate(response, payload)) { + startReauthFlow('Your Spotify session expired. Redirecting to login...'); + return null; + } + if (!response.ok) { + throw new Error(getApiErrorMessage(response, payload, 'Failed to fetch job status')); + } + return payload; + }) .then(statusPayload => { + if (!statusPayload) { + return; + } const status = statusPayload.status; + const totalPlaylists = Number( + statusPayload.total_playlists + ?? statusPayload.playlist_count + ?? selectedPlaylists.length + ); + const completedPlaylists = Number(statusPayload.completed_playlists ?? 0); + const failedPlaylists = Number(statusPayload.failed_playlists ?? 0); + const derivedProgress = ( + totalPlaylists > 0 + ? Math.round((completedPlaylists / totalPlaylists) * 100) + : (status === 'succeeded' ? 100 : 0) + ); + const progressPercent = clampProgressPercent( + statusPayload.progress_percent ?? derivedProgress + ); + const lastCompletedPlaylistId = ( + statusPayload.last_completed_playlist_id || null + ); + const reportedPlaylistName = ( + typeof statusPayload.last_completed_playlist_name === 'string' + ? statusPayload.last_completed_playlist_name + : null + ); + const lastCompletedPlaylistName = ( + reportedPlaylistName + || (lastCompletedPlaylistId ? playlistNameById[lastCompletedPlaylistId] : null) + ); + setJobProgress(prev => ({ + ...(prev || {}), + status, + jobId, + completedPlaylists: Number.isFinite(completedPlaylists) + ? completedPlaylists + : 0, + totalPlaylists: Number.isFinite(totalPlaylists) && totalPlaylists > 0 + ? totalPlaylists + : selectedPlaylists.length, + failedPlaylists: Number.isFinite(failedPlaylists) ? failedPlaylists : 0, + progressPercent, + lastCompletedPlaylistId, + lastCompletedPlaylistName, + error: statusPayload.error || null, + })); if (status === "succeeded") { - alert("Playlists processed successfully!"); setIsProcessing(false); return; } if (status === "failed") { - throw new Error(statusPayload.error || "Processing failed"); + setIsProcessing(false); + return; } setTimeout(pollJob, pollDelayMs); }); @@ -97,38 +504,221 @@ function PlaylistInputPage() { return pollJob(); }) .catch(error => { + if (authRedirectTimeoutRef.current) { + return; + } console.error("There was a problem with the fetch operation:", error); - alert(`Processing failed: ${error.message}`); + setJobProgress(prev => ({ + ...(prev || {}), + status: 'failed', + error: error.message || 'Unexpected error', + })); setIsProcessing(false); }); }; - return ( -
+ Tune how tracks are split by audio traits, then choose one or more playlists. +
+ {authNotice && ( +{authNotice}
+ )} ++ Pick a primary criterion first. Use Advanced only if you want manual tuning. +
+