import json from datetime import datetime, timezone, timedelta import anthropic from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.models.track import Track from app.models.playlist import Playlist from app.models.recommendation import Recommendation from app.models.user import User def build_taste_profile(tracks: list[Track]) -> dict: """Analyze tracks to build a taste profile summary.""" if not tracks: return {} genres_count: dict[str, int] = {} total_energy = 0.0 total_dance = 0.0 total_valence = 0.0 total_tempo = 0.0 count_features = 0 for t in tracks: if t.genres: for g in t.genres: genres_count[g] = genres_count.get(g, 0) + 1 if t.energy is not None: total_energy += t.energy total_dance += t.danceability or 0 total_valence += t.valence or 0 total_tempo += t.tempo or 0 count_features += 1 top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10] n = max(count_features, 1) return { "top_genres": [{"name": g, "count": c} for g, c in top_genres], "avg_energy": round(total_energy / n, 3), "avg_danceability": round(total_dance / n, 3), "avg_valence": round(total_valence / n, 3), "avg_tempo": round(total_tempo / n, 1), "track_count": len(tracks), "sample_artists": list({t.artist for t in tracks[:20]}), "sample_tracks": [f"{t.artist} - {t.title}" for t in tracks[:15]], } async def get_daily_rec_count(db: AsyncSession, user_id: int) -> int: """Count recommendations generated today for rate limiting.""" today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) result = await db.execute( select(func.count(Recommendation.id)).where( Recommendation.user_id == user_id, Recommendation.created_at >= today_start, ) ) return result.scalar() or 0 async def generate_recommendations( db: AsyncSession, user: User, playlist_id: int | None = None, query: str | None = None, bandcamp_mode: bool = False, ) -> tuple[list[Recommendation], int | None]: """Generate AI music recommendations using Claude.""" # Rate limit check for free users remaining = None if not user.is_pro: used_today = await get_daily_rec_count(db, user.id) remaining = max(0, settings.FREE_DAILY_RECOMMENDATIONS - used_today) if remaining <= 0: return [], 0 # Gather context taste_context = "" existing_tracks = set() if playlist_id: result = await db.execute( select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id) ) playlist = result.scalar_one_or_none() if playlist: result = await db.execute( select(Track).where(Track.playlist_id == playlist.id) ) tracks = list(result.scalars().all()) existing_tracks = {f"{t.artist} - {t.title}".lower() for t in tracks} profile = build_taste_profile(tracks) taste_context = f"Taste profile from playlist '{playlist.name}':\n{json.dumps(profile, indent=2)}" else: # Gather from all user playlists 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()) existing_tracks = {f"{t.artist} - {t.title}".lower() for t in all_tracks} if all_tracks: profile = build_taste_profile(all_tracks) taste_context = f"Taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}" # 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 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." else: focus_instruction = "Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices." prompt = f"""You are Vynl, an AI music discovery assistant. You help people discover new music they'll love. {taste_context} User request: {user_request} Already in their library (do NOT recommend these): {', '.join(list(existing_tracks)[:50]) if existing_tracks else 'None provided'} Respond with exactly {"15" if bandcamp_mode else "5"} music recommendations as a JSON array. Each item should have: - "title": song title - "artist": artist name - "album": album name (if known) - "reason": A warm, personal 2-3 sentence explanation of WHY they'll love this track. Reference specific qualities from their taste profile. Be specific about sonic qualities, not generic. - "score": confidence score 0.0-1.0 {focus_instruction} Return ONLY the JSON array, no other text.""" # Call Claude API 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}], ) # Parse response response_text = message.content[0].text.strip() # Handle potential markdown code blocks if response_text.startswith("```"): response_text = response_text.split("\n", 1)[1] response_text = response_text.rsplit("```", 1)[0] try: recs_data = json.loads(response_text) except json.JSONDecodeError: return [], remaining from app.services.bandcamp import search_bandcamp # Save to DB — in bandcamp mode, only keep results verified on Bandcamp recommendations = [] for rec in recs_data: if len(recommendations) >= 5: break bandcamp_url = None if bandcamp_mode: try: results = await search_bandcamp( f"{rec.get('artist', '')} {rec.get('title', '')}", item_type="t" ) if not results: # Try artist-only search as fallback results = await search_bandcamp(rec.get("artist", ""), item_type="b") if results: bandcamp_url = results[0].get("bandcamp_url") else: # Not on Bandcamp — skip this recommendation continue except Exception: continue r = Recommendation( user_id=user.id, playlist_id=playlist_id, title=rec.get("title", "Unknown"), artist=rec.get("artist", "Unknown"), album=rec.get("album"), reason=rec.get("reason", ""), score=rec.get("score"), query=query, bandcamp_url=bandcamp_url, ) db.add(r) recommendations.append(r) await db.flush() if remaining is not None: remaining = max(0, remaining - len(recommendations)) return recommendations, remaining