- Playlist Generator: describe a vibe, get a 15-30 song playlist, save or copy as text - Artist Deep Dive: click any artist name for influences, best album, hidden gems, similar artists - Music Timeline: visual decade breakdown of your taste with AI insight - Nav updates: Create Playlist, Timeline links
186 lines
5.7 KiB
Python
186 lines
5.7 KiB
Python
import json
|
|
import logging
|
|
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/profile", tags=["profile"])
|
|
|
|
|
|
class DecadeData(BaseModel):
|
|
decade: str
|
|
artists: list[str]
|
|
count: int
|
|
percentage: float
|
|
|
|
|
|
class TimelineResponse(BaseModel):
|
|
decades: list[DecadeData]
|
|
total_artists: int
|
|
dominant_era: str
|
|
insight: str
|
|
|
|
|
|
@router.get("/timeline", response_model=TimelineResponse)
|
|
async def get_timeline(
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Build a music timeline showing which eras/decades define the user's taste."""
|
|
|
|
# Get all tracks from user's playlists
|
|
result = await db.execute(
|
|
select(Playlist).where(Playlist.user_id == user.id)
|
|
)
|
|
playlists = list(result.scalars().all())
|
|
|
|
all_artists: set[str] = set()
|
|
for p in playlists:
|
|
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
|
|
tracks = result.scalars().all()
|
|
for t in tracks:
|
|
if t.artist:
|
|
all_artists.add(t.artist)
|
|
|
|
# Get artists from saved recommendations
|
|
result = await db.execute(
|
|
select(Recommendation).where(
|
|
Recommendation.user_id == user.id,
|
|
Recommendation.saved == True, # noqa: E712
|
|
)
|
|
)
|
|
saved_recs = result.scalars().all()
|
|
for r in saved_recs:
|
|
if r.artist:
|
|
all_artists.add(r.artist)
|
|
|
|
if not all_artists:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="No artists found. Import some playlists first.",
|
|
)
|
|
|
|
# Cap at 50 artists for the Claude call
|
|
artist_list = sorted(all_artists)[:50]
|
|
|
|
# Call Claude once to categorize all artists by era
|
|
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
|
|
|
prompt = f"""Categorize these artists by their primary era/decade. For each artist, pick the decade they are MOST associated with (when they were most active/influential).
|
|
|
|
Artists: {', '.join(artist_list)}
|
|
|
|
Respond with a JSON object with two keys:
|
|
1. "decades" - keys are decade strings, values are lists of artists from the input:
|
|
{{
|
|
"1960s": ["artist1"],
|
|
"1970s": ["artist2"],
|
|
"1980s": [],
|
|
"1990s": ["artist3"],
|
|
"2000s": ["artist4", "artist5"],
|
|
"2010s": ["artist6"],
|
|
"2020s": ["artist7"]
|
|
}}
|
|
|
|
2. "insight" - A single engaging sentence about their taste pattern across time, like "Your taste peaks in the 2000s indie explosion, with strong roots in 90s alternative." Make it specific to the actual artists and eras present.
|
|
|
|
Return ONLY a valid JSON object with "decades" and "insight" keys. No other text."""
|
|
|
|
try:
|
|
message = await client.messages.create(
|
|
model="claude-sonnet-4-20250514",
|
|
max_tokens=1024,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
|
|
response_text = message.content[0].text.strip()
|
|
|
|
# Try to extract JSON if wrapped in markdown code blocks
|
|
if response_text.startswith("```"):
|
|
lines = response_text.split("\n")
|
|
json_lines = []
|
|
in_block = False
|
|
for line in lines:
|
|
if line.startswith("```") and not in_block:
|
|
in_block = True
|
|
continue
|
|
elif line.startswith("```") and in_block:
|
|
break
|
|
elif in_block:
|
|
json_lines.append(line)
|
|
response_text = "\n".join(json_lines)
|
|
|
|
parsed = json.loads(response_text)
|
|
decades_data = parsed.get("decades", parsed)
|
|
insight = parsed.get("insight", "")
|
|
|
|
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
logger.error(f"Failed to parse Claude timeline response: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Failed to analyze your music timeline. Please try again.",
|
|
)
|
|
except anthropic.APIError as e:
|
|
logger.error(f"Claude API error in timeline: {e}")
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="AI service unavailable. Please try again later.",
|
|
)
|
|
|
|
# Build the response
|
|
total_categorized = 0
|
|
decade_results: list[DecadeData] = []
|
|
|
|
all_decades = ["1960s", "1970s", "1980s", "1990s", "2000s", "2010s", "2020s"]
|
|
|
|
for decade in all_decades:
|
|
artists = decades_data.get(decade, [])
|
|
if isinstance(artists, list):
|
|
total_categorized += len(artists)
|
|
|
|
dominant_decade = ""
|
|
max_count = 0
|
|
|
|
for decade in all_decades:
|
|
artists = decades_data.get(decade, [])
|
|
if not isinstance(artists, list):
|
|
artists = []
|
|
count = len(artists)
|
|
percentage = round((count / total_categorized * 100), 1) if total_categorized > 0 else 0.0
|
|
|
|
if count > max_count:
|
|
max_count = count
|
|
dominant_decade = decade
|
|
|
|
decade_results.append(
|
|
DecadeData(
|
|
decade=decade,
|
|
artists=artists,
|
|
count=count,
|
|
percentage=percentage,
|
|
)
|
|
)
|
|
|
|
if not insight:
|
|
insight = f"Your music taste is centered around the {dominant_decade}."
|
|
|
|
return TimelineResponse(
|
|
decades=decade_results,
|
|
total_artists=len(all_artists),
|
|
dominant_era=dominant_decade,
|
|
insight=insight,
|
|
)
|