diff --git a/backend/alembic/versions/f3a4b5c6d7e8_add_user_genres.py b/backend/alembic/versions/f3a4b5c6d7e8_add_user_genres.py new file mode 100644 index 0000000..f451d87 --- /dev/null +++ b/backend/alembic/versions/f3a4b5c6d7e8_add_user_genres.py @@ -0,0 +1,104 @@ +"""add genres and user_genres + +Revision ID: f3a4b5c6d7e8 +Revises: e2f3a4b5c6d7 +Create Date: 2025-02-19 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = "f3a4b5c6d7e8" +down_revision = "e2f3a4b5c6d7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "genres", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("normalized_name", sa.String(length=100), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("normalized_name", name="uq_genres_normalized_name"), + ) + op.create_index("ix_genres_normalized_name", "genres", ["normalized_name"]) + + op.create_table( + "user_genres", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "user_id", + sa.String(length=36), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "genre_id", + sa.Integer(), + sa.ForeignKey("genres.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint( + "user_id", "genre_id", name="uq_user_genres_user_genre" + ), + ) + op.create_index("ix_user_genres_user_id", "user_genres", ["user_id"]) + op.create_index("ix_user_genres_genre_id", "user_genres", ["genre_id"]) + + genres_table = sa.table( + "genres", + sa.column("name", sa.String), + sa.column("normalized_name", sa.String), + ) + + seed_genres = [ + "ambient", + "art pop", + "dream pop", + "electronic", + "folk", + "funk", + "hip-hop", + "house", + "industrial", + "indie rock", + "jazz fusion", + "lo-fi", + "neo-soul", + "post-punk", + "progressive rock", + "psychedelic", + "R&B", + "shoegaze", + "synth-pop", + "trip-hop", + ] + op.bulk_insert( + genres_table, + [ + {"name": genre, "normalized_name": genre.strip().lower()} + for genre in seed_genres + ], + ) + + +def downgrade() -> None: + op.drop_index("ix_user_genres_genre_id", table_name="user_genres") + op.drop_index("ix_user_genres_user_id", table_name="user_genres") + op.drop_table("user_genres") + op.drop_index("ix_genres_normalized_name", table_name="genres") + op.drop_table("genres") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index df7c953..276eb70 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -78,6 +78,9 @@ class User(Base): suno_prompts: Mapped[list["SunoPrompt"]] = relationship( back_populates="owner", cascade="all, delete-orphan" ) + user_genres: Mapped[list["UserGenre"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) class ExternalAccount(Base): @@ -123,6 +126,44 @@ class ExternalAccount(Base): user: Mapped[User] = relationship(back_populates="external_accounts") +class Genre(Base): + __tablename__ = "genres" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + normalized_name: Mapped[str] = mapped_column( + String(100), nullable=False, unique=True, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + user_links: Mapped[list["UserGenre"]] = relationship( + back_populates="genre", cascade="all, delete-orphan" + ) + + +class UserGenre(Base): + __tablename__ = "user_genres" + __table_args__ = ( + UniqueConstraint("user_id", "genre_id", name="uq_user_genres_user_genre"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + genre_id: Mapped[int] = mapped_column( + ForeignKey("genres.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + user: Mapped[User] = relationship(back_populates="user_genres") + genre: Mapped[Genre] = relationship(back_populates="user_links") + + class SunoPrompt(Base): """A saved Suno prompt that users can favorite and reuse.""" @@ -174,4 +215,4 @@ class SunoPrompt(Base): owner: Mapped[User] = relationship(back_populates="suno_prompts") -__all__ = ["Base", "User", "ExternalAccount", "SunoPrompt"] +__all__ = ["Base", "User", "ExternalAccount", "Genre", "UserGenre", "SunoPrompt"] diff --git a/backend/app/routes/spotify.py b/backend/app/routes/spotify.py index 12b3dc5..9c82a03 100644 --- a/backend/app/routes/spotify.py +++ b/backend/app/routes/spotify.py @@ -3,20 +3,73 @@ Fetches and processes user's Spotify data """ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from app.db.models import Genre, UserGenre +from app.deps import get_current_user_id, get_db, get_spotify_client +from app.schemas.genres import ( + GenreCatalogResponse, + GenreItem, + UserGenreAddRequest, + UserGenresResponse, +) from app.schemas.spotify import SpotifyProfileResponse -from app.deps import get_spotify_client +from app.services.genre_catalog import ensure_genres_seeded, get_or_create_genres from app.services.spotify_client import SpotifyClient +from app.services.taste_analyzer import ( + compute_avg_popularity, + derive_mood_tags, + generate_summary, +) from app.utils import fetch_and_parse_spotify_data router = APIRouter() +MAX_USER_GENRES = 20 + + +def _get_user_genre_names(db: Session, user_id: str) -> list[str]: + return ( + db.scalars( + select(Genre.name) + .join(UserGenre, UserGenre.genre_id == Genre.id) + .where(UserGenre.user_id == user_id) + .order_by(UserGenre.id.asc()) + ) + .all() + ) + + +def _seed_user_genres( + db: Session, + user_id: str, + genre_names: list[str], +) -> list[str]: + trimmed_names = genre_names[:MAX_USER_GENRES] + genres = get_or_create_genres(db, trimmed_names) + if not genres: + return [] + try: + db.add_all( + [ + UserGenre(user_id=user_id, genre_id=genre.id) + for genre in genres + ] + ) + db.commit() + except IntegrityError: + db.rollback() + return _get_user_genre_names(db, user_id) @router.get("/profile", response_model=SpotifyProfileResponse) async def get_profile( client: SpotifyClient = Depends(get_spotify_client), - time_range: str = Query(default="medium_term", pattern="^(short_term|medium_term|long_term)$") + time_range: str = Query(default="medium_term", pattern="^(short_term|medium_term|long_term)$"), + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), ): """ Get user's Spotify profile with taste analysis @@ -30,6 +83,20 @@ async def get_profile( top_artists, top_tracks, taste_profile = await fetch_and_parse_spotify_data( client, time_range ) + + # Ensure catalog exists and load user-managed genres + ensure_genres_seeded(db) + user_genres = _get_user_genre_names(db, user_id) + if not user_genres and taste_profile.top_genres: + user_genres = _seed_user_genres(db, user_id, taste_profile.top_genres) + + if user_genres: + taste_profile.top_genres = user_genres + avg_popularity = compute_avg_popularity(top_artists) + taste_profile.mood_tags = derive_mood_tags(user_genres, avg_popularity) + taste_profile.summary_sentence = generate_summary( + user_genres, taste_profile.mood_tags, avg_popularity + ) return SpotifyProfileResponse( top_artists=top_artists, @@ -37,3 +104,77 @@ async def get_profile( taste_profile=taste_profile, time_range=time_range ) + + +@router.get("/genres/catalog", response_model=GenreCatalogResponse) +def list_genre_catalog( + db: Session = Depends(get_db), + _user_id: str = Depends(get_current_user_id), +): + """ + List available genres for the top-genres picker. + """ + ensure_genres_seeded(db) + genres = db.scalars(select(Genre).order_by(Genre.name.asc())).all() + return GenreCatalogResponse( + genres=[GenreItem(id=genre.id, name=genre.name) for genre in genres] + ) + + +@router.post("/genres", response_model=UserGenresResponse) +def add_user_genre( + body: UserGenreAddRequest, + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): + """ + Add a genre to the user's top genres list. + """ + genre = db.get(Genre, body.genre_id) + if not genre: + raise HTTPException(status_code=404, detail="Genre not found") + + existing = db.scalar( + select(UserGenre) + .where(UserGenre.user_id == user_id) + .where(UserGenre.genre_id == body.genre_id) + ) + if not existing: + current_count = db.scalar( + select(func.count(UserGenre.id)).where(UserGenre.user_id == user_id) + ) + if (current_count or 0) >= MAX_USER_GENRES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You can only have up to 20 genres.", + ) + db.add(UserGenre(user_id=user_id, genre_id=body.genre_id)) + db.commit() + + return UserGenresResponse(genres=_get_user_genre_names(db, user_id)) + + +@router.delete("/genres/{genre_id}", response_model=UserGenresResponse) +def delete_user_genre( + genre_id: int, + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +): + """ + Remove a genre from the user's top genres list. + """ + link = db.scalar( + select(UserGenre) + .where(UserGenre.user_id == user_id) + .where(UserGenre.genre_id == genre_id) + ) + if link: + db.delete(link) + db.commit() + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not linked to user", + ) + + return UserGenresResponse(genres=_get_user_genre_names(db, user_id)) diff --git a/backend/app/schemas/genres.py b/backend/app/schemas/genres.py new file mode 100644 index 0000000..a8daedb --- /dev/null +++ b/backend/app/schemas/genres.py @@ -0,0 +1,22 @@ +""" +Pydantic schemas for user-managed genres. +""" + +from pydantic import BaseModel, Field + + +class GenreItem(BaseModel): + id: int + name: str + + +class GenreCatalogResponse(BaseModel): + genres: list[GenreItem] + + +class UserGenreAddRequest(BaseModel): + genre_id: int = Field(..., description="ID of the genre to add") + + +class UserGenresResponse(BaseModel): + genres: list[str] diff --git a/backend/app/services/genre_catalog.py b/backend/app/services/genre_catalog.py new file mode 100644 index 0000000..39d773b --- /dev/null +++ b/backend/app/services/genre_catalog.py @@ -0,0 +1,89 @@ +""" +Genre catalog helpers for user-managed top genres. +""" + +from typing import Iterable + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.db.models import Genre + + +DEFAULT_GENRES = [ + "ambient", + "art pop", + "dream pop", + "electronic", + "folk", + "funk", + "hip-hop", + "house", + "industrial", + "indie rock", + "jazz fusion", + "lo-fi", + "neo-soul", + "post-punk", + "progressive rock", + "psychedelic", + "R&B", + "shoegaze", + "synth-pop", + "trip-hop", +] + + +def normalize_genre_name(name: str) -> str: + """Normalize a genre name for matching and uniqueness.""" + return " ".join(name.strip().lower().split()) + + +def ensure_genres_seeded(db: Session) -> None: + """Seed the genre catalog if it's empty.""" + existing_count = db.scalar(select(func.count(Genre.id))) + if existing_count: + return + db.add_all( + [ + Genre(name=genre, normalized_name=normalize_genre_name(genre)) + for genre in DEFAULT_GENRES + ] + ) + try: + db.commit() + except IntegrityError: + db.rollback() + + +def get_or_create_genres(db: Session, names: Iterable[str]) -> list[Genre]: + """Fetch or create genre rows for the provided names.""" + normalized_to_name = {} + for name in names: + if not name: + continue + normalized_to_name[normalize_genre_name(name)] = name.strip() + + if not normalized_to_name: + return [] + + normalized_list = list(normalized_to_name.keys()) + existing = db.scalars( + select(Genre).where(Genre.normalized_name.in_(normalized_list)) + ).all() + existing_by_norm = {genre.normalized_name: genre for genre in existing} + + created = [] + for normalized, display_name in normalized_to_name.items(): + if normalized in existing_by_norm: + continue + created.append(Genre(name=display_name, normalized_name=normalized)) + + if created: + db.add_all(created) + db.commit() + for genre in created: + db.refresh(genre) + + return existing + created diff --git a/backend/app/services/taste_analyzer.py b/backend/app/services/taste_analyzer.py index 30ba3b2..eaa7585 100644 --- a/backend/app/services/taste_analyzer.py +++ b/backend/app/services/taste_analyzer.py @@ -76,32 +76,18 @@ } -def build_taste_profile( - top_artists: List[SpotifyArtist], - top_tracks: List[SpotifyTrack] -) -> TasteProfile: - """ - Analyze user's music taste and build a profile - - Args: - top_artists: List of user's top artists - top_tracks: List of user's top tracks - - Returns: - TasteProfile with genres, moods, summary, and banned references - """ - # Count genres from artists - genre_counter = Counter() - for artist in top_artists: - for genre in artist.genres: - genre_counter[genre] += 1 - - # Get ranked genres - top_genres = [genre for genre, _ in genre_counter.most_common(10)] - - # Derive mood tags from genres +def compute_avg_popularity(top_artists: List[SpotifyArtist]) -> float: + """Compute average popularity for the top artists list.""" + if not top_artists: + return 0 + return sum(artist.popularity for artist in top_artists) / len(top_artists) + + +def derive_mood_tags(genres: List[str], avg_popularity: float) -> List[str]: + """Derive mood tags from genres and popularity.""" mood_counter = Counter() - for genre in top_genres[:7]: # Top 7 genres + + for genre in genres[:7]: # Top 7 genres genre_lower = genre.lower() # Try exact match first if genre_lower in GENRE_MOOD_MAP: @@ -114,28 +100,61 @@ def build_taste_profile( for mood in moods: mood_counter[mood] += 1 break - - # Calculate average popularity - avg_popularity = 0 - if top_artists: - avg_popularity = sum(a.popularity for a in top_artists) / len(top_artists) - + # Add popularity-based moods if avg_popularity > 70: mood_counter["mainstream"] += 2 elif avg_popularity < 40: mood_counter["underground"] += 2 mood_counter["indie"] += 1 - - # Get top moods + mood_tags = [mood for mood, _ in mood_counter.most_common(5)] - - # If no moods found, add defaults if not mood_tags: mood_tags = ["eclectic", "diverse"] + + return mood_tags + + +def generate_summary( + genres: List[str], + moods: List[str], + avg_popularity: float, +) -> str: + """Generate a summary sentence about the user's taste.""" + return _generate_summary(genres, moods, avg_popularity) + + +def build_taste_profile( + top_artists: List[SpotifyArtist], + top_tracks: List[SpotifyTrack] +) -> TasteProfile: + """ + Analyze user's music taste and build a profile + Args: + top_artists: List of user's top artists + top_tracks: List of user's top tracks + + Returns: + TasteProfile with genres, moods, summary, and banned references + """ + # Count genres from artists + genre_counter = Counter() + for artist in top_artists: + for genre in artist.genres: + genre_counter[genre] += 1 + + # Get ranked genres + top_genres = [genre for genre, _ in genre_counter.most_common(10)] + + # Calculate average popularity + avg_popularity = compute_avg_popularity(top_artists) + + # Derive mood tags from genres + popularity + mood_tags = derive_mood_tags(top_genres, avg_popularity) + # Generate summary sentence - summary_sentence = _generate_summary(top_genres, mood_tags, avg_popularity) + summary_sentence = generate_summary(top_genres, mood_tags, avg_popularity) # Banned references (artist names to avoid in prompts) banned_references = [artist.name for artist in top_artists[:15]] diff --git a/docs/generation.md b/docs/generation.md new file mode 100644 index 0000000..e742955 --- /dev/null +++ b/docs/generation.md @@ -0,0 +1,139 @@ +# Generation: Input Concept and Lyrics + +This document explains how the input concept and lyrics generation flows work, +based on the current backend implementation. + +## Generate Input Concept (`POST /generate/input-concept`) + +Purpose: Create a short Suno-style concept (2-3 sentences) from genre influences. +The result is used as the `user_prompt` in `/generate/advanced`. + +Request (`InputConceptRequest`): +- `genres`: list of genre strings (1-3 are randomly selected). If empty, fallback + seed genres are used. +- `artists`: list of artist strings (passed through, not used in v1). +- `mood`: optional mood hint (used if provided; otherwise inferred). + +Response (`InputConceptResponse`): +- `concept`: generated 2-3 sentence concept. +- `chosen_genres`: the randomly selected 1-3 genres. +- `genres`: the full genre list used for selection. +- `artists`: echoed back for future use. +- `mood`: the mood used (provided or inferred). + +Flow: +1. `create_generator_with_providers(...)` builds: + - `InputConceptGenerator` + - `CompositeGenreInfluenceProvider` with `ManualInputGenreProvider` +2. `providers.get_influence_genres(...)` merges all provider genre lists. +3. `InputConceptGenerator.generate(...)`: + - Falls back to `FallbackSeedGenreProvider` if the merged list is empty. + - Randomly selects 1-3 genres from the list. + - Calls `_generate_concept(...)` to build a 2-3 sentence template. + - Uses `GENRE_DESCRIPTORS` when available. + - Falls back to generic texture/vibe/energy templates if unknown. +4. The endpoint returns the generated concept plus the metadata above. + +Relevant files: +- `backend/app/routes/generate_input_concept.py` +- `backend/app/services/input_concept_generator.py` +- `backend/app/services/artist_influence.py` + +## Generate Lyrics Topic (`POST /generate/lyrics-topic`) + +Purpose: Generate a short 1-2 sentence topic or theme that can be used as the +`lyrics_about` field for `/generate/advanced` or `/generate/lyrics-only`. + +Request (`LyricsTopicRequest`): +- `genres`: optional list of genres for thematic influence. +- `moods`: optional list of mood tags (preferred over genres when provided). +- `style_prompt`: optional style prompt to align the topic with a musical vibe. + +Response (`LyricsTopicResponse`): +- `topic`: the generated 1-2 sentence topic. +- `chosen_moods`: the moods that influenced the topic (seeded if none provided). +- `reasoning`: optional debug reasoning from the generator. + +Flow: +1. The endpoint calls `generate_lyrics_topic(...)` with `genres`, `moods`, and + `style_prompt`. +2. The generator picks or infers moods (from explicit moods or genre-to-mood + mappings) and returns a short topic sentence or two. +3. The endpoint returns the topic plus the chosen moods and optional reasoning. + +Relevant files: +- `backend/app/routes/generate_input_concept.py` +- `backend/app/services/lyrics_topic_generator.py` +- `backend/app/schemas/input_concept.py` + +## Generate Lyrics + +There are two paths: full generation (lyrics + Suno prompt) and lyrics-only. + +### Full Generation (`POST /generate/advanced`) + +Purpose: Generate lyrics plus Suno prompt artifacts, and auto-save the result. + +Key request fields (`AdvancedGenerateRequest`): +- `user_prompt`: style/vibe prompt (often from `/generate/input-concept`). +- `lyrics_about`: topic/theme for the lyrics. +- Optional: `selected_artists`, `tags`, `lyric_controls`, `prompt_variant`, + `model`/`style_model`/`lyrics_model`. + +High-level flow: +1. `AgentPromptGraph.generate(...)` picks a prompt variant: + - Request override -> settings -> default `v5_hybrid`. +2. Builds a per-request `GenerationContext`. + - Single-step: one model, one prompt + repair prompt. + - Two-step: separate style + lyrics prompts and models. +3. Single-step path: + - build_context -> generate -> parse/validate -> (repair loop) -> finalize +4. Two-step path: + - Runs style and lyrics branches in parallel. + - Lyrics branch may infer a lyric profile (for V4+ variants). + - Both branches can repair on validation failure. + - Results are merged into the final response. +5. Instrumental short-circuit: + - If `lyrics_about` is empty or contains phrases like "instrumental" or + "no lyrics", the lyrics branch is skipped and lyrics are returned empty. +6. The route auto-saves the prompt to the database: + - Uses Spotify user if logged in, otherwise creates or reuses a guest via + device cookie. + - Returns `prompt_id` if saved successfully. + +Response (`AdvancedGenerateResponse`) includes: +- `concept_title`, `lyrics`, `suno_prompt` +- `exclude`, `weirdness`, `style_influence` +- `generation_id`, optional `debug_info`, and `auto_tags` + +Relevant files: +- `backend/app/routes/generate_advanced.py` +- `backend/app/services/agent_prompt_graph.py` +- `backend/app/prompts.py` +- `backend/app/schemas/advanced.py` + +### Lyrics-Only (`POST /generate/lyrics-only`) + +Purpose: Generate new lyrics using a saved Suno prompt as style context. + +Request (`LyricsOnlyRequest`): +- `suno_prompt`: the saved Suno prompt for style guidance. +- `lyrics_about`: topic/theme for the new lyrics. + +Flow: +1. `_is_instrumental_lyrics_request(...)` checks for instrumental intent. + - If true, returns `song_title="Instrumental"` and empty lyrics. +2. Builds a small context block with `suno_prompt` and `lyrics_about`. +3. Calls the LLM directly with `LYRICS_SYSTEM_PROMPT`. +4. Parses output sections with `_extract_sections(...)`: + - `SONG TITLE` and `LYRICS` headers are extracted. + - Fallback: if no headers, use raw output and default title `Untitled`. + +Response (`LyricsOnlyResponse`): +- `song_title` +- `lyrics` + +Relevant files: +- `backend/app/routes/generate_advanced.py` +- `backend/app/schemas/advanced.py` +- `backend/app/prompts.py` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca23bee..0f967dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -281,6 +281,20 @@ function App() { loading={profileLoading} timeRange={settings.timeRange} onTimeRangeChange={(tr) => updateSettings({ timeRange: tr })} + onGenresUpdated={(genres) => + setProfile((prev) => + prev + ? { + ...prev, + taste_profile: { + ...prev.taste_profile, + top_genres: genres, + }, + } + : prev + ) + } + onProfileUpdated={(updatedProfile) => setProfile(updatedProfile)} /> )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4b88c14..821b249 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -38,6 +38,19 @@ export interface SpotifyProfileResponse { time_range: string; } +export interface GenreItem { + id: number; + name: string; +} + +export interface GenreCatalogResponse { + genres: GenreItem[]; +} + +export interface UserGenresResponse { + genres: string[]; +} + export interface AuthStatus { authenticated: boolean; user_name?: string; @@ -406,6 +419,46 @@ export async function getProfile( return handleResponse(response); } +/** + * Get the genre catalog for the top-genres picker + */ +export async function getGenreCatalog(): Promise { + const response = await fetch(`${API_BASE}/spotify/genres/catalog`, { + credentials: 'include', + }); + return handleResponse(response); +} + +/** + * Add a genre to the user's top genres + */ +export async function addUserGenre( + genreId: number +): Promise { + const response = await fetch(`${API_BASE}/spotify/genres`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ genre_id: genreId }), + }); + return handleResponse(response); +} + +/** + * Remove a genre from the user's top genres + */ +export async function deleteUserGenre( + genreId: number +): Promise { + const response = await fetch(`${API_BASE}/spotify/genres/${genreId}`, { + method: 'DELETE', + credentials: 'include', + }); + return handleResponse(response); +} + // === Generation Functions === /** diff --git a/frontend/src/components/TasteDisplay.tsx b/frontend/src/components/TasteDisplay.tsx index a43c93f..bc23fad 100644 --- a/frontend/src/components/TasteDisplay.tsx +++ b/frontend/src/components/TasteDisplay.tsx @@ -3,6 +3,7 @@ * Shows user's top artists, genres, and taste summary */ +import { useEffect, useMemo, useState } from 'react'; import { Box, Card, @@ -13,6 +14,7 @@ import { HStack, VStack, Tag, + TagLabel, Wrap, WrapItem, Skeleton, @@ -22,8 +24,16 @@ import { Stack, Tooltip, Avatar, + IconButton, + Menu, + MenuButton, + MenuList, + MenuItem, + useToast, } from '@chakra-ui/react'; +import { AddIcon, CloseIcon } from '@chakra-ui/icons'; +import * as api from '../api'; import { SpotifyProfileResponse, TimeRange } from '../api'; import { TIME_RANGE_LABELS } from '../types'; @@ -32,6 +42,8 @@ interface TasteDisplayProps { loading: boolean; timeRange: TimeRange; onTimeRangeChange: (range: TimeRange) => void; + onGenresUpdated?: (genres: string[]) => void; + onProfileUpdated?: (profile: SpotifyProfileResponse) => void; } export function TasteDisplay({ @@ -39,7 +51,140 @@ export function TasteDisplay({ loading, timeRange, onTimeRangeChange, + onGenresUpdated, + onProfileUpdated, }: TasteDisplayProps) { + const maxGenres = 20; + const toast = useToast(); + const [genreCatalog, setGenreCatalog] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(false); + const [genreActionLoading, setGenreActionLoading] = useState(false); + + useEffect(() => { + let active = true; + if (!profile) { + setGenreCatalog([]); + return; + } + setCatalogLoading(true); + api + .getGenreCatalog() + .then((data) => { + if (active) { + setGenreCatalog(data.genres); + } + }) + .catch(() => { + if (active) { + setGenreCatalog([]); + } + }) + .finally(() => { + if (active) { + setCatalogLoading(false); + } + }); + return () => { + active = false; + }; + }, [profile]); + + const genreIdByName = useMemo(() => { + const map = new Map(); + for (const genre of genreCatalog) { + map.set(genre.name.toLowerCase(), genre.id); + } + return map; + }, [genreCatalog]); + + const selectedCount = profile?.taste_profile.top_genres.length ?? 0; + const availableGenres = useMemo(() => { + const selected = new Set( + (profile?.taste_profile.top_genres || []).map((name) => name.toLowerCase()) + ); + return genreCatalog.filter( + (genre) => !selected.has(genre.name.toLowerCase()) + ); + }, [genreCatalog, profile]); + + const refreshProfile = async () => { + if (!onProfileUpdated) { + return; + } + try { + const updatedProfile = await api.getProfile(timeRange); + onProfileUpdated(updatedProfile); + } catch (error) { + toast({ + title: 'Could not refresh profile', + description: 'Please try again.', + status: 'error', + duration: 3000, + }); + } + }; + + const handleAddGenre = async (genre: api.GenreItem) => { + if (genreActionLoading) { + return; + } + if (selectedCount >= maxGenres) { + toast({ + title: 'Genre limit reached', + description: 'You can add up to 20 genres.', + status: 'info', + duration: 3000, + }); + return; + } + setGenreActionLoading(true); + try { + const result = await api.addUserGenre(genre.id); + onGenresUpdated?.(result.genres); + await refreshProfile(); + } catch (error) { + toast({ + title: 'Could not add genre', + description: 'Please try again.', + status: 'error', + duration: 3000, + }); + } finally { + setGenreActionLoading(false); + } + }; + + const handleDeleteGenre = async (genreName: string) => { + if (genreActionLoading) { + return; + } + const genreId = genreIdByName.get(genreName.toLowerCase()); + if (!genreId) { + toast({ + title: 'Genre not found', + description: 'Refresh the page and try again.', + status: 'warning', + duration: 3000, + }); + return; + } + setGenreActionLoading(true); + try { + const result = await api.deleteUserGenre(genreId); + onGenresUpdated?.(result.genres); + await refreshProfile(); + } catch (error) { + toast({ + title: 'Could not remove genre', + description: 'Please try again.', + status: 'error', + duration: 3000, + }); + } finally { + setGenreActionLoading(false); + } + }; + return ( @@ -125,18 +270,70 @@ export function TasteDisplay({ Top Genres - {profile.taste_profile.top_genres.slice(0, 8).map((genre, idx) => ( - - - {genre} - + {profile.taste_profile.top_genres.slice(0, maxGenres).map((genre, idx) => ( + + + + {genre} + + } + size="xs" + variant="solid" + colorScheme="gray" + boxSize="16px" + position="absolute" + top="-5px" + right="-5px" + borderRadius="full" + isDisabled={genreActionLoading} + onClick={() => handleDeleteGenre(genre)} + /> + ))} + + + } + size="sm" + variant="outline" + colorScheme="gray" + isLoading={catalogLoading || genreActionLoading} + isDisabled={ + catalogLoading || + genreActionLoading || + availableGenres.length === 0 || + selectedCount >= maxGenres + } + /> + + {selectedCount >= maxGenres ? ( + Max 20 genres reached + ) : availableGenres.length === 0 ? ( + No genres to add + ) : ( + availableGenres.map((genre) => ( + handleAddGenre(genre)} + > + {genre.name} + + )) + )} + + +