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/api/endpoints/__init__.py
Normal file
0
backend/app/api/endpoints/__init__.py
Normal file
93
backend/app/api/endpoints/auth.py
Normal file
93
backend/app/api/endpoints/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
||||
from app.services.spotify import get_spotify_auth_url, exchange_spotify_code, get_spotify_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=data.email,
|
||||
name=data.name,
|
||||
hashed_password=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
return TokenResponse(access_token=create_access_token(user.id))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.hashed_password or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
return TokenResponse(access_token=create_access_token(user.id))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_pro=user.is_pro,
|
||||
spotify_connected=user.spotify_id is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/spotify/url")
|
||||
async def spotify_auth_url():
|
||||
state = secrets.token_urlsafe(32)
|
||||
url = get_spotify_auth_url(state)
|
||||
return {"url": url, "state": state}
|
||||
|
||||
|
||||
@router.post("/spotify/callback", response_model=TokenResponse)
|
||||
async def spotify_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
token_data = await exchange_spotify_code(code)
|
||||
access_token = token_data["access_token"]
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
|
||||
spotify_user = await get_spotify_user(access_token)
|
||||
spotify_id = spotify_user["id"]
|
||||
email = spotify_user.get("email", f"{spotify_id}@spotify.user")
|
||||
name = spotify_user.get("display_name") or spotify_id
|
||||
|
||||
# Check if user exists by spotify_id or email
|
||||
result = await db.execute(select(User).where(User.spotify_id == spotify_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
user.spotify_id = spotify_id
|
||||
user.spotify_access_token = access_token
|
||||
user.spotify_refresh_token = refresh_token or user.spotify_refresh_token
|
||||
else:
|
||||
user = User(
|
||||
email=email,
|
||||
name=name,
|
||||
spotify_id=spotify_id,
|
||||
spotify_access_token=access_token,
|
||||
spotify_refresh_token=refresh_token,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
await db.flush()
|
||||
return TokenResponse(access_token=create_access_token(user.id))
|
||||
153
backend/app/api/endpoints/playlists.py
Normal file
153
backend/app/api/endpoints/playlists.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.track import Track
|
||||
from app.schemas.playlist import (
|
||||
PlaylistResponse,
|
||||
PlaylistDetailResponse,
|
||||
SpotifyPlaylistItem,
|
||||
ImportSpotifyRequest,
|
||||
)
|
||||
from app.services.spotify import get_user_playlists, get_playlist_tracks, get_audio_features
|
||||
from app.services.recommender import build_taste_profile
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PlaylistResponse])
|
||||
async def list_playlists(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user.id).order_by(Playlist.imported_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{playlist_id}", response_model=PlaylistDetailResponse)
|
||||
async def get_playlist(
|
||||
playlist_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Playlist)
|
||||
.options(selectinload(Playlist.tracks))
|
||||
.where(Playlist.id == playlist_id, Playlist.user_id == user.id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
return playlist
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}")
|
||||
async def delete_playlist(
|
||||
playlist_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
await db.delete(playlist)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/spotify/available", response_model=list[SpotifyPlaylistItem])
|
||||
async def list_spotify_playlists(user: User = Depends(get_current_user)):
|
||||
if not user.spotify_access_token:
|
||||
raise HTTPException(status_code=400, detail="Spotify not connected")
|
||||
playlists = await get_user_playlists(user.spotify_access_token)
|
||||
return playlists
|
||||
|
||||
|
||||
@router.post("/spotify/import", response_model=PlaylistDetailResponse)
|
||||
async def import_spotify_playlist(
|
||||
data: ImportSpotifyRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not user.spotify_access_token:
|
||||
raise HTTPException(status_code=400, detail="Spotify not connected")
|
||||
|
||||
# Free tier limit
|
||||
if not user.is_pro:
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user.id)
|
||||
)
|
||||
existing = list(result.scalars().all())
|
||||
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
|
||||
raise HTTPException(status_code=403, detail="Free tier limited to 1 playlist. Upgrade to Pro for unlimited.")
|
||||
|
||||
# Fetch tracks from Spotify
|
||||
raw_tracks = await get_playlist_tracks(user.spotify_access_token, data.playlist_id)
|
||||
|
||||
# Get playlist name from Spotify playlists
|
||||
spotify_playlists = await get_user_playlists(user.spotify_access_token)
|
||||
playlist_name = data.playlist_id
|
||||
for sp in spotify_playlists:
|
||||
if sp["id"] == data.playlist_id:
|
||||
playlist_name = sp["name"]
|
||||
break
|
||||
|
||||
# Create playlist
|
||||
playlist = Playlist(
|
||||
user_id=user.id,
|
||||
name=playlist_name,
|
||||
platform_source="spotify",
|
||||
external_id=data.playlist_id,
|
||||
track_count=len(raw_tracks),
|
||||
)
|
||||
db.add(playlist)
|
||||
await db.flush()
|
||||
|
||||
# Create tracks
|
||||
tracks = []
|
||||
for rt in raw_tracks:
|
||||
track = Track(
|
||||
playlist_id=playlist.id,
|
||||
title=rt["title"],
|
||||
artist=rt["artist"],
|
||||
album=rt.get("album"),
|
||||
spotify_id=rt.get("spotify_id"),
|
||||
isrc=rt.get("isrc"),
|
||||
preview_url=rt.get("preview_url"),
|
||||
image_url=rt.get("image_url"),
|
||||
)
|
||||
db.add(track)
|
||||
tracks.append(track)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Fetch audio features
|
||||
spotify_ids = [t.spotify_id for t in tracks if t.spotify_id]
|
||||
if spotify_ids:
|
||||
features = await get_audio_features(user.spotify_access_token, spotify_ids)
|
||||
features_map = {f["id"]: f for f in features if f}
|
||||
for track in tracks:
|
||||
if track.spotify_id and track.spotify_id in features_map:
|
||||
f = features_map[track.spotify_id]
|
||||
track.tempo = f.get("tempo")
|
||||
track.energy = f.get("energy")
|
||||
track.danceability = f.get("danceability")
|
||||
track.valence = f.get("valence")
|
||||
track.acousticness = f.get("acousticness")
|
||||
track.instrumentalness = f.get("instrumentalness")
|
||||
|
||||
# Build taste profile
|
||||
playlist.taste_profile = build_taste_profile(tracks)
|
||||
playlist.tracks = tracks
|
||||
|
||||
return playlist
|
||||
77
backend/app/api/endpoints/recommendations.py
Normal file
77
backend/app/api/endpoints/recommendations.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.recommendation import Recommendation
|
||||
from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem
|
||||
from app.services.recommender import generate_recommendations
|
||||
|
||||
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
|
||||
|
||||
|
||||
@router.post("/generate", response_model=RecommendationResponse)
|
||||
async def generate(
|
||||
data: RecommendationRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not data.playlist_id and not data.query:
|
||||
raise HTTPException(status_code=400, detail="Provide a playlist_id or query")
|
||||
|
||||
recs, remaining = await generate_recommendations(
|
||||
db, user, playlist_id=data.playlist_id, query=data.query
|
||||
)
|
||||
|
||||
if not recs and remaining == 0:
|
||||
raise HTTPException(status_code=429, detail="Daily recommendation limit reached. Upgrade to Pro for unlimited.")
|
||||
|
||||
return RecommendationResponse(
|
||||
recommendations=[RecommendationItem.model_validate(r) for r in recs],
|
||||
remaining_today=remaining,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[RecommendationItem])
|
||||
async def history(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Recommendation)
|
||||
.where(Recommendation.user_id == user.id)
|
||||
.order_by(Recommendation.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/saved", response_model=list[RecommendationItem])
|
||||
async def saved(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Recommendation)
|
||||
.where(Recommendation.user_id == user.id, Recommendation.saved == True)
|
||||
.order_by(Recommendation.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{rec_id}/save")
|
||||
async def save_recommendation(
|
||||
rec_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
|
||||
)
|
||||
rec = result.scalar_one_or_none()
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Recommendation not found")
|
||||
rec.saved = not rec.saved
|
||||
return {"saved": rec.saved}
|
||||
Reference in New Issue
Block a user