Initial commit: AuroraMusic monorepo

This commit is contained in:
Kris
2026-08-29 21:21:51 -05:00
commit 18f124a3d6
8881 changed files with 2283044 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+63
View File
@@ -0,0 +1,63 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
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: uuid.UUID) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _decode_user_id(token: str) -> uuid.UUID:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return uuid.UUID(payload["sub"])
except (JWTError, KeyError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
def get_current_user(
token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user_id = _decode_user_id(token)
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def get_current_user_optional(
token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User | None:
if not token:
return None
try:
user_id = _decode_user_id(token)
except HTTPException:
return None
return db.get(User, user_id)
+18
View File
@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+psycopg://aurora:aurora@localhost:5432/aurora"
redis_url: str = "redis://localhost:6379/0"
jwt_secret: str = "dev-secret-change-me"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 * 7
media_dir: str = "./media"
generation_backend: str = "mock"
default_song_duration_seconds: int = 20
cors_origins: list[str] = ["http://localhost:3000"]
settings = Settings()
+17
View File
@@ -0,0 +1,17 @@
from aurora_db.base import Base
from aurora_db.session import make_engine, make_sessionmaker
from .config import settings
engine = make_engine(settings.database_url)
SessionLocal = make_sessionmaker(engine)
__all__ = ["Base", "engine", "SessionLocal", "get_db"]
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+18
View File
@@ -0,0 +1,18 @@
import uuid
import redis
from rq import Queue
from .config import settings
redis_conn = redis.from_url(settings.redis_url)
generation_queue = Queue("generation", connection=redis_conn)
def enqueue_generation(song_id: uuid.UUID) -> None:
generation_queue.enqueue(
"worker.tasks.generate_song",
str(song_id),
job_timeout=60 * 20,
result_ttl=3600,
)
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .config import settings
from .routers import auth as auth_router
from .routers import songs as songs_router
Path(settings.media_dir).mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Aurora Music API")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/media", StaticFiles(directory=settings.media_dir), name="media")
app.include_router(auth_router.router)
app.include_router(songs_router.router)
@app.get("/health")
def health():
return {"status": "ok"}
+3
View File
@@ -0,0 +1,3 @@
from aurora_db.models import Like, Song, User
__all__ = ["User", "Song", "Like"]
View File
+42
View File
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from .. import auth, schemas
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/signup", response_model=schemas.TokenResponse, status_code=status.HTTP_201_CREATED)
def signup(payload: schemas.SignupRequest, db: Session = Depends(get_db)):
existing = db.query(User).filter(User.email == payload.email.lower()).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=payload.email.lower(),
hashed_password=auth.hash_password(payload.password),
display_name=payload.display_name,
)
db.add(user)
db.commit()
db.refresh(user)
token = auth.create_access_token(user.id)
return schemas.TokenResponse(access_token=token)
@router.post("/login", response_model=schemas.TokenResponse)
def login(payload: schemas.LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == payload.email.lower()).first()
if not user or not auth.verify_password(payload.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
token = auth.create_access_token(user.id)
return schemas.TokenResponse(access_token=token)
@router.get("/me", response_model=schemas.UserOut)
def me(current_user: User = Depends(auth.get_current_user)):
return current_user
+201
View File
@@ -0,0 +1,201 @@
import random
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import auth, schemas
from ..config import settings
from ..database import get_db
from ..jobs import enqueue_generation
from ..models import Like, Song, User
router = APIRouter(prefix="/songs", tags=["songs"])
def _serialize(song: Song, current_user: User | None, liked_ids: set[uuid.UUID] | None = None) -> schemas.SongOut:
liked = False
if current_user is not None:
if liked_ids is not None:
liked = song.id in liked_ids
return schemas.SongOut(
id=song.id,
user_id=song.user_id,
owner_display_name=song.owner.display_name if song.owner else None,
title=song.title,
prompt=song.prompt,
lyrics=song.lyrics,
mode=song.mode,
status=song.status,
error_message=song.error_message,
duration_seconds=song.duration_seconds,
cover_seed=song.cover_seed,
is_public=song.is_public,
like_count=song.like_count,
liked_by_me=liked,
audio_url=f"/media/{song.audio_path}" if song.audio_path else None,
created_at=song.created_at,
)
@router.post("", response_model=schemas.SongOut, status_code=status.HTTP_201_CREATED)
def create_song(
payload: schemas.SongCreateRequest,
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
if payload.mode == "vocals" and not payload.lyrics:
raise HTTPException(status_code=422, detail="Lyrics are required when mode is 'vocals'")
song = Song(
user_id=current_user.id,
title=payload.title,
prompt=payload.prompt,
lyrics=payload.lyrics,
mode=payload.mode,
is_public=payload.is_public,
status="pending",
cover_seed=random.randint(0, 2_147_483_647),
)
db.add(song)
db.commit()
db.refresh(song)
enqueue_generation(song.id)
return _serialize(song, current_user, set())
@router.get("/mine", response_model=schemas.SongListResponse)
def my_songs(
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
songs = (
db.query(Song)
.filter(Song.user_id == current_user.id)
.order_by(Song.created_at.desc())
.all()
)
liked_ids = _liked_song_ids(db, current_user, [s.id for s in songs])
return schemas.SongListResponse(items=[_serialize(s, current_user, liked_ids) for s in songs], total=len(songs))
@router.get("/feed", response_model=schemas.SongListResponse)
def feed(
limit: int = Query(default=30, le=100),
offset: int = Query(default=0, ge=0),
current_user: User | None = Depends(auth.get_current_user_optional),
db: Session = Depends(get_db),
):
base_query = db.query(Song).filter(Song.is_public.is_(True), Song.status == "completed")
total = base_query.with_entities(func.count(Song.id)).scalar() or 0
songs = base_query.order_by(Song.created_at.desc()).offset(offset).limit(limit).all()
liked_ids = _liked_song_ids(db, current_user, [s.id for s in songs]) if current_user else set()
return schemas.SongListResponse(items=[_serialize(s, current_user, liked_ids) for s in songs], total=total)
@router.get("/{song_id}", response_model=schemas.SongOut)
def get_song(
song_id: uuid.UUID,
current_user: User | None = Depends(auth.get_current_user_optional),
db: Session = Depends(get_db),
):
song = db.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
if not song.is_public and (current_user is None or song.user_id != current_user.id):
raise HTTPException(status_code=404, detail="Song not found")
liked_ids = _liked_song_ids(db, current_user, [song.id]) if current_user else set()
return _serialize(song, current_user, liked_ids)
@router.patch("/{song_id}", response_model=schemas.SongOut)
def update_song(
song_id: uuid.UUID,
payload: schemas.SongUpdateRequest,
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
song = db.get(Song, song_id)
if not song or song.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Song not found")
if payload.title is not None:
song.title = payload.title
if payload.is_public is not None:
song.is_public = payload.is_public
db.commit()
db.refresh(song)
liked_ids = _liked_song_ids(db, current_user, [song.id])
return _serialize(song, current_user, liked_ids)
@router.delete("/{song_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_song(
song_id: uuid.UUID,
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
song = db.get(Song, song_id)
if not song or song.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Song not found")
db.delete(song)
db.commit()
@router.post("/{song_id}/like", response_model=schemas.SongOut)
def like_song(
song_id: uuid.UUID,
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
song = db.get(Song, song_id)
if not song or not song.is_public:
raise HTTPException(status_code=404, detail="Song not found")
existing = db.query(Like).filter(Like.user_id == current_user.id, Like.song_id == song_id).first()
if not existing:
db.add(Like(user_id=current_user.id, song_id=song_id))
song.like_count += 1
db.commit()
db.refresh(song)
liked_ids = _liked_song_ids(db, current_user, [song.id])
return _serialize(song, current_user, liked_ids)
@router.delete("/{song_id}/like", response_model=schemas.SongOut)
def unlike_song(
song_id: uuid.UUID,
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
song = db.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
existing = db.query(Like).filter(Like.user_id == current_user.id, Like.song_id == song_id).first()
if existing:
db.delete(existing)
song.like_count = max(0, song.like_count - 1)
db.commit()
db.refresh(song)
liked_ids = _liked_song_ids(db, current_user, [song.id])
return _serialize(song, current_user, liked_ids)
def _liked_song_ids(db: Session, current_user: User | None, song_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not current_user or not song_ids:
return set()
rows = (
db.execute(
select(Like.song_id).where(Like.user_id == current_user.id, Like.song_id.in_(song_ids))
)
.scalars()
.all()
)
return set(rows)
+71
View File
@@ -0,0 +1,71 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=200)
display_name: str = Field(min_length=1, max_length=100)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
id: uuid.UUID
email: EmailStr
display_name: str
created_at: datetime
class Config:
from_attributes = True
class SongCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
prompt: str = Field(min_length=1, max_length=2000)
mode: Literal["instrumental", "vocals"] = "instrumental"
lyrics: str | None = Field(default=None, max_length=4000)
is_public: bool = False
class SongUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
is_public: bool | None = None
class SongOut(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
owner_display_name: str | None = None
title: str
prompt: str
lyrics: str | None
mode: str
status: str
error_message: str | None
duration_seconds: float | None
cover_seed: int
is_public: bool
like_count: int
liked_by_me: bool = False
audio_url: str | None = None
created_at: datetime
class Config:
from_attributes = True
class SongListResponse(BaseModel):
items: list[SongOut]
total: int