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,

View File

@@ -19,6 +19,7 @@ import ArtistDive from './pages/ArtistDive'
import PlaylistGenerator from './pages/PlaylistGenerator'
import Timeline from './pages/Timeline'
import Compatibility from './pages/Compatibility'
import CrateDigger from './pages/CrateDigger'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -170,6 +171,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/crate"
element={
<ProtectedRoute>
<Layout>
<CrateDigger />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/timeline"
element={

View File

@@ -13,6 +13,7 @@ const baseNavItems = [
{ path: '/discover', label: 'Discover', icon: Compass },
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
{ path: '/crate', label: 'Crate Dig', icon: Disc3 },
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
{ path: '/compatibility', label: 'Taste Match', icon: Users },
{ path: '/saved', label: 'Saved', icon: Heart },

View File

@@ -260,6 +260,21 @@ export interface GeneratedPlaylistResponse {
export const generatePlaylist = (theme: string, count: number = 25, save: boolean = false) =>
api.post<GeneratedPlaylistResponse>('/recommendations/generate-playlist', { theme, count, save }).then((r) => r.data)
// Crate Digger
export interface CrateItem {
title: string
artist: string
album: string | null
reason: string
youtube_url: string | null
}
export const fillCrate = (count: number = 20) =>
api.post<CrateItem[]>('/recommendations/crate', { count }).then((r) => r.data)
export const crateSave = (title: string, artist: string, album: string | null, reason: string) =>
api.post<{ id: string; saved: boolean }>('/recommendations/crate-save', { title, artist, album, reason }).then((r) => r.data)
// YouTube Music Import
export interface YouTubeTrackResult {
title: string
@@ -413,4 +428,39 @@ export interface LogEntry {
export const getAdminLogs = (level: string = 'ALL', limit: number = 100) =>
api.get<{ logs: LogEntry[]; total: number }>('/admin/logs', { params: { level, limit } }).then((r) => r.data)
// Concerts
export interface ConcertEvent {
date: string
venue: string
city: string
region: string
country: string
url: string
}
export const findConcerts = (artist: string) =>
api.get<{ artist: string; events: ConcertEvent[] }>('/concerts', { params: { artist } }).then((r) => r.data)
// Rabbit Hole
export interface RabbitHoleStep {
title: string
artist: string
album: string | null
reason: string
connection: string
youtube_url: string | null
}
export interface RabbitHoleResponse {
theme: string
steps: RabbitHoleStep[]
}
export const generateRabbitHole = (seedArtist?: string, seedTitle?: string, steps: number = 8) =>
api.post<RabbitHoleResponse>('/recommendations/rabbit-hole', {
seed_artist: seedArtist || undefined,
seed_title: seedTitle || undefined,
steps,
}).then((r) => r.data)
export default api

View File

@@ -0,0 +1,250 @@
import { useState, useCallback } from 'react'
import { Disc3, X, Heart, ExternalLink, Loader2, RotateCcw } from 'lucide-react'
import { fillCrate, crateSave, type CrateItem } from '../lib/api'
type CardState = 'visible' | 'saving' | 'passing'
export default function CrateDigger() {
const [crate, setCrate] = useState<CrateItem[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [cardState, setCardState] = useState<CardState>('visible')
const [savedCount, setSavedCount] = useState(0)
const [crateSize, setCrateSize] = useState(0)
const [finished, setFinished] = useState(false)
const loadCrate = useCallback(async () => {
setLoading(true)
setError('')
setFinished(false)
setSavedCount(0)
setCurrentIndex(0)
setCardState('visible')
try {
const items = await fillCrate(20)
setCrate(items)
setCrateSize(items.length)
} catch {
setError('Failed to fill the crate. Try again.')
} finally {
setLoading(false)
}
}, [])
const advanceCard = useCallback(() => {
setTimeout(() => {
setCardState('visible')
if (currentIndex + 1 >= crate.length) {
setFinished(true)
} else {
setCurrentIndex((i) => i + 1)
}
}, 300)
}, [currentIndex, crate.length])
const handlePass = useCallback(() => {
if (cardState !== 'visible') return
setCardState('passing')
advanceCard()
}, [cardState, advanceCard])
const handleSave = useCallback(async () => {
if (cardState !== 'visible') return
const item = crate[currentIndex]
setCardState('saving')
setSavedCount((c) => c + 1)
try {
await crateSave(item.title, item.artist, item.album, item.reason)
} catch {
// Still advance even if save fails
}
advanceCard()
}, [cardState, crate, currentIndex, advanceCard])
const currentItem = crate[currentIndex]
// Empty state — no crate loaded yet
if (crate.length === 0 && !loading && !finished) {
return (
<div className="max-w-lg mx-auto text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 bg-purple-100 rounded-full flex items-center justify-center">
<Disc3 className="w-10 h-10 text-purple" />
</div>
<h1 className="text-3xl font-bold text-charcoal mb-3">Crate Digger</h1>
<p className="text-charcoal-muted mb-8">
Dig through a crate of hand-picked discoveries. Save the ones that speak to you, pass on the rest.
</p>
{error && (
<p className="text-red-600 text-sm mb-4">{error}</p>
)}
<button
onClick={loadCrate}
disabled={loading}
className="inline-flex items-center gap-2 px-8 py-4 bg-purple text-white rounded-xl font-semibold text-lg hover:bg-purple-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Filling Crate...
</>
) : (
<>
<Disc3 className="w-5 h-5" />
Fill My Crate
</>
)}
</button>
</div>
)
}
// Loading state
if (loading) {
return (
<div className="max-w-lg mx-auto text-center py-16">
<Loader2 className="w-12 h-12 text-purple animate-spin mx-auto mb-4" />
<p className="text-charcoal-muted text-lg">Filling your crate with discoveries...</p>
</div>
)
}
// Finished state
if (finished) {
return (
<div className="max-w-lg mx-auto text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 bg-purple-100 rounded-full flex items-center justify-center">
<Heart className="w-10 h-10 text-purple" />
</div>
<h2 className="text-3xl font-bold text-charcoal mb-3">Crate Empty!</h2>
<p className="text-charcoal-muted text-lg mb-2">
You saved <span className="font-bold text-purple">{savedCount}</span> out of <span className="font-bold">{crateSize}</span> records.
</p>
<p className="text-charcoal-muted mb-8">
{savedCount === 0
? 'Tough crowd! Try another crate for different picks.'
: savedCount <= crateSize / 4
? 'Selective taste. Check your saved recommendations!'
: savedCount <= crateSize / 2
? 'Nice haul! Some real gems in there.'
: 'You loved most of them! Great crate.'}
</p>
{error && <p className="text-red-600 text-sm mb-4">{error}</p>}
<button
onClick={loadCrate}
disabled={loading}
className="inline-flex items-center gap-2 px-8 py-4 bg-purple text-white rounded-xl font-semibold text-lg hover:bg-purple-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
>
<RotateCcw className="w-5 h-5" />
Fill Again
</button>
</div>
)
}
// Main crate digging view
return (
<div className="max-w-lg mx-auto py-4">
{/* Header */}
<div className="text-center mb-6">
<div className="flex items-center justify-center gap-2 mb-1">
<Disc3 className="w-6 h-6 text-purple" />
<h1 className="text-2xl font-bold text-charcoal">Crate Digger</h1>
</div>
<p className="text-charcoal-muted text-sm">Dig through the crate</p>
</div>
{/* Progress */}
<div className="flex items-center justify-between mb-4 px-2">
<span className="text-sm text-charcoal-muted">
{currentIndex + 1} of {crate.length}
</span>
<span className="text-sm text-charcoal-muted">
<Heart className="w-3.5 h-3.5 inline text-purple mr-1" />
{savedCount} saved
</span>
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-purple-100 rounded-full mb-6 overflow-hidden">
<div
className="h-full bg-purple rounded-full transition-all duration-300"
style={{ width: `${((currentIndex + 1) / crate.length) * 100}%` }}
/>
</div>
{/* Card */}
{currentItem && (
<div
className={`bg-white rounded-2xl shadow-lg border border-purple-100 overflow-hidden transition-all duration-300 ${
cardState === 'saving'
? 'opacity-0 translate-x-24'
: cardState === 'passing'
? 'opacity-0 -translate-x-24'
: 'opacity-100 translate-x-0'
}`}
>
{/* Album art placeholder */}
<div className="h-48 bg-gradient-to-br from-purple-600 via-purple-500 to-purple-800 flex items-center justify-center relative">
<Disc3 className="w-20 h-20 text-white/30" />
<div className="absolute bottom-3 right-3">
{currentItem.youtube_url && (
<a
href={currentItem.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg text-white text-xs font-medium hover:bg-white/30 transition-colors no-underline"
>
<ExternalLink className="w-3 h-3" />
Listen
</a>
)}
</div>
</div>
{/* Track info */}
<div className="p-6">
<h2 className="text-xl font-bold text-charcoal mb-1 leading-tight">
{currentItem.title}
</h2>
<p className="text-purple font-semibold mb-1">{currentItem.artist}</p>
{currentItem.album && (
<p className="text-charcoal-muted text-sm mb-4">{currentItem.album}</p>
)}
{!currentItem.album && <div className="mb-4" />}
<div className="bg-cream rounded-xl p-4">
<p className="text-charcoal text-sm leading-relaxed italic">
"{currentItem.reason}"
</p>
</div>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-center gap-8 mt-8">
<button
onClick={handlePass}
disabled={cardState !== 'visible'}
className="w-16 h-16 rounded-full bg-white border-2 border-red-300 flex items-center justify-center hover:bg-red-50 hover:border-red-400 transition-all cursor-pointer disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95"
title="Pass"
>
<X className="w-7 h-7 text-red-500" />
</button>
<button
onClick={handleSave}
disabled={cardState !== 'visible'}
className="w-16 h-16 rounded-full bg-white border-2 border-green-300 flex items-center justify-center hover:bg-green-50 hover:border-green-400 transition-all cursor-pointer disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95"
title="Save"
>
<Heart className="w-7 h-7 text-green-500" />
</button>
</div>
{/* Keyboard hints */}
<p className="text-center text-charcoal-muted text-xs mt-4">
Tap the buttons to pass or save
</p>
</div>
)
}