Initial MVP: full-stack music discovery app

Backend (FastAPI):
- User auth with email/password and Spotify OAuth
- Spotify playlist import with audio feature extraction
- AI recommendation engine using Claude API with taste profiling
- Save/bookmark recommendations
- Rate limiting for free tier (10 recs/day, 1 playlist)
- PostgreSQL models with Alembic migrations
- Redis-ready configuration

Frontend (React 19 + TypeScript + Vite + Tailwind):
- Landing page, auth flows (email + Spotify OAuth)
- Dashboard with stats and quick discover
- Playlist management and import from Spotify
- Discover page with custom query support
- Recommendation cards with explanations and save toggle
- Taste profile visualization
- Responsive layout with mobile navigation
- PWA-ready configuration

Infrastructure:
- Docker Compose with PostgreSQL, Redis, backend, frontend
- Environment-based configuration
This commit is contained in:
root
2026-03-30 15:53:39 -05:00
commit 155cbd1bbf
62 changed files with 7536 additions and 0 deletions

View File

View File

@@ -0,0 +1,176 @@
import json
from datetime import datetime, timezone, timedelta
import anthropic
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.track import Track
from app.models.playlist import Playlist
from app.models.recommendation import Recommendation
from app.models.user import User
def build_taste_profile(tracks: list[Track]) -> dict:
"""Analyze tracks to build a taste profile summary."""
if not tracks:
return {}
genres_count: dict[str, int] = {}
total_energy = 0.0
total_dance = 0.0
total_valence = 0.0
total_tempo = 0.0
count_features = 0
for t in tracks:
if t.genres:
for g in t.genres:
genres_count[g] = genres_count.get(g, 0) + 1
if t.energy is not None:
total_energy += t.energy
total_dance += t.danceability or 0
total_valence += t.valence or 0
total_tempo += t.tempo or 0
count_features += 1
top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10]
n = max(count_features, 1)
return {
"top_genres": [{"name": g, "count": c} for g, c in top_genres],
"avg_energy": round(total_energy / n, 3),
"avg_danceability": round(total_dance / n, 3),
"avg_valence": round(total_valence / n, 3),
"avg_tempo": round(total_tempo / n, 1),
"track_count": len(tracks),
"sample_artists": list({t.artist for t in tracks[:20]}),
"sample_tracks": [f"{t.artist} - {t.title}" for t in tracks[:15]],
}
async def get_daily_rec_count(db: AsyncSession, user_id: int) -> int:
"""Count recommendations generated today for rate limiting."""
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(Recommendation.id)).where(
Recommendation.user_id == user_id,
Recommendation.created_at >= today_start,
)
)
return result.scalar() or 0
async def generate_recommendations(
db: AsyncSession,
user: User,
playlist_id: int | None = None,
query: str | None = None,
) -> tuple[list[Recommendation], int | None]:
"""Generate AI music recommendations using Claude."""
# Rate limit check for free users
remaining = None
if not user.is_pro:
used_today = await get_daily_rec_count(db, user.id)
remaining = max(0, settings.FREE_DAILY_RECOMMENDATIONS - used_today)
if remaining <= 0:
return [], 0
# Gather context
taste_context = ""
existing_tracks = set()
if playlist_id:
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
)
playlist = result.scalar_one_or_none()
if playlist:
result = await db.execute(
select(Track).where(Track.playlist_id == playlist.id)
)
tracks = list(result.scalars().all())
existing_tracks = {f"{t.artist} - {t.title}".lower() for t in tracks}
profile = build_taste_profile(tracks)
taste_context = f"Taste profile from playlist '{playlist.name}':\n{json.dumps(profile, indent=2)}"
else:
# Gather from all user playlists
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
playlists = list(result.scalars().all())
all_tracks = []
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
all_tracks.extend(result.scalars().all())
existing_tracks = {f"{t.artist} - {t.title}".lower() for t in all_tracks}
if all_tracks:
profile = build_taste_profile(all_tracks)
taste_context = f"Taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
# Build prompt
user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems."
prompt = f"""You are Vynl, an AI music discovery assistant. You help people discover new music they'll love.
{taste_context}
User request: {user_request}
Already in their library (do NOT recommend these):
{', '.join(list(existing_tracks)[:50]) if existing_tracks else 'None provided'}
Respond with exactly 5 music recommendations as a JSON array. Each item should have:
- "title": song title
- "artist": artist name
- "album": album name (if known)
- "reason": A warm, personal 2-3 sentence explanation of WHY they'll love this track. Reference specific qualities from their taste profile. Be specific about sonic qualities, not generic.
- "score": confidence score 0.0-1.0
Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices.
Return ONLY the JSON array, no other text."""
# Call Claude API
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
# Parse response
response_text = message.content[0].text.strip()
# Handle potential markdown code blocks
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
recs_data = json.loads(response_text)
except json.JSONDecodeError:
return [], remaining
# Save to DB
recommendations = []
for rec in recs_data[:5]:
r = Recommendation(
user_id=user.id,
playlist_id=playlist_id,
title=rec.get("title", "Unknown"),
artist=rec.get("artist", "Unknown"),
album=rec.get("album"),
reason=rec.get("reason", ""),
score=rec.get("score"),
query=query,
)
db.add(r)
recommendations.append(r)
await db.flush()
if remaining is not None:
remaining = max(0, remaining - len(recommendations))
return recommendations, remaining

