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.playlist import Playlist from app.models.track import Track from app.models.recommendation import Recommendation from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem from app.services.recommender import generate_recommendations, build_taste_profile 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], ) class ArtistDeepDiveRequest(BaseModel): artist: str class ArtistDeepDiveResponse(BaseModel): artist: str summary: str why_they_matter: str influences: list[str] influenced: list[str] start_with: str start_with_reason: str deep_cut: str similar_artists: list[str] genres: list[str] @router.post("/artist-dive", response_model=ArtistDeepDiveResponse) async def artist_deep_dive( data: ArtistDeepDiveRequest, user: User = Depends(get_current_user), ): prompt = f"""You are Vynl, a music expert. Give a deep dive on this artist: Artist: {data.artist} Respond with a JSON object: {{ "artist": "{data.artist}", "summary": "2-3 sentences about who they are and their sound", "why_they_matter": "Their cultural significance and impact on music", "influences": ["artist1", "artist2", "artist3"], "influenced": ["artist1", "artist2", "artist3"], "start_with": "Album Name", "start_with_reason": "Why this is the best entry point", "deep_cut": "A hidden gem track title", "similar_artists": ["artist1", "artist2", "artist3", "artist4", "artist5"], "genres": ["genre1", "genre2"] }} 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") return ArtistDeepDiveResponse( artist=parsed.get("artist", data.artist), summary=parsed.get("summary", ""), why_they_matter=parsed.get("why_they_matter", ""), influences=parsed.get("influences", []), influenced=parsed.get("influenced", []), start_with=parsed.get("start_with", ""), start_with_reason=parsed.get("start_with_reason", ""), deep_cut=parsed.get("deep_cut", ""), similar_artists=parsed.get("similar_artists", []), genres=parsed.get("genres", []), ) class GeneratePlaylistRequest(BaseModel): theme: str count: int = 25 save: bool = False class PlaylistTrack(BaseModel): title: str artist: str album: str | None = None reason: str youtube_url: str | None = None class GeneratedPlaylistResponse(BaseModel): name: str description: str tracks: list[PlaylistTrack] playlist_id: int | None = None @router.post("/generate-playlist", response_model=GeneratedPlaylistResponse) async def generate_playlist( data: GeneratePlaylistRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not data.theme.strip(): raise HTTPException(status_code=400, detail="Theme is required") if data.count < 5 or data.count > 50: raise HTTPException(status_code=400, detail="Count must be between 5 and 50") # Build taste context from user's playlists taste_context = "" result = await db.execute( select(Playlist).where(Playlist.user_id == user.id) ) playlists = list(result.scalars().all()) all_tracks = [] for p in playlists: track_result = await db.execute(select(Track).where(Track.playlist_id == p.id)) all_tracks.extend(track_result.scalars().all()) if all_tracks: profile = build_taste_profile(all_tracks) taste_context = f"\n\nThe user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}\n\nUse this to personalize the playlist to their taste while staying true to the theme." prompt = f"""You are Vynl, a playlist curator. Create a cohesive playlist for this theme: Theme: {data.theme} {taste_context} Generate a playlist of exactly {data.count} songs. The playlist should flow naturally — songs should be ordered for a great listening experience, not random. Respond with a JSON object: {{ "name": "A creative playlist name", "description": "A 1-2 sentence description of the playlist vibe", "tracks": [ {{"title": "...", "artist": "...", "album": "...", "reason": "Why this fits the playlist"}} ] }} Only recommend real songs that actually exist. Do not invent song titles. 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=4000, 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") playlist_name = parsed.get("name", f"Playlist: {data.theme}") description = parsed.get("description", "") tracks_data = parsed.get("tracks", []) # Add YouTube Music search links tracks = [] for t in tracks_data: artist = t.get("artist", "Unknown") title = t.get("title", "Unknown") yt_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}" tracks.append(PlaylistTrack( title=title, artist=artist, album=t.get("album"), reason=t.get("reason", ""), youtube_url=yt_url, )) # Optionally save as a playlist in the DB playlist_id = None if data.save: new_playlist = Playlist( user_id=user.id, name=playlist_name, platform_source="generated", track_count=len(tracks), ) db.add(new_playlist) await db.flush() for t in tracks: track_record = Track( playlist_id=new_playlist.id, title=t.title, artist=t.artist, album=t.album, ) db.add(track_record) await db.flush() playlist_id = new_playlist.id return GeneratedPlaylistResponse( name=playlist_name, description=description, tracks=tracks, playlist_id=playlist_id, ) class CrateRequest(BaseModel): count: int = 20 class CrateItem(BaseModel): title: str artist: str album: str | None = None reason: str youtube_url: str | None = None @router.post("/crate", response_model=list[CrateItem]) async def fill_crate( data: CrateRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if data.count < 1 or data.count > 50: raise HTTPException(status_code=400, detail="Count must be between 1 and 50") # Build taste context from user's playlists taste_context = "No listening history yet — give a diverse mix of great music across genres and eras." result = await db.execute( select(Playlist).where(Playlist.user_id == user.id) ) playlists = list(result.scalars().all()) all_tracks = [] for p in playlists: track_result = await db.execute(select(Track).where(Track.playlist_id == p.id)) all_tracks.extend(track_result.scalars().all()) if all_tracks: profile = build_taste_profile(all_tracks) taste_context = f"The user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}" prompt = f"""You are Vynl, filling a vinyl crate for a music lover to dig through. {taste_context} Fill a crate with {data.count} diverse music discoveries. Mix it up: - Some familiar-adjacent picks they'll instantly love - Some wildcards from genres they haven't explored - Some deep cuts and rarities - Some brand new artists - Some classics they may have missed Make each pick interesting and varied. This should feel like flipping through records at a great shop. Respond with a JSON array of objects with: title, artist, album, reason (1 sentence why it's in the crate). Only recommend real songs. Return ONLY the JSON array.""" client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) message = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=4000, 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") items = [] for rec in parsed: artist = rec.get("artist", "Unknown") title = rec.get("title", "Unknown") youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}" items.append(CrateItem( title=title, artist=artist, album=rec.get("album"), reason=rec.get("reason", ""), youtube_url=youtube_url, )) return items class CrateSaveRequest(BaseModel): title: str artist: str album: str | None = None reason: str @router.post("/crate-save") async def crate_save( data: CrateSaveRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{data.artist} {data.title}')}" r = Recommendation( user_id=user.id, title=data.title, artist=data.artist, album=data.album, reason=data.reason, saved=True, youtube_url=youtube_url, query="crate-digger", ) db.add(r) await db.flush() return {"id": r.id, "saved": True} class RabbitHoleStep(BaseModel): title: str artist: str album: str | None = None reason: str connection: str # How this connects to the previous step youtube_url: str | None = None class RabbitHoleResponse(BaseModel): theme: str steps: list[RabbitHoleStep] class RabbitHoleRequest(BaseModel): seed_artist: str | None = None seed_title: str | None = None steps: int = 8 @router.post("/rabbit-hole", response_model=RabbitHoleResponse) async def rabbit_hole( data: RabbitHoleRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Generate a musical rabbit hole — a chain of connected songs.""" if data.steps < 3 or data.steps > 15: raise HTTPException(status_code=400, detail="Steps must be between 3 and 15") # Build taste context taste_context = "" result = await db.execute( select(Playlist).where(Playlist.user_id == user.id) ) playlists = list(result.scalars().all()) all_tracks = [] for p in playlists: track_result = await db.execute(select(Track).where(Track.playlist_id == p.id)) all_tracks.extend(track_result.scalars().all()) if all_tracks: profile = build_taste_profile(all_tracks) taste_context = f"\n\nThe user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}" seed_info = "" if data.seed_artist: seed_info = f"Starting from: {data.seed_artist}" if data.seed_title: seed_info += f" - {data.seed_title}" prompt = f"""You are Vynl, a music guide taking someone on a journey. Create a musical rabbit hole — a chain of connected songs where each one leads naturally to the next through a shared quality. {seed_info} {taste_context} Create a {data.steps}-step rabbit hole. Each step should connect to the previous one through ONE specific quality — maybe the same producer, a shared influence, a similar guitar tone, a lyrical theme, a tempo shift, etc. The connections should feel like "if you liked THAT about the last song, wait until you hear THIS." Respond with JSON: {{ "theme": "A fun 1-sentence description of where this rabbit hole goes", "steps": [ {{ "title": "...", "artist": "...", "album": "...", "reason": "Why this song is great", "connection": "How this connects to the previous song (leave empty for first step)" }} ] }} Only use real songs. Return ONLY the JSON.""" client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) message = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=4000, 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") theme = parsed.get("theme", "A musical journey") steps_data = parsed.get("steps", []) steps = [] for s in steps_data: artist = s.get("artist", "Unknown") title = s.get("title", "Unknown") yt_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}" steps.append(RabbitHoleStep( title=title, artist=artist, album=s.get("album"), reason=s.get("reason", ""), connection=s.get("connection", ""), youtube_url=yt_url, )) return RabbitHoleResponse(theme=theme, steps=steps) @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 ] }