Files
vynl/backend/app/api/endpoints/playlists.py
root 155cbd1bbf 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
2026-03-30 15:53:39 -05:00

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