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
154 lines
5.1 KiB
Python
154 lines
5.1 KiB
Python
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
|