Add Crate Digger feature for swipe-based music discovery
- POST /api/recommendations/crate endpoint generates diverse crate of discoveries - POST /api/recommendations/crate-save endpoint saves individual picks - CrateDigger.tsx page with card UI, pass/save buttons, slide animations - Progress bar, save counter, and end-of-crate stats summary - Added to nav, routing, and API client
This commit is contained in:
@@ -378,6 +378,228 @@ Return ONLY the JSON object."""
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user