diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 44d8ad3..5d79251 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -31,6 +31,25 @@ async def generate( recs, remaining = await generate_recommendations( db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode, mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude, count=data.count, + mood_energy=data.mood_energy, mood_valence=data.mood_valence, + ) + + if not recs and remaining == 0: + raise HTTPException(status_code=429, detail="Weekly recommendation limit reached. Upgrade to Premium for unlimited.") + + return RecommendationResponse( + recommendations=[RecommendationItem.model_validate(r) for r in recs], + remaining_this_week=remaining, + ) + + +@router.post("/surprise", response_model=RecommendationResponse) +async def surprise( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + recs, remaining = await generate_recommendations( + db, user, query=None, mode="surprise", count=5 ) if not recs and remaining == 0: diff --git a/backend/app/schemas/recommendation.py b/backend/app/schemas/recommendation.py index 1ec41fc..6b97cb3 100644 --- a/backend/app/schemas/recommendation.py +++ b/backend/app/schemas/recommendation.py @@ -11,6 +11,8 @@ class RecommendationRequest(BaseModel): adventurousness: int = 3 # 1-5 exclude: str | None = None # comma-separated genres to exclude count: int = 5 # Number of recommendations (5, 10, 15, 20) + mood_energy: int | None = None # 1-5, 1=chill, 5=energetic + mood_valence: int | None = None # 1-5, 1=sad/dark, 5=happy/bright class RecommendationItem(BaseModel): diff --git a/backend/app/services/recommender.py b/backend/app/services/recommender.py index e912ecb..441bce1 100644 --- a/backend/app/services/recommender.py +++ b/backend/app/services/recommender.py @@ -70,6 +70,7 @@ MODE_PROMPTS = { "era_bridge": "Suggest classic artists from earlier eras who directly inspired their current favorites. Trace musical lineage — if they love Tame Impala, suggest the 70s psych rock that influenced him. Bridge eras.", "deep_cuts": "Find B-sides, album tracks, rarities, and lesser-known songs from artists already in their library. Focus on tracks they probably haven't heard even from artists they already know.", "rising": "Find artists with under 50K monthly listeners who match their taste. Focus on brand new, up-and-coming artists who haven't broken through yet. Think artists who just released their debut album or EP.", + "surprise": "Be wildly creative. Pick ONE obscure, unexpected angle from their taste profile — maybe a specific production technique, a niche sub-genre, a particular era, or an unusual sonic quality — and build all recommendations around that single thread. Start your 'reason' for the first recommendation by explaining the angle you chose. Make it feel like a curated rabbit hole they never knew they wanted.", } @@ -92,6 +93,8 @@ async def generate_recommendations( adventurousness: int = 3, exclude: str | None = None, count: int = 5, + mood_energy: int | None = None, + mood_valence: int | None = None, ) -> tuple[list[Recommendation], int | None]: """Generate AI music recommendations using Claude.""" @@ -145,7 +148,10 @@ async def generate_recommendations( disliked_artists = list({a for a in disliked_result.scalars().all()}) # Build prompt - user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems." + if mode == "surprise" and not query: + user_request = "Surprise me with something unexpected based on my taste profile. Pick a creative, unusual angle I wouldn't think of myself." + else: + user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems." if bandcamp_mode: focus_instruction = "IMPORTANT: Strongly prioritize independent and underground artists who release music on Bandcamp. Think DIY, indie labels, self-released artists, and the kind of music you'd find crate-digging on Bandcamp. Focus on artists who self-publish or release on small indie labels." @@ -158,6 +164,18 @@ async def generate_recommendations( # Adventurousness instruction adventurousness_instruction = build_adventurousness_prompt(adventurousness) + # Mood instruction + mood_instruction = "" + if mood_energy is not None or mood_valence is not None: + energy_desc = {1: "very chill and calm", 2: "relaxed", 3: "moderate energy", 4: "upbeat and energetic", 5: "high energy and intense"} + valence_desc = {1: "dark, melancholy, or moody", 2: "introspective or bittersweet", 3: "neutral mood", 4: "positive and uplifting", 5: "happy, euphoric, or celebratory"} + parts = [] + if mood_energy is not None: + parts.append(f"Energy: {energy_desc.get(mood_energy, 'moderate energy')}") + if mood_valence is not None: + parts.append(f"Mood: {valence_desc.get(mood_valence, 'neutral mood')}") + mood_instruction = f"\nMatch this mood: {'. '.join(parts)}" + # Exclude genres instruction exclude_instruction = "" combined_exclude = exclude or "" @@ -180,6 +198,7 @@ User request: {user_request} Discovery mode: {mode_instruction} {adventurousness_instruction} +{mood_instruction} IMPORTANT: If the user mentions specific artists or songs in their request, do NOT recommend anything BY those artists. The user already knows them — recommend music by OTHER artists that match the vibe. For example, if they say "I like Sublime", recommend artists similar to Sublime, but NEVER Sublime themselves. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8df6d52..5726fbf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -177,6 +177,8 @@ export const generateRecommendations = ( adventurousness?: number, exclude?: string, count?: number, + moodEnergy?: number, + moodValence?: number, ) => api.post('/recommendations/generate', { playlist_id: playlistId, @@ -186,8 +188,13 @@ export const generateRecommendations = ( adventurousness: adventurousness ?? 3, exclude: exclude || undefined, count: count ?? 5, + mood_energy: moodEnergy || undefined, + mood_valence: moodValence || undefined, }).then((r) => r.data) +export const surpriseMe = () => + api.post('/recommendations/surprise').then((r) => r.data) + export const getRecommendationHistory = () => api.get('/recommendations/history').then((r) => r.data) diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx index 61a960a..9168cfd 100644 --- a/frontend/src/pages/Discover.tsx +++ b/frontend/src/pages/Discover.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useRef } from 'react' import { useSearchParams } from 'react-router-dom' -import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp } from 'lucide-react' +import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp, Shuffle, X } from 'lucide-react' import { useAuth } from '../lib/auth' -import { getPlaylists, generateRecommendations, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api' +import { getPlaylists, generateRecommendations, surpriseMe, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api' import RecommendationCard from '../components/RecommendationCard' const DISCOVERY_MODES = [ @@ -39,6 +39,10 @@ export default function Discover() { const [adventurousness, setAdventurousness] = useState(3) const [excludeGenres, setExcludeGenres] = useState('') const [count, setCount] = useState(5) + const [moodEnergy, setMoodEnergy] = useState(null) + const [moodValence, setMoodValence] = useState(null) + const [showMood, setShowMood] = useState(false) + const [surprising, setSurprising] = useState(false) const resultsRef = useRef(null) useEffect(() => { @@ -79,6 +83,8 @@ export default function Discover() { adventurousness, excludeGenres.trim() || undefined, count, + moodEnergy ?? undefined, + moodValence ?? undefined, ) const recs = response.recommendations || [] setResults(recs) @@ -136,6 +142,30 @@ export default function Discover() { } } + const handleSurprise = async () => { + setSurprising(true) + setError('') + setResults([]) + + try { + const response = await surpriseMe() + const recs = response.recommendations || [] + setResults(recs) + setRemaining(response.remaining_this_week ?? null) + if (recs.length === 0) { + setError('No surprise recommendations returned.') + } + if (recs.length > 0) { + setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth' }), 100) + } + } catch (err: any) { + const msg = err.response?.data?.detail || err.message || 'Unknown error' + setError(`Error: ${msg} (status: ${err.response?.status || 'none'})`) + } finally { + setSurprising(false) + } + } + if (loading) { return (
@@ -156,6 +186,25 @@ export default function Discover() {

+ {/* Surprise Me */} + + {/* Discovery Modes */}
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => ( @@ -238,6 +287,86 @@ export default function Discover() {
+ {/* Mood Scanner */} +
+ + + {showMood && ( +
+ {/* Energy Slider */} +
+
+ + {moodEnergy !== null && ( + + )} +
+ setMoodEnergy(Number(e.target.value))} + className={`w-full h-2 rounded-full appearance-none cursor-pointer accent-purple ${moodEnergy !== null ? 'bg-purple-100' : 'bg-gray-200 opacity-50'}`} + /> +
+ Chill + Energetic +
+
+ + {/* Mood/Valence Slider */} +
+
+ + {moodValence !== null && ( + + )} +
+ setMoodValence(Number(e.target.value))} + className={`w-full h-2 rounded-full appearance-none cursor-pointer accent-purple ${moodValence !== null ? 'bg-purple-100' : 'bg-gray-200 opacity-50'}`} + /> +
+ Dark + Happy +
+
+
+ )} +
+ {/* Recommendation Count */}