View File

@@ -0,0 +1,129 @@
import httpx
from urllib.parse import urlencode
from app.core.config import settings
SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
SPOTIFY_API_URL = "https://api.spotify.com/v1"
SCOPES = "user-read-private user-read-email playlist-read-private playlist-read-collaborative"
def get_spotify_auth_url(state: str) -> str:
params = {
"client_id": settings.SPOTIFY_CLIENT_ID,
"response_type": "code",
"redirect_uri": settings.SPOTIFY_REDIRECT_URI,
"scope": SCOPES,
"state": state,
}
return f"{SPOTIFY_AUTH_URL}?{urlencode(params)}"
async def exchange_spotify_code(code: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
SPOTIFY_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.SPOTIFY_REDIRECT_URI,
"client_id": settings.SPOTIFY_CLIENT_ID,
"client_secret": settings.SPOTIFY_CLIENT_SECRET,
},
)
response.raise_for_status()
return response.json()
async def refresh_spotify_token(refresh_token: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
SPOTIFY_TOKEN_URL,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.SPOTIFY_CLIENT_ID,
"client_secret": settings.SPOTIFY_CLIENT_SECRET,
},
)
response.raise_for_status()
return response.json()
async def get_spotify_user(access_token: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{SPOTIFY_API_URL}/me",
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return response.json()
async def get_user_playlists(access_token: str) -> list[dict]:
playlists = []
url = f"{SPOTIFY_API_URL}/me/playlists?limit=50"
async with httpx.AsyncClient() as client:
while url:
response = await client.get(
url, headers={"Authorization": f"Bearer {access_token}"}
)
response.raise_for_status()
data = response.json()
for item in data["items"]:
playlists.append({
"id": item["id"],
"name": item["name"],
"track_count": item["tracks"]["total"],
"image_url": item["images"][0]["url"] if item.get("images") else None,
})
url = data.get("next")
return playlists
async def get_playlist_tracks(access_token: str, playlist_id: str) -> list[dict]:
tracks = []
url = f"{SPOTIFY_API_URL}/playlists/{playlist_id}/tracks?limit=100"
async with httpx.AsyncClient() as client:
while url:
response = await client.get(
url, headers={"Authorization": f"Bearer {access_token}"}
)
response.raise_for_status()
data = response.json()
for item in data["items"]:
t = item.get("track")
if not t or not t.get("id"):
continue
tracks.append({
"spotify_id": t["id"],
"title": t["name"],
"artist": ", ".join(a["name"] for a in t["artists"]),
"album": t["album"]["name"] if t.get("album") else None,
"isrc": t.get("external_ids", {}).get("isrc"),
"preview_url": t.get("preview_url"),
"image_url": t["album"]["images"][0]["url"]
if t.get("album", {}).get("images")
else None,
})
url = data.get("next")
return tracks
async def get_audio_features(access_token: str, track_ids: list[str]) -> list[dict]:
features = []
async with httpx.AsyncClient() as client:
# Spotify allows max 100 IDs per request
for i in range(0, len(track_ids), 100):
batch = track_ids[i : i + 100]
response = await client.get(
f"{SPOTIFY_API_URL}/audio-features",
params={"ids": ",".join(batch)},
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
data = response.json()
features.extend(data.get("audio_features") or [])
return features