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:
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
SECRET_KEY=change-me-to-a-random-string
|
||||
DATABASE_URL=postgresql+asyncpg://vynl:vynl@db:5432/vynl
|
||||
DATABASE_URL_SYNC=postgresql://vynl:vynl@db:5432/vynl
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
SPOTIFY_CLIENT_ID=your-spotify-client-id
|
||||
SPOTIFY_CLIENT_SECRET=your-spotify-client-secret
|
||||
SPOTIFY_REDIRECT_URI=http://localhost:5173/auth/spotify/callback
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://vynl:vynl@localhost:5432/vynl
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
46
backend/alembic/env.py
Normal file
46
backend/alembic/env.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
|
||||
# Import all models so they register with Base.metadata
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.playlist import Playlist # noqa: F401
|
||||
from app.models.track import Track # noqa: F401
|
||||
from app.models.recommendation import Recommendation # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
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}
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
35
backend/app/core/config.py
Normal file
35
backend/app/core/config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "Vynl"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://vynl:vynl@localhost:5432/vynl"
|
||||
DATABASE_URL_SYNC: str = "postgresql://vynl:vynl@localhost:5432/vynl"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# Spotify
|
||||
SPOTIFY_CLIENT_ID: str = ""
|
||||
SPOTIFY_CLIENT_SECRET: str = ""
|
||||
SPOTIFY_REDIRECT_URI: str = "http://localhost:5173/auth/spotify/callback"
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL: str = "http://localhost:5173"
|
||||
|
||||
# Rate limits (free tier)
|
||||
FREE_DAILY_RECOMMENDATIONS: int = 10
|
||||
FREE_MAX_PLAYLISTS: int = 1
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
55
backend/app/core/security.py
Normal file
55
backend/app/core/security.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": str(user_id), "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.models.user import User
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
24
backend/app/main.py
Normal file
24
backend/app/main.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.endpoints import auth, playlists, recommendations
|
||||
|
||||
app = FastAPI(title="Vynl API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.FRONTEND_URL],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(playlists.router, prefix="/api")
|
||||
app.include_router(recommendations.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "app": "vynl"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
28
backend/app/models/playlist.py
Normal file
28
backend/app/models/playlist.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, JSON, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(500))
|
||||
platform_source: Mapped[str] = mapped_column(String(50)) # spotify, manual, etc.
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
track_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
taste_profile: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
imported_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="playlists")
|
||||
tracks: Mapped[list["Track"]] = relationship(back_populates="playlist", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
from app.models.user import User # noqa: E402, F811
|
||||
from app.models.track import Track # noqa: E402
|
||||
38
backend/app/models/recommendation.py
Normal file
38
backend/app/models/recommendation.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Recommendation(Base):
|
||||
__tablename__ = "recommendations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Recommended track info
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
artist: Mapped[str] = mapped_column(String(500))
|
||||
album: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# AI explanation
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# User interaction
|
||||
saved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="recommendations")
|
||||
|
||||
|
||||
from app.models.user import User # noqa: E402, F811
|
||||
33
backend/app/models/track.py
Normal file
33
backend/app/models/track.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import String, Integer, Float, ForeignKey, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Track(Base):
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
artist: Mapped[str] = mapped_column(String(500))
|
||||
album: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
isrc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Audio features from Spotify
|
||||
tempo: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
energy: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
danceability: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
valence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
acousticness: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
instrumentalness: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
genres: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
playlist: Mapped["Playlist"] = relationship(back_populates="tracks")
|
||||
|
||||
|
||||
from app.models.playlist import Playlist # noqa: E402, F811
|
||||
32
backend/app/models/user.py
Normal file
32
backend/app/models/user.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_pro: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Spotify OAuth
|
||||
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||
spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
spotify_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
playlists: Mapped[list["Playlist"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
recommendations: Mapped[list["Recommendation"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
from app.models.playlist import Playlist # noqa: E402
|
||||
from app.models.recommendation import Recommendation # noqa: E402
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
27
backend/app/schemas/auth.py
Normal file
27
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
is_pro: bool
|
||||
spotify_connected: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
46
backend/app/schemas/playlist.py
Normal file
46
backend/app/schemas/playlist.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TrackResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
spotify_id: str | None = None
|
||||
preview_url: str | None = None
|
||||
image_url: str | None = None
|
||||
tempo: float | None = None
|
||||
energy: float | None = None
|
||||
danceability: float | None = None
|
||||
valence: float | None = None
|
||||
genres: list | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlaylistResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
platform_source: str
|
||||
track_count: int
|
||||
taste_profile: dict | None = None
|
||||
imported_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlaylistDetailResponse(PlaylistResponse):
|
||||
tracks: list[TrackResponse] = []
|
||||
|
||||
|
||||
class SpotifyPlaylistItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
track_count: int
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ImportSpotifyRequest(BaseModel):
|
||||
playlist_id: str
|
||||
39
backend/app/schemas/recommendation.py
Normal file
39
backend/app/schemas/recommendation.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RecommendationRequest(BaseModel):
|
||||
playlist_id: int | None = None
|
||||
query: str | None = None # Manual search/request
|
||||
|
||||
|
||||
class RecommendationItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
spotify_id: str | None = None
|
||||
preview_url: str | None = None
|
||||
image_url: str | None = None
|
||||
reason: str
|
||||
score: float | None = None
|
||||
saved: bool = False
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RecommendationResponse(BaseModel):
|
||||
recommendations: list[RecommendationItem]
|
||||
remaining_today: int | None = None # None = unlimited (pro)
|
||||
|
||||
|
||||
class TasteProfile(BaseModel):
|
||||
top_genres: list[dict]
|
||||
avg_energy: float
|
||||
avg_danceability: float
|
||||
avg_valence: float
|
||||
avg_tempo: float
|
||||
era_preferences: list[str]
|
||||
mood_summary: str
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
176
backend/app/services/recommender.py
Normal file
176
backend/app/services/recommender.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import anthropic
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.track import Track
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.recommendation import Recommendation
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def build_taste_profile(tracks: list[Track]) -> dict:
|
||||
"""Analyze tracks to build a taste profile summary."""
|
||||
if not tracks:
|
||||
return {}
|
||||
|
||||
genres_count: dict[str, int] = {}
|
||||
total_energy = 0.0
|
||||
total_dance = 0.0
|
||||
total_valence = 0.0
|
||||
total_tempo = 0.0
|
||||
count_features = 0
|
||||
|
||||
for t in tracks:
|
||||
if t.genres:
|
||||
for g in t.genres:
|
||||
genres_count[g] = genres_count.get(g, 0) + 1
|
||||
if t.energy is not None:
|
||||
total_energy += t.energy
|
||||
total_dance += t.danceability or 0
|
||||
total_valence += t.valence or 0
|
||||
total_tempo += t.tempo or 0
|
||||
count_features += 1
|
||||
|
||||
top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
n = max(count_features, 1)
|
||||
|
||||
return {
|
||||
"top_genres": [{"name": g, "count": c} for g, c in top_genres],
|
||||
"avg_energy": round(total_energy / n, 3),
|
||||
"avg_danceability": round(total_dance / n, 3),
|
||||
"avg_valence": round(total_valence / n, 3),
|
||||
"avg_tempo": round(total_tempo / n, 1),
|
||||
"track_count": len(tracks),
|
||||
"sample_artists": list({t.artist for t in tracks[:20]}),
|
||||
"sample_tracks": [f"{t.artist} - {t.title}" for t in tracks[:15]],
|
||||
}
|
||||
|
||||
|
||||
async def get_daily_rec_count(db: AsyncSession, user_id: int) -> int:
|
||||
"""Count recommendations generated today for rate limiting."""
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
result = await db.execute(
|
||||
select(func.count(Recommendation.id)).where(
|
||||
Recommendation.user_id == user_id,
|
||||
Recommendation.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def generate_recommendations(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
playlist_id: int | None = None,
|
||||
query: str | None = None,
|
||||
) -> tuple[list[Recommendation], int | None]:
|
||||
"""Generate AI music recommendations using Claude."""
|
||||
|
||||
# Rate limit check for free users
|
||||
remaining = None
|
||||
if not user.is_pro:
|
||||
used_today = await get_daily_rec_count(db, user.id)
|
||||
remaining = max(0, settings.FREE_DAILY_RECOMMENDATIONS - used_today)
|
||||
if remaining <= 0:
|
||||
return [], 0
|
||||
|
||||
# Gather context
|
||||
taste_context = ""
|
||||
existing_tracks = set()
|
||||
|
||||
if playlist_id:
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if playlist:
|
||||
result = await db.execute(
|
||||
select(Track).where(Track.playlist_id == playlist.id)
|
||||
)
|
||||
tracks = list(result.scalars().all())
|
||||
existing_tracks = {f"{t.artist} - {t.title}".lower() for t in tracks}
|
||||
profile = build_taste_profile(tracks)
|
||||
taste_context = f"Taste profile from playlist '{playlist.name}':\n{json.dumps(profile, indent=2)}"
|
||||
else:
|
||||
# Gather from all user playlists
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user.id)
|
||||
)
|
||||
playlists = list(result.scalars().all())
|
||||
all_tracks = []
|
||||
for p in playlists:
|
||||
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
|
||||
all_tracks.extend(result.scalars().all())
|
||||
existing_tracks = {f"{t.artist} - {t.title}".lower() for t in all_tracks}
|
||||
if all_tracks:
|
||||
profile = build_taste_profile(all_tracks)
|
||||
taste_context = f"Taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
|
||||
|
||||
# Build prompt
|
||||
user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems."
|
||||
|
||||
prompt = f"""You are Vynl, an AI music discovery assistant. You help people discover new music they'll love.
|
||||
|
||||
{taste_context}
|
||||
|
||||
User request: {user_request}
|
||||
|
||||
Already in their library (do NOT recommend these):
|
||||
{', '.join(list(existing_tracks)[:50]) if existing_tracks else 'None provided'}
|
||||
|
||||
Respond with exactly 5 music recommendations as a JSON array. Each item should have:
|
||||
- "title": song title
|
||||
- "artist": artist name
|
||||
- "album": album name (if known)
|
||||
- "reason": A warm, personal 2-3 sentence explanation of WHY they'll love this track. Reference specific qualities from their taste profile. Be specific about sonic qualities, not generic.
|
||||
- "score": confidence score 0.0-1.0
|
||||
|
||||
Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices.
|
||||
Return ONLY the JSON array, no other text."""
|
||||
|
||||
# Call Claude API
|
||||
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
# Parse response
|
||||
response_text = message.content[0].text.strip()
|
||||
# Handle potential markdown code blocks
|
||||
if response_text.startswith("```"):
|
||||
response_text = response_text.split("\n", 1)[1]
|
||||
response_text = response_text.rsplit("```", 1)[0]
|
||||
|
||||
try:
|
||||
recs_data = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
return [], remaining
|
||||
|
||||
# Save to DB
|
||||
recommendations = []
|
||||
for rec in recs_data[:5]:
|
||||
r = Recommendation(
|
||||
user_id=user.id,
|
||||
playlist_id=playlist_id,
|
||||
title=rec.get("title", "Unknown"),
|
||||
artist=rec.get("artist", "Unknown"),
|
||||
album=rec.get("album"),
|
||||
reason=rec.get("reason", ""),
|
||||
score=rec.get("score"),
|
||||
query=query,
|
||||
)
|
||||
db.add(r)
|
||||
recommendations.append(r)
|
||||
|
||||
await db.flush()
|
||||
|
||||
if remaining is not None:
|
||||
remaining = max(0, remaining - len(recommendations))
|
||||
|
||||
return recommendations, remaining
|
||||
129
backend/app/services/spotify.py
Normal file
129
backend/app/services/spotify.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import httpx
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
|
||||
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
SPOTIFY_API_URL = "https://api.spotify.com/v1"
|
||||
|
||||
SCOPES = "user-read-private user-read-email playlist-read-private playlist-read-collaborative"
|
||||
|
||||
|
||||
def get_spotify_auth_url(state: str) -> str:
|
||||
params = {
|
||||
"client_id": settings.SPOTIFY_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": settings.SPOTIFY_REDIRECT_URI,
|
||||
"scope": SCOPES,
|
||||
"state": state,
|
||||
}
|
||||
return f"{SPOTIFY_AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def exchange_spotify_code(code: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
SPOTIFY_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": settings.SPOTIFY_REDIRECT_URI,
|
||||
"client_id": settings.SPOTIFY_CLIENT_ID,
|
||||
"client_secret": settings.SPOTIFY_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def refresh_spotify_token(refresh_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
SPOTIFY_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": settings.SPOTIFY_CLIENT_ID,
|
||||
"client_secret": settings.SPOTIFY_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_spotify_user(access_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{SPOTIFY_API_URL}/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_user_playlists(access_token: str) -> list[dict]:
|
||||
playlists = []
|
||||
url = f"{SPOTIFY_API_URL}/me/playlists?limit=50"
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
response = await client.get(
|
||||
url, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
playlists.append({
|
||||
"id": item["id"],
|
||||
"name": item["name"],
|
||||
"track_count": item["tracks"]["total"],
|
||||
"image_url": item["images"][0]["url"] if item.get("images") else None,
|
||||
})
|
||||
url = data.get("next")
|
||||
return playlists
|
||||
|
||||
|
||||
async def get_playlist_tracks(access_token: str, playlist_id: str) -> list[dict]:
|
||||
tracks = []
|
||||
url = f"{SPOTIFY_API_URL}/playlists/{playlist_id}/tracks?limit=100"
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
response = await client.get(
|
||||
url, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
t = item.get("track")
|
||||
if not t or not t.get("id"):
|
||||
continue
|
||||
tracks.append({
|
||||
"spotify_id": t["id"],
|
||||
"title": t["name"],
|
||||
"artist": ", ".join(a["name"] for a in t["artists"]),
|
||||
"album": t["album"]["name"] if t.get("album") else None,
|
||||
"isrc": t.get("external_ids", {}).get("isrc"),
|
||||
"preview_url": t.get("preview_url"),
|
||||
"image_url": t["album"]["images"][0]["url"]
|
||||
if t.get("album", {}).get("images")
|
||||
else None,
|
||||
})
|
||||
url = data.get("next")
|
||||
return tracks
|
||||
|
||||
|
||||
async def get_audio_features(access_token: str, track_ids: list[str]) -> list[dict]:
|
||||
features = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Spotify allows max 100 IDs per request
|
||||
for i in range(0, len(track_ids), 100):
|
||||
batch = track_ids[i : i + 100]
|
||||
response = await client.get(
|
||||
f"{SPOTIFY_API_URL}/audio-features",
|
||||
params={"ids": ",".join(batch)},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
features.extend(data.get("audio_features") or [])
|
||||
return features
|
||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.1
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.20
|
||||
pydantic[email]==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
redis==5.2.1
|
||||
celery==5.4.0
|
||||
httpx==0.28.1
|
||||
anthropic==0.42.0
|
||||
spotipy==2.24.0
|
||||
python-dotenv==1.0.1
|
||||
Reference in New Issue
Block a user