Add mood scanner and surprise me features to discover page
Add mood_energy and mood_valence sliders that inject mood context into AI recommendation prompts. Add "Surprise Me" button that generates recommendations from a creative, unexpected angle without requiring any user input. Includes backend endpoints, schema updates, and full frontend UI integration.
This commit is contained in:
@@ -31,6 +31,25 @@ async def generate(
|
|||||||
recs, remaining = await generate_recommendations(
|
recs, remaining = await generate_recommendations(
|
||||||
db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode,
|
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,
|
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:
|
if not recs and remaining == 0:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class RecommendationRequest(BaseModel):
|
|||||||
adventurousness: int = 3 # 1-5
|
adventurousness: int = 3 # 1-5
|
||||||
exclude: str | None = None # comma-separated genres to exclude
|
exclude: str | None = None # comma-separated genres to exclude
|
||||||
count: int = 5 # Number of recommendations (5, 10, 15, 20)
|
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):
|
class RecommendationItem(BaseModel):
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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.",
|
"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,
|
adventurousness: int = 3,
|
||||||
exclude: str | None = None,
|
exclude: str | None = None,
|
||||||
count: int = 5,
|
count: int = 5,
|
||||||
|
mood_energy: int | None = None,
|
||||||
|
mood_valence: int | None = None,
|
||||||
) -> tuple[list[Recommendation], int | None]:
|
) -> tuple[list[Recommendation], int | None]:
|
||||||
"""Generate AI music recommendations using Claude."""
|
"""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()})
|
disliked_artists = list({a for a in disliked_result.scalars().all()})
|
||||||
|
|
||||||
# Build prompt
|
# 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:
|
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."
|
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
|
||||||
adventurousness_instruction = build_adventurousness_prompt(adventurousness)
|
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 genres instruction
|
||||||
exclude_instruction = ""
|
exclude_instruction = ""
|
||||||
combined_exclude = exclude or ""
|
combined_exclude = exclude or ""
|
||||||
@@ -180,6 +198,7 @@ User request: {user_request}
|
|||||||
Discovery mode: {mode_instruction}
|
Discovery mode: {mode_instruction}
|
||||||
|
|
||||||
{adventurousness_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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export const generateRecommendations = (
|
|||||||
adventurousness?: number,
|
adventurousness?: number,
|
||||||
exclude?: string,
|
exclude?: string,
|
||||||
count?: number,
|
count?: number,
|
||||||
|
moodEnergy?: number,
|
||||||
|
moodValence?: number,
|
||||||
) =>
|
) =>
|
||||||
api.post<RecommendationResponse>('/recommendations/generate', {
|
api.post<RecommendationResponse>('/recommendations/generate', {
|
||||||
playlist_id: playlistId,
|
playlist_id: playlistId,
|
||||||
@@ -186,8 +188,13 @@ export const generateRecommendations = (
|
|||||||
adventurousness: adventurousness ?? 3,
|
adventurousness: adventurousness ?? 3,
|
||||||
exclude: exclude || undefined,
|
exclude: exclude || undefined,
|
||||||
count: count ?? 5,
|
count: count ?? 5,
|
||||||
|
mood_energy: moodEnergy || undefined,
|
||||||
|
mood_valence: moodValence || undefined,
|
||||||
}).then((r) => r.data)
|
}).then((r) => r.data)
|
||||||
|
|
||||||
|
export const surpriseMe = () =>
|
||||||
|
api.post<RecommendationResponse>('/recommendations/surprise').then((r) => r.data)
|
||||||
|
|
||||||
export const getRecommendationHistory = () =>
|
export const getRecommendationHistory = () =>
|
||||||
api.get<RecommendationItem[]>('/recommendations/history').then((r) => r.data)
|
api.get<RecommendationItem[]>('/recommendations/history').then((r) => r.data)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
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 { 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'
|
import RecommendationCard from '../components/RecommendationCard'
|
||||||
|
|
||||||
const DISCOVERY_MODES = [
|
const DISCOVERY_MODES = [
|
||||||
@@ -39,6 +39,10 @@ export default function Discover() {
|
|||||||
const [adventurousness, setAdventurousness] = useState(3)
|
const [adventurousness, setAdventurousness] = useState(3)
|
||||||
const [excludeGenres, setExcludeGenres] = useState('')
|
const [excludeGenres, setExcludeGenres] = useState('')
|
||||||
const [count, setCount] = useState(5)
|
const [count, setCount] = useState(5)
|
||||||
|
const [moodEnergy, setMoodEnergy] = useState<number | null>(null)
|
||||||
|
const [moodValence, setMoodValence] = useState<number | null>(null)
|
||||||
|
const [showMood, setShowMood] = useState(false)
|
||||||
|
const [surprising, setSurprising] = useState(false)
|
||||||
const resultsRef = useRef<HTMLDivElement>(null)
|
const resultsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,6 +83,8 @@ export default function Discover() {
|
|||||||
adventurousness,
|
adventurousness,
|
||||||
excludeGenres.trim() || undefined,
|
excludeGenres.trim() || undefined,
|
||||||
count,
|
count,
|
||||||
|
moodEnergy ?? undefined,
|
||||||
|
moodValence ?? undefined,
|
||||||
)
|
)
|
||||||
const recs = response.recommendations || []
|
const recs = response.recommendations || []
|
||||||
setResults(recs)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -156,6 +186,25 @@ export default function Discover() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Surprise Me */}
|
||||||
|
<button
|
||||||
|
onClick={handleSurprise}
|
||||||
|
disabled={surprising || discovering}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-purple via-purple-dark to-purple text-white font-semibold rounded-2xl hover:shadow-xl hover:shadow-purple/30 transition-all disabled:opacity-50 cursor-pointer border-none text-base flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
{surprising ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
Cooking up a surprise...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shuffle className="w-6 h-6" />
|
||||||
|
Surprise Me
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Discovery Modes */}
|
{/* Discovery Modes */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => (
|
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => (
|
||||||
@@ -238,6 +287,86 @@ export default function Discover() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mood Scanner */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMood(!showMood)
|
||||||
|
if (showMood) {
|
||||||
|
setMoodEnergy(null)
|
||||||
|
setMoodValence(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium text-charcoal hover:text-purple transition-colors cursor-pointer flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{showMood ? 'Hide mood' : 'Set mood'}
|
||||||
|
<span className="text-charcoal-muted text-xs font-normal">(optional)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMood && (
|
||||||
|
<div className="mt-3 space-y-4 p-4 bg-cream/30 rounded-xl border border-purple-100/50">
|
||||||
|
{/* Energy Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-charcoal">Energy</label>
|
||||||
|
{moodEnergy !== null && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoodEnergy(null)}
|
||||||
|
className="text-xs text-charcoal-muted hover:text-purple transition-colors cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={moodEnergy ?? 3}
|
||||||
|
onChange={(e) => 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'}`}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between mt-1.5 px-0.5">
|
||||||
|
<span className="text-xs text-charcoal-muted">Chill</span>
|
||||||
|
<span className="text-xs text-charcoal-muted">Energetic</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mood/Valence Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-charcoal">Mood</label>
|
||||||
|
{moodValence !== null && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoodValence(null)}
|
||||||
|
className="text-xs text-charcoal-muted hover:text-purple transition-colors cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={moodValence ?? 3}
|
||||||
|
onChange={(e) => 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'}`}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between mt-1.5 px-0.5">
|
||||||
|
<span className="text-xs text-charcoal-muted">Dark</span>
|
||||||
|
<span className="text-xs text-charcoal-muted">Happy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recommendation Count */}
|
{/* Recommendation Count */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-charcoal mb-3">
|
<label className="block text-sm font-medium text-charcoal mb-3">
|
||||||
@@ -291,7 +420,7 @@ export default function Discover() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDiscover}
|
onClick={handleDiscover}
|
||||||
disabled={discovering || (!selectedPlaylist && !query.trim())}
|
disabled={discovering || surprising || (!selectedPlaylist && !query.trim())}
|
||||||
className="w-full py-3.5 bg-gradient-to-r from-purple to-purple-dark text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-purple/25 transition-all disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
|
className="w-full py-3.5 bg-gradient-to-r from-purple to-purple-dark text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-purple/25 transition-all disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{discovering ? (
|
{discovering ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user