From aeadf722cba5ad40df6415b6902bb80feb218029 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 18:58:02 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/recommendations.py | 222 ++++++++++++++++ frontend/src/App.tsx | 11 + frontend/src/components/Layout.tsx | 1 + frontend/src/lib/api.ts | 50 ++++ frontend/src/pages/CrateDigger.tsx | 250 +++++++++++++++++++ 5 files changed, 534 insertions(+) create mode 100644 frontend/src/pages/CrateDigger.tsx diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index eefa93e..760a7d0 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -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, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d89593..4867178 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + + } + /> api.post('/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('/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('/recommendations/rabbit-hole', { + seed_artist: seedArtist || undefined, + seed_title: seedTitle || undefined, + steps, + }).then((r) => r.data) + export default api diff --git a/frontend/src/pages/CrateDigger.tsx b/frontend/src/pages/CrateDigger.tsx new file mode 100644 index 0000000..76913b6 --- /dev/null +++ b/frontend/src/pages/CrateDigger.tsx @@ -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([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [cardState, setCardState] = useState('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 ( +
+
+ +
+

Crate Digger

+

+ Dig through a crate of hand-picked discoveries. Save the ones that speak to you, pass on the rest. +

+ {error && ( +

{error}

+ )} + +
+ ) + } + + // Loading state + if (loading) { + return ( +
+ +

Filling your crate with discoveries...

+
+ ) + } + + // Finished state + if (finished) { + return ( +
+
+ +
+

Crate Empty!

+

+ You saved {savedCount} out of {crateSize} records. +

+

+ {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.'} +

+ {error &&

{error}

} + +
+ ) + } + + // Main crate digging view + return ( +
+ {/* Header */} +
+
+ +

Crate Digger

+
+

Dig through the crate

+
+ + {/* Progress */} +
+ + {currentIndex + 1} of {crate.length} + + + + {savedCount} saved + +
+ + {/* Progress bar */} +
+
+
+ + {/* Card */} + {currentItem && ( +
+ {/* Album art placeholder */} +
+ +
+ {currentItem.youtube_url && ( + + + Listen + + )} +
+
+ + {/* Track info */} +
+

+ {currentItem.title} +

+

{currentItem.artist}

+ {currentItem.album && ( +

{currentItem.album}

+ )} + {!currentItem.album &&
} +
+

+ "{currentItem.reason}" +

+
+
+
+ )} + + {/* Action buttons */} +
+ + +
+ + {/* Keyboard hints */} +

+ Tap the buttons to pass or save +

+
+ ) +}