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

View 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))

View 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

View 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}