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

9
backend/.env.example Normal file
View 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
View 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
View 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
View 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()

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

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}

View File

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

View 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

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

View File

View 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

View 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

View 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

View 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

View File

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

View 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

View 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

View File

View 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

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