Add Tier 2 features: Playlist Generator, Artist Deep Dive, Music Timeline

- 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
This commit is contained in:
root
2026-03-31 18:50:23 -05:00
parent 0b82149b97
commit 7abec6de7c
10 changed files with 1102 additions and 4 deletions

View File

@@ -12,9 +12,11 @@ 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
from app.services.recommender import generate_recommendations, build_taste_profile
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
@@ -172,6 +174,210 @@ Return ONLY the JSON object."""
)
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,
)
@router.post("/{rec_id}/save")
async def save_recommendation(
rec_id: int,