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, ) 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 ] }