Initial commit: AuroraMusic monorepo
This commit is contained in:
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.
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,3 @@
|
||||
from aurora_db.models import Like, Song, User
|
||||
|
||||
__all__ = ["User", "Song", "Like"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user