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:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
176
backend/app/services/recommender.py
Normal file
176
backend/app/services/recommender.py
Normal 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
|
||||
129
backend/app/services/spotify.py
Normal file
129
backend/app/services/spotify.py
Normal 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
|
||||
Reference in New Issue
Block a user