Files
vynl/backend/app/api/endpoints/recommendations.py
root 0b82149b97 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.
2026-03-31 18:31:35 -05:00

295 lines
9.7 KiB
Python

import hashlib
import json
from urllib.parse import quote_plus
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.recommendation import Recommendation
from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem
from app.services.recommender import generate_recommendations
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
@router.post("/generate", response_model=RecommendationResponse)
async def generate(
data: RecommendationRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not data.playlist_id and not data.query:
raise HTTPException(status_code=400, detail="Provide a playlist_id or query")
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:
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.get("/history", response_model=list[RecommendationItem])
async def history(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Recommendation)
.where(Recommendation.user_id == user.id)
.order_by(Recommendation.created_at.desc())
.limit(50)
)
return result.scalars().all()
@router.get("/saved", response_model=list[RecommendationItem])
async def saved(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Recommendation)
.where(Recommendation.user_id == user.id, Recommendation.saved == True)
.order_by(Recommendation.created_at.desc())
)
return result.scalars().all()
class AnalyzeRequest(BaseModel):
artist: str
title: str
class AnalyzeResponse(BaseModel):
analysis: str
qualities: list[str]
recommendations: list[RecommendationItem]
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze_song(
data: AnalyzeRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
prompt = f"""You are Vynl, a music analysis expert. The user wants to understand why they love this song:
Artist: {data.artist}
Title: {data.title}
Respond with a JSON object:
{{
"analysis": "A warm, insightful 3-4 sentence explanation of what makes this song special and why someone would be drawn to it. Reference specific sonic qualities, production choices, lyrical themes, and emotional resonance.",
"qualities": ["quality1", "quality2", ...],
"recommendations": [
{{"title": "...", "artist": "...", "album": "...", "reason": "...", "score": 0.9}}
]
}}
For "qualities", list 4-6 specific musical qualities (e.g., "warm analog production", "introspective lyrics about loss", "driving bass line with syncopated rhythm").
For "recommendations", suggest 5 songs that share these same qualities. Only suggest songs that actually exist.
Return ONLY the JSON object."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
parsed = json.loads(response_text)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse AI response")
analysis = parsed.get("analysis", "")
qualities = parsed.get("qualities", [])
recs_data = parsed.get("recommendations", [])
recommendations = []
for rec in recs_data[:5]:
artist = rec.get("artist", "Unknown")
title = rec.get("title", "Unknown")
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
r = Recommendation(
user_id=user.id,
title=title,
artist=artist,
album=rec.get("album"),
reason=rec.get("reason", ""),
score=rec.get("score"),
query=f"analyze: {data.artist} - {data.title}",
youtube_url=youtube_url,
)
db.add(r)
recommendations.append(r)
await db.flush()
return AnalyzeResponse(
analysis=analysis,
qualities=qualities,
recommendations=[RecommendationItem.model_validate(r) for r in recommendations],
)
@router.post("/{rec_id}/save")
async def save_recommendation(
rec_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
rec.saved = not rec.saved
return {"saved": rec.saved}
@router.post("/{rec_id}/dislike")
async def dislike_recommendation(
rec_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
rec.disliked = not rec.disliked
return {"disliked": rec.disliked}
@router.post("/{rec_id}/share")
async def share_recommendation(
rec_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate a share link for a recommendation."""
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
token = hashlib.sha256(f"{rec_id}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
return {"share_url": f"{settings.FRONTEND_URL}/shared/{rec_id}/{token}"}
@router.get("/shared/{rec_id}/{token}")
async def get_shared_recommendation(
rec_id: int,
token: str,
db: AsyncSession = Depends(get_db),
):
"""View a shared recommendation (no auth required)."""
expected = hashlib.sha256(f"{rec_id}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
if token != expected:
raise HTTPException(status_code=404, detail="Invalid share link")
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
return {
"title": rec.title,
"artist": rec.artist,
"album": rec.album,
"reason": rec.reason,
"youtube_url": rec.youtube_url,
"image_url": rec.image_url,
}
@router.post("/share-batch")
async def share_batch(
rec_ids: list[int],
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate a share link for multiple recommendations."""
ids_str = ",".join(str(i) for i in sorted(rec_ids))
token = hashlib.sha256(f"batch:{ids_str}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
return {"share_url": f"{settings.FRONTEND_URL}/shared/batch/{ids_str}/{token}"}
@router.get("/shared/batch/{ids_str}/{token}")
async def get_shared_batch(
ids_str: str,
token: str,
db: AsyncSession = Depends(get_db),
):
"""View shared recommendations (no auth required)."""
expected = hashlib.sha256(f"batch:{ids_str}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
if token != expected:
raise HTTPException(status_code=404, detail="Invalid share link")
rec_ids = [int(i) for i in ids_str.split(",")]
result = await db.execute(
select(Recommendation).where(Recommendation.id.in_(rec_ids))
)
recs = result.scalars().all()
return {
"recommendations": [
{
"title": r.title,
"artist": r.artist,
"album": r.album,
"reason": r.reason,
"youtube_url": r.youtube_url,
"image_url": r.image_url,
}
for r in recs
]
}