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:
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
|
||||
Reference in New Issue
Block a user