Add Taste Match compatibility feature for comparing music taste between users
This commit is contained in:
216
backend/app/api/endpoints/compatibility.py
Normal file
216
backend/app/api/endpoints/compatibility.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
|
||||
import anthropic
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.track import Track
|
||||
from app.services.recommender import build_taste_profile
|
||||
|
||||
router = APIRouter(prefix="/profile", tags=["profile"])
|
||||
|
||||
|
||||
class CompatibilityRequest(BaseModel):
|
||||
friend_email: str
|
||||
|
||||
|
||||
class CompatibilityResponse(BaseModel):
|
||||
friend_name: str
|
||||
compatibility_score: int
|
||||
shared_genres: list[str]
|
||||
unique_to_you: list[str]
|
||||
unique_to_them: list[str]
|
||||
shared_artists: list[str]
|
||||
insight: str
|
||||
recommendations: list[dict]
|
||||
|
||||
|
||||
async def _get_user_tracks(db: AsyncSession, user_id: int) -> list[Track]:
|
||||
"""Load all tracks across all playlists for a user."""
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user_id)
|
||||
)
|
||||
playlists = list(result.scalars().all())
|
||||
|
||||
all_tracks = []
|
||||
for p in playlists:
|
||||
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
|
||||
all_tracks.extend(result.scalars().all())
|
||||
return all_tracks
|
||||
|
||||
|
||||
def _extract_genres(tracks: list[Track]) -> set[str]:
|
||||
"""Get the set of genres from a user's tracks."""
|
||||
genres = set()
|
||||
for t in tracks:
|
||||
if t.genres:
|
||||
for g in t.genres:
|
||||
genres.add(g)
|
||||
return genres
|
||||
|
||||
|
||||
def _extract_artists(tracks: list[Track]) -> set[str]:
|
||||
"""Get the set of artists from a user's tracks."""
|
||||
return {t.artist for t in tracks}
|
||||
|
||||
|
||||
def _audio_feature_avg(tracks: list[Track], attr: str) -> float:
|
||||
"""Calculate the average of an audio feature across tracks."""
|
||||
vals = [getattr(t, attr) for t in tracks if getattr(t, attr) is not None]
|
||||
return sum(vals) / len(vals) if vals else 0.0
|
||||
|
||||
|
||||
def _calculate_compatibility(
|
||||
my_tracks: list[Track],
|
||||
their_tracks: list[Track],
|
||||
) -> tuple[int, list[str], list[str], list[str], list[str]]:
|
||||
"""Calculate a weighted compatibility score between two users.
|
||||
|
||||
Returns (score, shared_genres, unique_to_you, unique_to_them, shared_artists).
|
||||
"""
|
||||
my_genres = _extract_genres(my_tracks)
|
||||
their_genres = _extract_genres(their_tracks)
|
||||
my_artists = _extract_artists(my_tracks)
|
||||
their_artists = _extract_artists(their_tracks)
|
||||
|
||||
shared_genres = sorted(my_genres & their_genres)
|
||||
unique_to_you = sorted(my_genres - their_genres)
|
||||
unique_to_them = sorted(their_genres - my_genres)
|
||||
shared_artists = sorted(my_artists & their_artists)
|
||||
|
||||
# Genre overlap (40% weight)
|
||||
all_genres = my_genres | their_genres
|
||||
genre_score = (len(shared_genres) / len(all_genres) * 100) if all_genres else 0
|
||||
|
||||
# Shared artists (30% weight)
|
||||
all_artists = my_artists | their_artists
|
||||
artist_score = (len(shared_artists) / len(all_artists) * 100) if all_artists else 0
|
||||
|
||||
# Audio feature similarity (30% weight)
|
||||
feature_diffs = []
|
||||
for attr in ("energy", "valence", "danceability"):
|
||||
my_avg = _audio_feature_avg(my_tracks, attr)
|
||||
their_avg = _audio_feature_avg(their_tracks, attr)
|
||||
feature_diffs.append(abs(my_avg - their_avg))
|
||||
avg_diff = sum(feature_diffs) / len(feature_diffs) if feature_diffs else 0
|
||||
feature_score = max(0, (1 - avg_diff) * 100)
|
||||
|
||||
score = int(genre_score * 0.4 + artist_score * 0.3 + feature_score * 0.3)
|
||||
score = max(0, min(100, score))
|
||||
|
||||
return score, shared_genres, unique_to_you, unique_to_them, shared_artists
|
||||
|
||||
|
||||
async def _generate_ai_insight(
|
||||
profile1: dict,
|
||||
profile2: dict,
|
||||
score: int,
|
||||
shared_genres: list[str],
|
||||
shared_artists: list[str],
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Call Claude to generate an insight and shared recommendations."""
|
||||
prompt = f"""Two music lovers want to know their taste compatibility.
|
||||
|
||||
User 1 taste profile:
|
||||
{json.dumps(profile1, indent=2)}
|
||||
|
||||
User 2 taste profile:
|
||||
{json.dumps(profile2, indent=2)}
|
||||
|
||||
Their compatibility score is {score}%.
|
||||
Shared genres: {", ".join(shared_genres) if shared_genres else "None"}
|
||||
Shared artists: {", ".join(shared_artists) if shared_artists else "None"}
|
||||
|
||||
Respond with JSON:
|
||||
{{
|
||||
"insight": "A fun 2-3 sentence description of their musical relationship",
|
||||
"recommendations": [
|
||||
{{"title": "...", "artist": "...", "reason": "Why both would love this"}}
|
||||
]
|
||||
}}
|
||||
Return ONLY the JSON. Include exactly 5 recommendations."""
|
||||
|
||||
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
try:
|
||||
text = message.content[0].text.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
||||
data = json.loads(text)
|
||||
return data.get("insight", ""), data.get("recommendations", [])
|
||||
except (json.JSONDecodeError, IndexError, KeyError):
|
||||
return "These two listeners have an interesting musical connection!", []
|
||||
|
||||
|
||||
@router.post("/compatibility", response_model=CompatibilityResponse)
|
||||
async def check_compatibility(
|
||||
data: CompatibilityRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Compare your taste profile with another user."""
|
||||
if data.friend_email.lower() == user.email.lower():
|
||||
raise HTTPException(status_code=400, detail="You can't compare with yourself!")
|
||||
|
||||
# Look up the friend
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == data.friend_email.lower())
|
||||
)
|
||||
friend = result.scalar_one_or_none()
|
||||
if not friend:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No user found with that email. They need to have a Vynl account first!",
|
||||
)
|
||||
|
||||
# Load tracks for both users
|
||||
my_tracks = await _get_user_tracks(db, user.id)
|
||||
their_tracks = await _get_user_tracks(db, friend.id)
|
||||
|
||||
if not my_tracks:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="You need to import some playlists first!",
|
||||
)
|
||||
if not their_tracks:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Your friend hasn't imported any playlists yet!",
|
||||
)
|
||||
|
||||
# Calculate compatibility
|
||||
score, shared_genres, unique_to_you, unique_to_them, shared_artists = (
|
||||
_calculate_compatibility(my_tracks, their_tracks)
|
||||
)
|
||||
|
||||
# Build taste profiles for AI
|
||||
profile1 = build_taste_profile(my_tracks)
|
||||
profile2 = build_taste_profile(their_tracks)
|
||||
|
||||
# Generate AI insight and recommendations
|
||||
insight, recommendations = await _generate_ai_insight(
|
||||
profile1, profile2, score, shared_genres[:10], shared_artists[:10]
|
||||
)
|
||||
|
||||
return CompatibilityResponse(
|
||||
friend_name=friend.name,
|
||||
compatibility_score=score,
|
||||
shared_genres=shared_genres[:15],
|
||||
unique_to_you=unique_to_you[:10],
|
||||
unique_to_them=unique_to_them[:10],
|
||||
shared_artists=shared_artists[:15],
|
||||
insight=insight,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.endpoints import admin, auth, bandcamp, billing, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, timeline, youtube_music
|
||||
from app.api.endpoints import admin, auth, bandcamp, billing, compatibility, concerts, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, timeline, youtube_music
|
||||
|
||||
app = FastAPI(title="Vynl API", version="1.0.0", redirect_slashes=False)
|
||||
|
||||
@@ -29,6 +29,8 @@ app.include_router(manual_import.router, prefix="/api")
|
||||
app.include_router(lastfm.router, prefix="/api")
|
||||
app.include_router(bandcamp.router, prefix="/api")
|
||||
app.include_router(profile.router, prefix="/api")
|
||||
app.include_router(compatibility.router, prefix="/api")
|
||||
app.include_router(concerts.router, prefix="/api")
|
||||
app.include_router(timeline.router, prefix="/api")
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import SharedView from './pages/SharedView'
|
||||
import ArtistDive from './pages/ArtistDive'
|
||||
import PlaylistGenerator from './pages/PlaylistGenerator'
|
||||
import Timeline from './pages/Timeline'
|
||||
import Compatibility from './pages/Compatibility'
|
||||
|
||||
function RootRedirect() {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -159,6 +160,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/compatibility"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Compatibility />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/timeline"
|
||||
element={
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
|
||||
import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Users, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
|
||||
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
|
||||
@@ -14,6 +14,7 @@ const baseNavItems = [
|
||||
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
|
||||
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
||||
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
||||
{ path: '/compatibility', label: 'Taste Match', icon: Users },
|
||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||
{ path: '/billing', label: 'Pro', icon: Crown },
|
||||
]
|
||||
|
||||
@@ -359,6 +359,21 @@ export const fixPlaylist = (playlistId: string) =>
|
||||
export const getTasteProfile = () =>
|
||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
||||
|
||||
// Taste Compatibility
|
||||
export interface CompatibilityResponse {
|
||||
friend_name: string
|
||||
compatibility_score: number
|
||||
shared_genres: string[]
|
||||
unique_to_you: string[]
|
||||
unique_to_them: string[]
|
||||
shared_artists: string[]
|
||||
insight: string
|
||||
recommendations: { title: string; artist: string; reason: string }[]
|
||||
}
|
||||
|
||||
export const checkCompatibility = (friendEmail: string) =>
|
||||
api.post<CompatibilityResponse>('/profile/compatibility', { friend_email: friendEmail }).then((r) => r.data)
|
||||
|
||||
// Timeline
|
||||
export interface TimelineDecade {
|
||||
decade: string
|
||||
|
||||
228
frontend/src/pages/Compatibility.tsx
Normal file
228
frontend/src/pages/Compatibility.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import { Users, Loader2, Music, Sparkles, Heart } from 'lucide-react'
|
||||
import { checkCompatibility, type CompatibilityResponse } from '../lib/api'
|
||||
|
||||
function ScoreCircle({ score }: { score: number }) {
|
||||
const radius = 70
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
|
||||
const color =
|
||||
score < 30 ? '#EF4444' : score < 60 ? '#EAB308' : '#22C55E'
|
||||
const bgColor =
|
||||
score < 30 ? 'text-red-100' : score < 60 ? 'text-yellow-100' : 'text-green-100'
|
||||
const label =
|
||||
score < 30 ? 'Different Wavelengths' : score < 60 ? 'Some Common Ground' : score < 80 ? 'Great Match' : 'Musical Soulmates'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative w-44 h-44">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
|
||||
<circle
|
||||
cx="80"
|
||||
cy="80"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="10"
|
||||
className={bgColor}
|
||||
/>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="80"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4xl font-bold text-charcoal">{score}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-charcoal-muted">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GenrePills({ genres, color }: { genres: string[]; color: string }) {
|
||||
if (!genres.length) return <span className="text-sm text-charcoal-muted italic">None</span>
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{genres.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${color}`}
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Compatibility() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<CompatibilityResponse | null>(null)
|
||||
|
||||
const handleCompare = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const data = await checkCompatibility(email.trim())
|
||||
setResult(data)
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.detail || 'Failed to check compatibility.'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="w-14 h-14 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-7 h-7 text-purple" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-charcoal">Taste Match</h1>
|
||||
<p className="text-charcoal-muted mt-2">
|
||||
See how your music taste compares with a friend
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email input */}
|
||||
<form onSubmit={handleCompare} className="max-w-md mx-auto flex gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your friend's email"
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-purple-200 bg-white text-charcoal placeholder-charcoal-muted/50 focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email.trim()}
|
||||
className="px-6 py-3 bg-purple text-white rounded-xl font-medium hover:bg-purple-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Compare'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="max-w-md mx-auto bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
<p className="text-charcoal-muted text-sm">Analyzing your musical chemistry...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && !loading && (
|
||||
<div className="space-y-8 max-w-2xl mx-auto">
|
||||
{/* Score */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-8 text-center">
|
||||
<p className="text-sm text-charcoal-muted mb-4">
|
||||
You & <span className="font-semibold text-charcoal">{result.friend_name}</span>
|
||||
</p>
|
||||
<ScoreCircle score={result.compatibility_score} />
|
||||
</div>
|
||||
|
||||
{/* AI Insight */}
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100/50 rounded-2xl border border-purple-200 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-purple mt-0.5 flex-shrink-0" />
|
||||
<p className="text-charcoal leading-relaxed">{result.insight}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared Genres */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
|
||||
<h3 className="font-semibold text-charcoal flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-purple" />
|
||||
Shared Genres
|
||||
</h3>
|
||||
<GenrePills genres={result.shared_genres} color="bg-purple-100 text-purple" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-charcoal-muted mb-2">Only You</h4>
|
||||
<GenrePills genres={result.unique_to_you} color="bg-blue-100 text-blue-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-charcoal-muted mb-2">Only Them</h4>
|
||||
<GenrePills genres={result.unique_to_them} color="bg-amber-100 text-amber-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared Artists */}
|
||||
{result.shared_artists.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<h3 className="font-semibold text-charcoal flex items-center gap-2 mb-3">
|
||||
<Music className="w-4 h-4 text-purple" />
|
||||
Shared Artists
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.shared_artists.map((a) => (
|
||||
<span
|
||||
key={a}
|
||||
className="px-3 py-1.5 bg-cream rounded-lg text-sm text-charcoal font-medium"
|
||||
>
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{result.recommendations.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-charcoal text-lg">
|
||||
Songs You'd Both Love
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{result.recommendations.map((rec, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-xl border border-purple-100 p-4 flex items-start gap-4"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Music className="w-5 h-5 text-purple" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-charcoal truncate">{rec.title}</p>
|
||||
<p className="text-sm text-charcoal-muted">{rec.artist}</p>
|
||||
<p className="text-xs text-charcoal-muted/80 mt-1">{rec.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user