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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user