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:
root
2026-03-31 18:58:02 -05:00
parent 5b603f4acc
commit aeadf722cb
5 changed files with 534 additions and 0 deletions

View File

@@ -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,