Compare commits
7 Commits
2f384fb72a
...
94be8a0746
| Author | SHA1 | Date |
|---|---|---|
|
|
94be8a0746 | |
|
|
da59f3d6e3 | |
|
|
ee6069e5d5 | |
|
|
ece201f92b | |
|
|
56069a04a1 | |
|
|
f362effe7a | |
|
|
1562aee998 |
198
README.md
198
README.md
|
|
@ -4,24 +4,29 @@ AI 기반 광고 음악 생성 서비스의 백엔드 API 서버입니다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
|
- **Language**: Python 3.13
|
||||||
- **Framework**: FastAPI
|
- **Framework**: FastAPI
|
||||||
- **Database**: MySQL (asyncmy 비동기 드라이버)
|
- **Database**: MySQL (asyncmy 비동기 드라이버), Redis
|
||||||
- **ORM**: SQLAlchemy (async)
|
- **ORM**: SQLAlchemy (async)
|
||||||
|
- **Package Manager**: uv
|
||||||
- **AI Services**:
|
- **AI Services**:
|
||||||
- OpenAI ChatGPT (가사 생성, 마케팅 분석)
|
- OpenAI ChatGPT (가사 생성, 마케팅 분석)
|
||||||
- Suno AI (음악 생성)
|
- Suno AI (음악 생성)
|
||||||
|
- Creatomate (비디오 생성)
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/
|
app/
|
||||||
├── core/ # 핵심 설정 및 공통 모듈
|
├── core/ # 핵심 설정 및 공통 모듈 (logging, exceptions)
|
||||||
├── database/ # 데이터베이스 세션 및 설정
|
├── database/ # 데이터베이스 세션 및 Redis 설정
|
||||||
|
├── dependencies/ # FastAPI 의존성 주입
|
||||||
├── home/ # 홈 API (크롤링, 영상 생성 요청)
|
├── home/ # 홈 API (크롤링, 영상 생성 요청)
|
||||||
├── lyric/ # 가사 API (가사 생성)
|
├── lyric/ # 가사 API (가사 생성)
|
||||||
├── song/ # 노래 API (Suno AI 음악 생성)
|
├── song/ # 노래 API (Suno AI 음악 생성)
|
||||||
|
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
|
||||||
├── video/ # 비디오 관련 모듈
|
├── video/ # 비디오 관련 모듈
|
||||||
└── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러)
|
└── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러, 프롬프트)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 엔드포인트
|
## API 엔드포인트
|
||||||
|
|
@ -57,22 +62,84 @@ app/
|
||||||
`.env` 파일에 다음 환경 변수를 설정합니다:
|
`.env` 파일에 다음 환경 변수를 설정합니다:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 프로젝트 설정
|
# ================================
|
||||||
PROJECT_NAME=CastAD
|
# 프로젝트 기본 정보
|
||||||
PROJECT_DOMAIN=localhost:8000
|
# ================================
|
||||||
DEBUG=True
|
PROJECT_NAME=CastAD # 프로젝트 이름
|
||||||
|
PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포트)
|
||||||
|
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
||||||
|
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||||
|
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
||||||
|
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||||
|
|
||||||
|
# ================================
|
||||||
# MySQL 설정
|
# MySQL 설정
|
||||||
MYSQL_HOST=localhost
|
# ================================
|
||||||
MYSQL_PORT=3306
|
MYSQL_HOST=localhost # MySQL 호스트 주소
|
||||||
MYSQL_USER=your_user
|
MYSQL_PORT=3306 # MySQL 포트 번호
|
||||||
MYSQL_PASSWORD=your_password
|
MYSQL_USER=castad-admin # MySQL 사용자명
|
||||||
MYSQL_DB=castad
|
MYSQL_PASSWORD=o2o1324 # MySQL 비밀번호
|
||||||
|
MYSQL_DB=castad # 사용할 데이터베이스명
|
||||||
|
|
||||||
# API Keys
|
# ================================
|
||||||
CHATGPT_API_KEY=your_openai_api_key
|
# Redis 설정
|
||||||
SUNO_API_KEY=your_suno_api_key
|
# ================================
|
||||||
SUNO_CALLBACK_URL=https://your-domain.com/api/suno/callback
|
REDIS_HOST=localhost # Redis 호스트 주소
|
||||||
|
REDIS_PORT=6379 # Redis 포트 번호
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# CORS 설정
|
||||||
|
# ================================
|
||||||
|
CORS_ALLOW_ORIGINS='["*"]' # 허용할 Origin 목록 (JSON 배열 형식)
|
||||||
|
CORS_ALLOW_CREDENTIALS=True # 자격 증명(쿠키 등) 허용 여부
|
||||||
|
CORS_ALLOW_METHODS='["*"]' # 허용할 HTTP 메서드 (JSON 배열 형식)
|
||||||
|
CORS_ALLOW_HEADERS='["*"]' # 허용할 HTTP 헤더 (JSON 배열 형식)
|
||||||
|
CORS_MAX_AGE=600 # Preflight 요청 캐시 시간 (초)
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Azure Blob Storage 설정
|
||||||
|
# ================================
|
||||||
|
AZURE_BLOB_SAS_TOKEN=your_sas_token # Azure Blob Storage SAS 토큰
|
||||||
|
AZURE_BLOB_BASE_URL=https://... # Azure Blob Storage 기본 URL
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Creatomate 템플릿 설정
|
||||||
|
# ================================
|
||||||
|
TEMPLATE_ID_VERTICAL=your_template_id # 세로형(9:16) 비디오 템플릿 ID
|
||||||
|
TEMPLATE_DURATION_VERTICAL=60.0 # 세로형 비디오 기본 길이 (초)
|
||||||
|
TEMPLATE_ID_HORIZONTAL=your_template_id # 가로형(16:9) 비디오 템플릿 ID
|
||||||
|
TEMPLATE_DURATION_HORIZONTAL=20.0 # 가로형 비디오 기본 길이 (초)
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# JWT 토큰 설정
|
||||||
|
# ================================
|
||||||
|
JWT_SECRET=your_secret_key # JWT 서명용 비밀키 (랜덤 문자열 권장)
|
||||||
|
JWT_ALGORITHM=HS256 # JWT 알고리즘 (기본: HS256)
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 # Access Token 만료 시간 (분)
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 # Refresh Token 만료 시간 (일)
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# 프롬프트 설정
|
||||||
|
# ================================
|
||||||
|
PROMPT_FOLDER_ROOT=./app/utils/prompts # 프롬프트 파일 루트 디렉토리
|
||||||
|
MARKETING_PROMPT_NAME=marketing_prompt # 마케팅 분석용 프롬프트 파일명
|
||||||
|
SUMMARIZE_PROMPT_NAME=summarize_prompt # 요약용 프롬프트 파일명
|
||||||
|
LYLIC_PROMPT_NAME=lyric_prompt # 가사 생성용 프롬프트 파일명
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# 로그 설정
|
||||||
|
# ================================
|
||||||
|
LOG_CONSOLE_ENABLED=True # 콘솔 로그 출력 여부
|
||||||
|
LOG_FILE_ENABLED=True # 파일 로그 저장 여부
|
||||||
|
LOG_LEVEL=DEBUG # 전체 로그 레벨 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
LOG_CONSOLE_LEVEL=DEBUG # 콘솔 출력 로그 레벨
|
||||||
|
LOG_FILE_LEVEL=DEBUG # 파일 저장 로그 레벨
|
||||||
|
LOG_MAX_SIZE_MB=15 # 로그 파일 최대 크기 (MB)
|
||||||
|
LOG_BACKUP_COUNT=30 # 로그 백업 파일 보관 개수
|
||||||
|
LOG_DIR=logs # 로그 저장 디렉토리 경로
|
||||||
|
# - 절대 경로: 해당 경로 사용
|
||||||
|
# - 상대 경로: 프로젝트 루트 기준
|
||||||
|
# - /www/log/uvicorn 존재 시: 자동으로 해당 경로 사용 (운영)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 실행 방법
|
## 실행 방법
|
||||||
|
|
@ -110,3 +177,100 @@ fastapi run main.py
|
||||||
## API 문서
|
## API 문서
|
||||||
|
|
||||||
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
## 서버 아키텍처
|
||||||
|
|
||||||
|
### 전체 시스템 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client (Web/Mobile) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI Application │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Auth API │ │ Home API │ │ Lyric API │ │ Song/Video API │ │
|
||||||
|
│ │ (카카오) │ │ (크롤링) │ │ (가사생성) │ │ (음악/영상 생성) │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
||||||
|
└─────────┼────────────────┼────────────────┼─────────────────────┼───────────┘
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐
|
||||||
|
│ Kakao OAuth │ │ Naver Maps │ │ ChatGPT │ │ External AI Services │
|
||||||
|
│ (로그인) │ │ (크롤링) │ │ (OpenAI) │ │ ┌───────┐ ┌──────────┐ │
|
||||||
|
└─────────────────┘ └─────────────┘ └─────────────┘ │ │ Suno │ │Creatomate│ │
|
||||||
|
│ │ (음악) │ │ (영상) │ │
|
||||||
|
│ └───────┘ └──────────┘ │
|
||||||
|
└─────────────────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Data Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ MySQL │ │ Azure Blob Storage │ │
|
||||||
|
│ │ (메인 DB) │ │ (미디어 저장소) │ │
|
||||||
|
│ └─────────────┘ └─────────────────────┘ │
|
||||||
|
│ ┌─────────────┐ │
|
||||||
|
│ │ Redis │ │
|
||||||
|
│ │ (캐시/세션) │ │
|
||||||
|
│ └─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 광고 콘텐츠 생성 플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ 1. 입력 │───▶│ 2. 크롤링 │───▶│ 3. 가사 │───▶│ 4. 음악 │───▶│ 5. 영상 │
|
||||||
|
│ │ │ │ │ 생성 │ │ 생성 │ │ 생성 │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||||
|
│ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼
|
||||||
|
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│장소 URL │ │Naver Maps│ │ ChatGPT │ │ Suno AI │ │Creatomate│
|
||||||
|
│or 이미지 │ │ 크롤러 │ │ API │ │ API │ │ API │
|
||||||
|
└────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│장소 정보 │ │ 광고 가사 │ │ MP3 │ │ 광고 영상 │
|
||||||
|
│이미지 수집 │ │ 텍스트 │ │ 파일 │ │ 파일 │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인증 플로우 (카카오 OAuth)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────┐ ┌────────────┐ ┌───────────┐ ┌────────────┐
|
||||||
|
│ Client │ │ CastAD │ │ Kakao │ │ MySQL │
|
||||||
|
│ │ │ Backend │ │ OAuth │ │ │
|
||||||
|
└───┬────┘ └─────┬──────┘ └─────┬─────┘ └─────┬──────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ 1. 로그인 요청 │ │ │
|
||||||
|
│───────────────▶│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 2. 카카오 URL │ │ │
|
||||||
|
│◀───────────────│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 3. 카카오 로그인 │ │ │
|
||||||
|
│────────────────────────────────▶ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 4. 인가 코드 │ │ │
|
||||||
|
│◀──────────────────────────────── │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 5. 콜백 (code) │ │ │
|
||||||
|
│───────────────▶│ 6. 토큰 요청 │ │
|
||||||
|
│ │─────────────────▶│ │
|
||||||
|
│ │ 7. Access Token │ │
|
||||||
|
│ │◀─────────────────│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 8. 사용자 저장/조회 │ │
|
||||||
|
│ │─────────────────────────────────▶ │
|
||||||
|
│ │◀───────────────────────────────── │
|
||||||
|
│ │ │ │
|
||||||
|
│ 9. JWT 토큰 발급 │ │ │
|
||||||
|
│◀───────────────│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""
|
|
||||||
카카오 로그인 API 라우터
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.auth.dependencies import get_current_user_optional
|
|
||||||
from app.auth.models import User
|
|
||||||
from app.auth.schemas import AuthStatusResponse, TokenResponse, UserResponse
|
|
||||||
from app.auth.services.jwt import create_access_token
|
|
||||||
from app.auth.services.kakao import kakao_client
|
|
||||||
from app.database.session import get_session
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Auth"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth/kakao/login")
|
|
||||||
async def kakao_login():
|
|
||||||
"""
|
|
||||||
카카오 로그인 페이지로 리다이렉트
|
|
||||||
|
|
||||||
프론트엔드에서 이 URL을 호출하면 카카오 로그인 페이지로 이동합니다.
|
|
||||||
"""
|
|
||||||
auth_url = kakao_client.get_authorization_url()
|
|
||||||
return RedirectResponse(url=auth_url)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/kakao/callback", response_model=TokenResponse)
|
|
||||||
async def kakao_callback(
|
|
||||||
code: str,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
카카오 로그인 콜백
|
|
||||||
|
|
||||||
카카오 로그인 성공 후 인가 코드를 받아 JWT 토큰을 발급합니다.
|
|
||||||
"""
|
|
||||||
# 1. 인가 코드로 액세스 토큰 획득
|
|
||||||
token_data = await kakao_client.get_access_token(code)
|
|
||||||
|
|
||||||
if "error" in token_data:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"카카오 토큰 발급 실패: {token_data.get('error_description', token_data.get('error'))}",
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token = token_data.get("access_token")
|
|
||||||
if not access_token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="액세스 토큰을 받지 못했습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 액세스 토큰으로 사용자 정보 조회
|
|
||||||
user_info = await kakao_client.get_user_info(access_token)
|
|
||||||
|
|
||||||
if "id" not in user_info:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="사용자 정보를 가져오지 못했습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
kakao_id = str(user_info["id"])
|
|
||||||
kakao_account = user_info.get("kakao_account", {})
|
|
||||||
profile = kakao_account.get("profile", {})
|
|
||||||
|
|
||||||
nickname = profile.get("nickname")
|
|
||||||
email = kakao_account.get("email")
|
|
||||||
profile_image = profile.get("profile_image_url")
|
|
||||||
|
|
||||||
# 3. 기존 회원 확인 또는 신규 가입
|
|
||||||
result = await session.execute(select(User).where(User.kakao_id == kakao_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
# 신규 가입
|
|
||||||
user = User(
|
|
||||||
kakao_id=kakao_id,
|
|
||||||
nickname=nickname,
|
|
||||||
email=email,
|
|
||||||
profile_image=profile_image,
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user)
|
|
||||||
else:
|
|
||||||
# 기존 회원 - 마지막 로그인 시간 및 정보 업데이트
|
|
||||||
user.nickname = nickname
|
|
||||||
user.email = email
|
|
||||||
user.profile_image = profile_image
|
|
||||||
user.last_login_at = datetime.now(timezone.utc)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user)
|
|
||||||
|
|
||||||
# 4. JWT 토큰 발급
|
|
||||||
jwt_token = create_access_token({"sub": str(user.id)})
|
|
||||||
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=jwt_token,
|
|
||||||
user=UserResponse.model_validate(user),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth/me", response_model=AuthStatusResponse)
|
|
||||||
async def get_auth_status(
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
현재 인증 상태 확인
|
|
||||||
|
|
||||||
프론트엔드에서 로그인 상태를 확인할 때 사용합니다.
|
|
||||||
토큰이 유효하면 사용자 정보를, 아니면 is_authenticated=False를 반환합니다.
|
|
||||||
"""
|
|
||||||
if current_user is None:
|
|
||||||
return AuthStatusResponse(is_authenticated=False)
|
|
||||||
|
|
||||||
return AuthStatusResponse(
|
|
||||||
is_authenticated=True,
|
|
||||||
user=UserResponse.model_validate(current_user),
|
|
||||||
)
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"""
|
|
||||||
Auth 모듈 의존성 주입
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.auth.models import User
|
|
||||||
from app.auth.services.jwt import decode_access_token
|
|
||||||
from app.database.session import get_session
|
|
||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> User:
|
|
||||||
"""현재 로그인한 사용자 반환 (필수 인증)"""
|
|
||||||
if credentials is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="인증이 필요합니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = decode_access_token(credentials.credentials)
|
|
||||||
if payload is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="유효하지 않은 토큰입니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="유효하지 않은 토큰입니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await session.execute(select(User).where(User.id == int(user_id)))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="사용자를 찾을 수 없습니다",
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_optional(
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> User | None:
|
|
||||||
"""현재 로그인한 사용자 반환 (선택적 인증)"""
|
|
||||||
if credentials is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
payload = decode_access_token(credentials.credentials)
|
|
||||||
if payload is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
user_id = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
result = await session.execute(select(User).where(User.id == int(user_id)))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"""
|
|
||||||
Auth 모듈 SQLAlchemy 모델 정의
|
|
||||||
|
|
||||||
카카오 로그인 사용자 정보를 저장합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, Integer, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
"""
|
|
||||||
사용자 테이블 (카카오 로그인)
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: 고유 식별자 (자동 증가)
|
|
||||||
kakao_id: 카카오 고유 ID
|
|
||||||
nickname: 카카오 닉네임
|
|
||||||
email: 카카오 이메일 (선택)
|
|
||||||
profile_image: 프로필 이미지 URL
|
|
||||||
created_at: 가입 일시
|
|
||||||
last_login_at: 마지막 로그인 일시
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "user"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_user_kakao_id", "kakao_id"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
primary_key=True,
|
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
kakao_id: Mapped[str] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
unique=True,
|
|
||||||
comment="카카오 고유 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
nickname: Mapped[str | None] = mapped_column(
|
|
||||||
String(100),
|
|
||||||
nullable=True,
|
|
||||||
comment="카카오 닉네임",
|
|
||||||
)
|
|
||||||
|
|
||||||
email: Mapped[str | None] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=True,
|
|
||||||
comment="카카오 이메일",
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_image: Mapped[str | None] = mapped_column(
|
|
||||||
String(500),
|
|
||||||
nullable=True,
|
|
||||||
comment="프로필 이미지 URL",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="가입 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
last_login_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
onupdate=func.now(),
|
|
||||||
comment="마지막 로그인 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<User(id={self.id}, kakao_id='{self.kakao_id}', nickname='{self.nickname}')>"
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"""
|
|
||||||
Auth 모듈 Pydantic 스키마
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
|
||||||
"""사용자 정보 응답"""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
kakao_id: str
|
|
||||||
nickname: str | None
|
|
||||||
email: str | None
|
|
||||||
profile_image: str | None
|
|
||||||
created_at: datetime
|
|
||||||
last_login_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
"""토큰 응답"""
|
|
||||||
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
user: UserResponse
|
|
||||||
|
|
||||||
|
|
||||||
class AuthStatusResponse(BaseModel):
|
|
||||||
"""인증 상태 응답 (프론트엔드용)"""
|
|
||||||
|
|
||||||
is_authenticated: bool
|
|
||||||
user: UserResponse | None = None
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"""
|
|
||||||
JWT 토큰 유틸리티
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
|
|
||||||
from config import security_settings
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict) -> str:
|
|
||||||
"""JWT 액세스 토큰 생성"""
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=security_settings.JWT_EXPIRE_MINUTES)
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
return jwt.encode(
|
|
||||||
to_encode,
|
|
||||||
security_settings.JWT_SECRET,
|
|
||||||
algorithm=security_settings.JWT_ALGORITHM,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_access_token(token: str) -> dict | None:
|
|
||||||
"""JWT 액세스 토큰 디코딩"""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token,
|
|
||||||
security_settings.JWT_SECRET,
|
|
||||||
algorithms=[security_settings.JWT_ALGORITHM],
|
|
||||||
)
|
|
||||||
return payload
|
|
||||||
except JWTError:
|
|
||||||
return None
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
"""
|
|
||||||
카카오 OAuth API 클라이언트
|
|
||||||
"""
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from config import kakao_settings
|
|
||||||
|
|
||||||
|
|
||||||
class KakaoOAuthClient:
|
|
||||||
"""카카오 OAuth API 클라이언트"""
|
|
||||||
|
|
||||||
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
|
|
||||||
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
|
||||||
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.client_id = kakao_settings.KAKAO_CLIENT_ID
|
|
||||||
self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET
|
|
||||||
self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI
|
|
||||||
|
|
||||||
def get_authorization_url(self) -> str:
|
|
||||||
"""카카오 로그인 페이지 URL 반환"""
|
|
||||||
return (
|
|
||||||
f"{self.AUTH_URL}"
|
|
||||||
f"?client_id={self.client_id}"
|
|
||||||
f"&redirect_uri={self.redirect_uri}"
|
|
||||||
f"&response_type=code"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_access_token(self, code: str) -> dict:
|
|
||||||
"""인가 코드로 액세스 토큰 획득"""
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
data = {
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": self.client_id,
|
|
||||||
"client_secret": self.client_secret,
|
|
||||||
"redirect_uri": self.redirect_uri,
|
|
||||||
"code": code,
|
|
||||||
}
|
|
||||||
print(f"[kakao] Token request - client_id: {self.client_id}, redirect_uri: {self.redirect_uri}")
|
|
||||||
async with session.post(self.TOKEN_URL, data=data) as response:
|
|
||||||
result = await response.json()
|
|
||||||
print(f"[kakao] Token response: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def get_user_info(self, access_token: str) -> dict:
|
|
||||||
"""액세스 토큰으로 사용자 정보 조회"""
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
|
||||||
async with session.get(self.USER_INFO_URL, headers=headers) as response:
|
|
||||||
return await response.json()
|
|
||||||
|
|
||||||
|
|
||||||
kakao_client = KakaoOAuthClient()
|
|
||||||
|
|
@ -379,7 +379,7 @@ print(response.json())
|
||||||
200: {"description": "이미지 업로드 성공"},
|
200: {"description": "이미지 업로드 성공"},
|
||||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||||
},
|
},
|
||||||
tags=["image"],
|
tags=["Image"],
|
||||||
)
|
)
|
||||||
async def upload_images(
|
async def upload_images(
|
||||||
images_json: Optional[str] = Form(
|
images_json: Optional[str] = Form(
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ import traceback as tb
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("lyric")
|
logger = get_logger("lyric")
|
||||||
|
|
||||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
router = APIRouter(prefix="/lyric", tags=["Lyric"])
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -398,7 +398,7 @@ async def get_lyric_status(
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"s",
|
"s/",
|
||||||
summary="가사 목록 조회 (페이지네이션)",
|
summary="가사 목록 조회 (페이지네이션)",
|
||||||
description="""
|
description="""
|
||||||
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
생성 완료된 가사를 페이지네이션으로 조회합니다.
|
||||||
|
|
@ -418,9 +418,9 @@ async def get_lyric_status(
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
```
|
```
|
||||||
GET /lyrics # 기본 조회 (1페이지, 20개)
|
GET /lyrics/ # 기본 조회 (1페이지, 20개)
|
||||||
GET /lyrics?page=2 # 2페이지 조회
|
GET /lyrics/?page=2 # 2페이지 조회
|
||||||
GET /lyrics?page=1&page_size=50 # 50개씩 조회
|
GET /lyrics/?page=1&page_size=50 # 50개씩 조회
|
||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Song API Router
|
||||||
|
|
||||||
엔드포인트 목록:
|
엔드포인트 목록:
|
||||||
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
|
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
|
||||||
- GET /song/status/{suno_task_id}: Suno API 노래 생성 상태 조회
|
- GET /song/status/{song_id}: Suno API 노래 생성 상태 조회
|
||||||
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
|
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
|
|
@ -13,7 +13,7 @@ Song API Router
|
||||||
app.include_router(router, prefix="/api/v1")
|
app.include_router(router, prefix="/api/v1")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -25,6 +25,8 @@ from app.dependencies.pagination import (
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
|
from app.song.models import SongTimestamp
|
||||||
|
|
||||||
from app.song.schemas.song_schema import (
|
from app.song.schemas.song_schema import (
|
||||||
DownloadSongResponse,
|
DownloadSongResponse,
|
||||||
GenerateSongRequest,
|
GenerateSongRequest,
|
||||||
|
|
@ -32,6 +34,7 @@ from app.song.schemas.song_schema import (
|
||||||
PollingSongResponse,
|
PollingSongResponse,
|
||||||
SongListItem,
|
SongListItem,
|
||||||
)
|
)
|
||||||
|
from app.song.worker.song_task import download_and_upload_song_by_suno_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.utils.suno import SunoService
|
from app.utils.suno import SunoService
|
||||||
|
|
@ -58,7 +61,7 @@ Suno API를 통해 노래 생성을 요청합니다.
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 요청 성공 여부
|
- **success**: 요청 성공 여부
|
||||||
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
|
- **task_id**: 내부 작업 ID (Project/Lyric task_id)
|
||||||
- **suno_task_id**: Suno API 작업 ID (상태 조회에 사용)
|
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
|
||||||
- **message**: 응답 메시지
|
- **message**: 응답 메시지
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
|
|
@ -73,7 +76,7 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 1분 이내 길이입니다.
|
- 생성되는 노래는 약 1분 이내 길이입니다.
|
||||||
- suno_task_id를 사용하여 /status/{suno_task_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
response_model=GenerateSongResponse,
|
response_model=GenerateSongResponse,
|
||||||
|
|
@ -193,7 +196,7 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
song_id=None,
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
@ -237,7 +240,7 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=None,
|
song_id=None,
|
||||||
message="노래 생성 요청에 실패했습니다.",
|
message="노래 생성 요청에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
@ -273,8 +276,8 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=True,
|
success=True,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=suno_task_id,
|
song_id=suno_task_id,
|
||||||
message="노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
message="노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -286,87 +289,120 @@ async def generate_song(
|
||||||
return GenerateSongResponse(
|
return GenerateSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
suno_task_id=suno_task_id,
|
song_id=suno_task_id,
|
||||||
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
message="노래 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/status/{suno_task_id}",
|
"/status/{song_id}",
|
||||||
summary="노래 생성 상태 조회",
|
summary="노래 생성 상태 조회 (Suno API)",
|
||||||
description="""
|
description="""
|
||||||
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
|
||||||
|
|
||||||
## 경로 파라미터
|
## 경로 파라미터
|
||||||
- **suno_task_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
- **song_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 조회 성공 여부
|
- **success**: 조회 성공 여부
|
||||||
- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
|
- **status**: Suno API 작업 상태
|
||||||
- **message**: 상태 메시지
|
- **message**: 상태 메시지
|
||||||
- **clips**: 생성된 노래 클립 목록 (완료 시)
|
|
||||||
- **raw_response**: Suno API 원본 응답
|
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
```
|
```
|
||||||
GET /song/status/abc123...
|
GET /song/status/abc123...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 상태 값
|
## 상태 값 (Suno API 응답)
|
||||||
- **PENDING**: 대기 중
|
- **PENDING**: Suno API 대기 중
|
||||||
- **processing**: 생성 중
|
- **processing**: Suno API에서 노래 생성 중
|
||||||
- **SUCCESS**: 생성 완료
|
- **SUCCESS**: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
|
||||||
- **failed**: 생성 실패
|
- **TEXT_SUCCESS**: Suno API 노래 생성 완료
|
||||||
|
- **failed**: Suno API 노래 생성 실패
|
||||||
|
- **error**: API 조회 오류
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 스트림 URL: 30-40초 내 생성
|
- 이 엔드포인트는 Suno API의 상태를 반환합니다
|
||||||
- 다운로드 URL: 2-3분 내 생성
|
- SUCCESS 응답 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드가 시작됩니다
|
||||||
- SUCCESS 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드 → Song 테이블 업데이트 진행
|
- 최종 완료 상태는 `/song/download/{task_id}` 엔드포인트에서 확인하세요
|
||||||
- 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3)
|
- Song 테이블 상태: processing → uploading → completed
|
||||||
- Song 테이블의 song_result_url에 Blob URL이 저장됩니다
|
|
||||||
""",
|
""",
|
||||||
response_model=PollingSongResponse,
|
response_model=PollingSongResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "상태 조회 성공"},
|
200: {"description": "상태 조회 성공"},
|
||||||
500: {"description": "상태 조회 실패"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_song_status(
|
async def get_song_status(
|
||||||
suno_task_id: str,
|
suno_task_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PollingSongResponse:
|
) -> PollingSongResponse:
|
||||||
"""suno_task_id로 노래 생성 작업의 상태를 조회합니다.
|
"""song_id로 노래 생성 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
|
||||||
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로,
|
||||||
song_result_url을 Blob URL로 업데이트합니다.
|
song_result_url을 Blob URL로 업데이트합니다.
|
||||||
"""
|
"""
|
||||||
logger.info(f"[get_song_status] START - suno_task_id: {suno_task_id}")
|
logger.info(f"[get_song_status] START - song_id: {suno_task_id}")
|
||||||
try:
|
try:
|
||||||
suno_service = SunoService()
|
suno_service = SunoService()
|
||||||
result = await suno_service.get_task_status(suno_task_id)
|
result = await suno_service.get_task_status(suno_task_id)
|
||||||
|
logger.debug(f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}")
|
||||||
parsed_response = suno_service.parse_status_response(result)
|
parsed_response = suno_service.parse_status_response(result)
|
||||||
logger.info(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
|
logger.info(f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}")
|
||||||
|
|
||||||
# SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
|
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
|
||||||
if parsed_response.status == "SUCCESS" and parsed_response.clips:
|
if parsed_response.status == "SUCCESS" and result:
|
||||||
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
# result에서 직접 clips 데이터 추출
|
||||||
first_clip = parsed_response.clips[0]
|
data = result.get("data", {})
|
||||||
audio_url = first_clip.audio_url
|
response_data = data.get("response") or {}
|
||||||
clip_duration = first_clip.duration
|
clips_data = response_data.get("sunoData") or []
|
||||||
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}")
|
|
||||||
|
|
||||||
if audio_url:
|
if clips_data:
|
||||||
# suno_task_id로 Song 조회
|
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
||||||
song_result = await session.execute(
|
first_clip = clips_data[0]
|
||||||
select(Song)
|
audio_url = first_clip.get("audioUrl")
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
clip_duration = first_clip.get("duration")
|
||||||
.order_by(Song.created_at.desc())
|
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}")
|
||||||
|
|
||||||
|
if audio_url:
|
||||||
|
# song_id로 Song 조회
|
||||||
|
song_result = await session.execute(
|
||||||
|
select(Song)
|
||||||
|
.where(Song.suno_task_id == suno_task_id)
|
||||||
|
.order_by(Song.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
song = song_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
suno_audio_id = first_clip.id
|
||||||
|
word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id)
|
||||||
|
lyric_result = await session.execute(
|
||||||
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == song.task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
song = song_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
gt_lyric = lyric.lyric_result
|
||||||
|
lyric_line_list = gt_lyric.split("\n")
|
||||||
|
sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"]
|
||||||
|
|
||||||
|
timestamped_lyrics = await suno_service.align_lyrics(word_data, sentences)
|
||||||
|
# TODO : DB upload timestamped_lyrics
|
||||||
|
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||||
|
song_timestamp = SongTimestamp(
|
||||||
|
suno_audio_id = suno_audio_id,
|
||||||
|
order_idx = order_idx,
|
||||||
|
lyric_line = timestamped_lyric["text"],
|
||||||
|
start_time = timestamped_lyric["start_sec"],
|
||||||
|
end_time = timestamped_lyric["end_sec"]
|
||||||
|
)
|
||||||
|
session.add(song_timestamp)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
if song and song.status != "completed":
|
if song and song.status != "completed":
|
||||||
# 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장
|
# 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장
|
||||||
|
|
@ -379,26 +415,54 @@ async def get_song_status(
|
||||||
elif song and song.status == "completed":
|
elif song and song.status == "completed":
|
||||||
logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
|
logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
|
||||||
|
|
||||||
logger.info(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
|
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||||
|
if song and song.status == "processing":
|
||||||
|
# store_name 조회
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project).where(Project.id == song.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
store_name = project.store_name if project else "song"
|
||||||
|
|
||||||
|
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||||
|
song.status = "uploading"
|
||||||
|
song.suno_audio_id = first_clip.get('id')
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}")
|
||||||
|
|
||||||
|
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
|
||||||
|
background_tasks.add_task(
|
||||||
|
download_and_upload_song_by_suno_task_id,
|
||||||
|
suno_task_id=suno_task_id,
|
||||||
|
audio_url=audio_url,
|
||||||
|
store_name=store_name,
|
||||||
|
duration=clip_duration,
|
||||||
|
)
|
||||||
|
logger.info(f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}")
|
||||||
|
|
||||||
|
elif song and song.status == "uploading":
|
||||||
|
logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}")
|
||||||
|
elif song and song.status == "completed":
|
||||||
|
logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}")
|
||||||
|
|
||||||
|
logger.info(f"[get_song_status] SUCCESS - song_id: {suno_task_id}")
|
||||||
return parsed_response
|
return parsed_response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
logger.error(f"[get_song_status] EXCEPTION - song_id: {song_id}, error: {e}")
|
||||||
return PollingSongResponse(
|
return PollingSongResponse(
|
||||||
success=False,
|
success=False,
|
||||||
status="error",
|
status="error",
|
||||||
message="상태 조회에 실패했습니다.",
|
message="상태 조회에 실패했습니다.",
|
||||||
clips=None,
|
|
||||||
raw_response=None,
|
|
||||||
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/download/{task_id}",
|
"/download/{task_id}",
|
||||||
summary="노래 생성 URL 조회",
|
summary="노래 다운로드 상태 조회 (DB Polling)",
|
||||||
description="""
|
description="""
|
||||||
task_id를 기반으로 Song 테이블의 상태를 조회하고,
|
task_id를 기반으로 Song 테이블의 상태를 조회하고,
|
||||||
completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
||||||
|
|
@ -408,31 +472,38 @@ completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **success**: 조회 성공 여부
|
- **success**: 조회 성공 여부
|
||||||
- **status**: 처리 상태 (processing, completed, failed, not_found)
|
- **status**: DB 처리 상태 (processing, uploading, completed, failed, not_found, error)
|
||||||
- **message**: 응답 메시지
|
- **message**: 응답 메시지
|
||||||
- **store_name**: 업체명
|
- **store_name**: 업체명 (completed 시)
|
||||||
- **region**: 지역명
|
- **region**: 지역명 (completed 시)
|
||||||
- **detail_region_info**: 상세 지역 정보
|
- **detail_region_info**: 상세 지역 정보 (completed 시)
|
||||||
- **task_id**: 작업 고유 식별자
|
- **task_id**: 작업 고유 식별자
|
||||||
- **language**: 언어
|
- **language**: 언어 (completed 시)
|
||||||
- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL)
|
- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL)
|
||||||
- **created_at**: 생성 일시
|
- **created_at**: 생성 일시 (completed 시)
|
||||||
|
- **error_message**: 에러 메시지 (실패 시)
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
```
|
```
|
||||||
GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
|
GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 상태 값 (DB 상태)
|
||||||
|
- **processing**: Suno API에서 노래 생성 중
|
||||||
|
- **uploading**: MP3 다운로드 및 Azure Blob 업로드 중
|
||||||
|
- **completed**: 모든 작업 완료, Blob URL 사용 가능
|
||||||
|
- **failed**: 노래 생성 또는 업로드 실패
|
||||||
|
- **not_found**: task_id에 해당하는 Song 없음
|
||||||
|
- **error**: 조회 중 오류 발생
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- processing 상태인 경우 song_result_url은 null입니다.
|
- 이 엔드포인트는 DB의 Song 테이블 상태를 반환합니다
|
||||||
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다.
|
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다
|
||||||
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
|
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
|
||||||
""",
|
""",
|
||||||
response_model=DownloadSongResponse,
|
response_model=DownloadSongResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "조회 성공"},
|
200: {"description": "조회 성공 (모든 상태에서 200 반환)"},
|
||||||
404: {"description": "Song을 찾을 수 없음"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def download_song(
|
async def download_song(
|
||||||
|
|
@ -472,6 +543,16 @@ async def download_song(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# uploading 상태인 경우
|
||||||
|
if song.status == "uploading":
|
||||||
|
logger.info(f"[download_song] UPLOADING - task_id: {task_id}")
|
||||||
|
return DownloadSongResponse(
|
||||||
|
success=True,
|
||||||
|
status="uploading",
|
||||||
|
message="노래 파일을 업로드 중입니다.",
|
||||||
|
task_id=task_id,
|
||||||
|
)
|
||||||
|
|
||||||
# failed 상태인 경우
|
# failed 상태인 경우
|
||||||
if song.status == "failed":
|
if song.status == "failed":
|
||||||
logger.warning(f"[download_song] FAILED - task_id: {task_id}")
|
logger.warning(f"[download_song] FAILED - task_id: {task_id}")
|
||||||
|
|
@ -544,7 +625,6 @@ GET /songs/?page=1&page_size=10
|
||||||
response_model=PaginatedResponse[SongListItem],
|
response_model=PaginatedResponse[SongListItem],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "노래 목록 조회 성공"},
|
200: {"description": "노래 목록 조회 성공"},
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_songs(
|
async def get_songs(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
@ -25,7 +25,7 @@ class Song(Base):
|
||||||
lyric_id: 연결된 Lyric의 id (외래키)
|
lyric_id: 연결된 Lyric의 id (외래키)
|
||||||
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
|
||||||
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
suno_task_id: Suno API 작업 고유 식별자 (선택)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (processing, uploading, completed, failed)
|
||||||
song_prompt: 노래 생성에 사용된 프롬프트
|
song_prompt: 노래 생성에 사용된 프롬프트
|
||||||
song_result_url: 생성 결과 URL (선택)
|
song_result_url: 생성 결과 URL (선택)
|
||||||
language: 출력 언어
|
language: 출력 언어
|
||||||
|
|
@ -82,10 +82,16 @@ class Song(Base):
|
||||||
comment="Suno API 작업 고유 식별자",
|
comment="Suno API 작업 고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suno_audio_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=True,
|
||||||
|
comment="Suno 첫번째 노래의 고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="처리 상태 (processing, completed, failed)",
|
comment="처리 상태 (processing, uploading, completed, failed)",
|
||||||
)
|
)
|
||||||
|
|
||||||
song_prompt: Mapped[str] = mapped_column(
|
song_prompt: Mapped[str] = mapped_column(
|
||||||
|
|
@ -150,3 +156,92 @@ class Song(Base):
|
||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SongTimestamp(Base):
|
||||||
|
"""
|
||||||
|
노래 타임스탬프 테이블
|
||||||
|
|
||||||
|
노래의 가사별 시작/종료 시간 정보를 저장합니다.
|
||||||
|
Suno API에서 반환된 타임스탬프 데이터를 기반으로 생성됩니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: 고유 식별자 (자동 증가)
|
||||||
|
suno_audio_id: 가사의 원본 오디오 ID
|
||||||
|
order_idx: 오디오 내에서 가사의 순서
|
||||||
|
lyric_line: 가사 한 줄의 내용
|
||||||
|
start_time: 가사 시작 시점 (초)
|
||||||
|
end_time: 가사 종료 시점 (초)
|
||||||
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "song_timestamp"
|
||||||
|
__table_args__ = (
|
||||||
|
{
|
||||||
|
"mysql_engine": "InnoDB",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
autoincrement=True,
|
||||||
|
comment="고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
|
suno_audio_id: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="가사의 원본 오디오 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
order_idx: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="오디오 내에서 가사의 순서",
|
||||||
|
)
|
||||||
|
|
||||||
|
lyric_line: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
comment="가사 한 줄의 내용",
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
comment="가사 시작 시점 (초)",
|
||||||
|
)
|
||||||
|
|
||||||
|
end_time: Mapped[float] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=False,
|
||||||
|
comment="가사 종료 시점 (초)",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
comment="생성 일시",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "None"
|
||||||
|
return (value[:max_len] + "...") if len(value) > max_len else value
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"<SongTimestamp("
|
||||||
|
f"id={self.id}, "
|
||||||
|
f"suno_audio_id='{truncate(self.suno_audio_id)}', "
|
||||||
|
f"order_idx={self.order_idx}, "
|
||||||
|
f"start_time={self.start_time}, "
|
||||||
|
f"end_time={self.end_time}"
|
||||||
|
f")>"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -64,8 +64,8 @@ class GenerateSongResponse(BaseModel):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": "abc123...",
|
"song_id": "abc123...",
|
||||||
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
|
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ class GenerateSongResponse(BaseModel):
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"suno_task_id": null,
|
"song_id": null,
|
||||||
"message": "노래 생성 요청에 실패했습니다.",
|
"message": "노래 생성 요청에 실패했습니다.",
|
||||||
"error_message": "Suno API connection error"
|
"error_message": "Suno API connection error"
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +81,7 @@ class GenerateSongResponse(BaseModel):
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
||||||
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
song_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ class PollingSongRequest(BaseModel):
|
||||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
|
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
|
||||||
|
|
||||||
Example Request:
|
Example Request:
|
||||||
{
|
{
|
||||||
|
|
@ -114,32 +114,39 @@ class SongClipData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PollingSongResponse(BaseModel):
|
class PollingSongResponse(BaseModel):
|
||||||
"""노래 생성 상태 조회 응답 스키마
|
"""노래 생성 상태 조회 응답 스키마 (Suno API)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /song/status/{suno_task_id}
|
GET /song/status/{song_id}
|
||||||
Suno API 작업 상태를 조회합니다.
|
Suno API 작업 상태를 조회합니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값 (Suno API 응답):
|
||||||
- PENDING: 대기 중
|
- PENDING: Suno API 대기 중
|
||||||
- processing: 생성 중
|
- processing: Suno API에서 노래 생성 중
|
||||||
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
|
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
|
||||||
- failed: 생성 실패
|
- TEXT_SUCCESS: Suno API 노래 생성 완료
|
||||||
|
- failed: Suno API 노래 생성 실패
|
||||||
- error: API 조회 오류
|
- error: API 조회 오류
|
||||||
|
|
||||||
SUCCESS 상태 시:
|
SUCCESS 상태 시:
|
||||||
- 백그라운드에서 MP3 파일 다운로드 시작
|
- 백그라운드에서 MP3 파일 다운로드 및 Azure Blob 업로드 시작
|
||||||
- Song 테이블의 status를 completed로 업데이트
|
- Song 테이블의 status가 uploading으로 변경
|
||||||
- song_result_url에 로컬 파일 경로 저장
|
- 업로드 완료 시 status가 completed로 변경, song_result_url에 Blob URL 저장
|
||||||
|
|
||||||
|
Example Response (Pending):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status": "PENDING",
|
||||||
|
"message": "노래 생성 대기 중입니다.",
|
||||||
|
"error_message": null
|
||||||
|
}
|
||||||
|
|
||||||
Example Response (Processing):
|
Example Response (Processing):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"message": "노래를 생성하고 있습니다.",
|
"message": "노래를 생성하고 있습니다.",
|
||||||
"clips": null,
|
|
||||||
"raw_response": {...},
|
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,18 +155,6 @@ class PollingSongResponse(BaseModel):
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
"message": "노래 생성이 완료되었습니다.",
|
"message": "노래 생성이 완료되었습니다.",
|
||||||
"clips": [
|
|
||||||
{
|
|
||||||
"id": "clip-id",
|
|
||||||
"audio_url": "https://...",
|
|
||||||
"stream_audio_url": "https://...",
|
|
||||||
"image_url": "https://...",
|
|
||||||
"title": "Song Title",
|
|
||||||
"status": "complete",
|
|
||||||
"duration": 60.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"raw_response": {...},
|
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,19 +163,15 @@ class PollingSongResponse(BaseModel):
|
||||||
"success": false,
|
"success": false,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "상태 조회에 실패했습니다.",
|
"message": "상태 조회에 실패했습니다.",
|
||||||
"clips": null,
|
|
||||||
"raw_response": null,
|
|
||||||
"error_message": "ConnectionError: ..."
|
"error_message": "ConnectionError: ..."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="조회 성공 여부")
|
success: bool = Field(..., description="조회 성공 여부")
|
||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
|
None, description="Suno API 작업 상태 (PENDING, processing, SUCCESS, TEXT_SUCCESS, failed, error)"
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="상태 메시지")
|
message: str = Field(..., description="상태 메시지")
|
||||||
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
|
|
||||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
|
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -210,17 +201,18 @@ class SongListItem(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class DownloadSongResponse(BaseModel):
|
class DownloadSongResponse(BaseModel):
|
||||||
"""노래 다운로드 응답 스키마
|
"""노래 다운로드 응답 스키마 (DB Polling)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /song/download/{task_id}
|
GET /song/download/{task_id}
|
||||||
Polls for song completion and returns project info with song URL.
|
DB의 Song 테이블 상태를 조회하고 완료 시 Project 정보와 노래 URL을 반환합니다.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
상태 값:
|
상태 값 (DB 상태):
|
||||||
- processing: 노래 생성 진행 중 (song_result_url은 null)
|
- processing: Suno API에서 노래 생성 중 (song_result_url은 null)
|
||||||
- completed: 노래 생성 완료 (song_result_url 포함)
|
- uploading: MP3 다운로드 및 Azure Blob 업로드 중 (song_result_url은 null)
|
||||||
- failed: 노래 생성 실패
|
- completed: 모든 작업 완료 (song_result_url에 Azure Blob URL 포함)
|
||||||
|
- failed: 노래 생성 또는 업로드 실패
|
||||||
- not_found: task_id에 해당하는 Song 없음
|
- not_found: task_id에 해당하는 Song 없음
|
||||||
- error: 조회 중 오류 발생
|
- error: 조회 중 오류 발생
|
||||||
|
|
||||||
|
|
@ -239,6 +231,21 @@ class DownloadSongResponse(BaseModel):
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Example Response (Uploading):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"status": "uploading",
|
||||||
|
"message": "노래 파일을 업로드 중입니다.",
|
||||||
|
"store_name": null,
|
||||||
|
"region": null,
|
||||||
|
"detail_region_info": null,
|
||||||
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
|
"language": null,
|
||||||
|
"song_result_url": null,
|
||||||
|
"created_at": null,
|
||||||
|
"error_message": null
|
||||||
|
}
|
||||||
|
|
||||||
Example Response (Completed):
|
Example Response (Completed):
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
|
|
@ -249,7 +256,7 @@ class DownloadSongResponse(BaseModel):
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
"song_result_url": "https://blob.azure.com/.../song.mp3",
|
||||||
"created_at": "2025-01-15T12:00:00",
|
"created_at": "2025-01-15T12:00:00",
|
||||||
"error_message": null
|
"error_message": null
|
||||||
}
|
}
|
||||||
|
|
@ -270,8 +277,8 @@ class DownloadSongResponse(BaseModel):
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool = Field(..., description="다운로드 성공 여부")
|
success: bool = Field(..., description="조회 성공 여부")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
status: str = Field(..., description="DB 처리 상태 (processing, uploading, completed, failed, not_found, error)")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
|
|
|
||||||
|
|
@ -173,90 +173,6 @@ async def download_and_save_song(
|
||||||
await _update_song_status(task_id, "failed")
|
await _update_song_status(task_id, "failed")
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_to_blob(
|
|
||||||
task_id: str,
|
|
||||||
audio_url: str,
|
|
||||||
store_name: str,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
audio_url: 다운로드할 오디오 URL
|
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
"""
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
|
||||||
temp_file_path: Path | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
|
||||||
safe_store_name = "".join(
|
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
temp_file_path = temp_dir / file_name
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
|
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
|
||||||
|
|
||||||
content = await _download_audio(audio_url, task_id)
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
|
||||||
await f.write(content)
|
|
||||||
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
|
||||||
uploader = AzureBlobUploader(task_id=task_id)
|
|
||||||
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
|
|
||||||
|
|
||||||
if not upload_success:
|
|
||||||
raise Exception("Azure Blob Storage 업로드 실패")
|
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
|
||||||
blob_url = uploader.public_url
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
|
||||||
|
|
||||||
# Song 테이블 업데이트
|
|
||||||
await _update_song_status(task_id, "completed", blob_url)
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# 임시 파일 삭제
|
|
||||||
if temp_file_path and temp_file_path.exists():
|
|
||||||
try:
|
|
||||||
temp_file_path.unlink()
|
|
||||||
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
|
|
||||||
|
|
||||||
# 임시 디렉토리 삭제 시도
|
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
|
||||||
if temp_dir.exists():
|
|
||||||
try:
|
|
||||||
temp_dir.rmdir()
|
|
||||||
except Exception:
|
|
||||||
pass # 디렉토리가 비어있지 않으면 무시
|
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_by_suno_task_id(
|
async def download_and_upload_song_by_suno_task_id(
|
||||||
suno_task_id: str,
|
suno_task_id: str,
|
||||||
audio_url: str,
|
audio_url: str,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from app.user.dependencies import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
AccessTokenResponse,
|
||||||
KakaoCallbackRequest,
|
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
|
|
@ -43,7 +42,7 @@ async def kakao_login() -> KakaoLoginResponse:
|
||||||
return KakaoLoginResponse(auth_url=auth_url)
|
return KakaoLoginResponse(auth_url=auth_url)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.get(
|
||||||
"/kakao/callback",
|
"/kakao/callback",
|
||||||
response_model=LoginResponse,
|
response_model=LoginResponse,
|
||||||
summary="카카오 로그인 콜백",
|
summary="카카오 로그인 콜백",
|
||||||
|
|
@ -51,7 +50,7 @@ async def kakao_login() -> KakaoLoginResponse:
|
||||||
)
|
)
|
||||||
async def kakao_callback(
|
async def kakao_callback(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: KakaoCallbackRequest,
|
code: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
||||||
) -> LoginResponse:
|
) -> LoginResponse:
|
||||||
|
|
@ -72,7 +71,7 @@ async def kakao_callback(
|
||||||
ip_address = forwarded_for.split(",")[0].strip()
|
ip_address = forwarded_for.split(",")[0].strip()
|
||||||
|
|
||||||
return await auth_service.kakao_login(
|
return await auth_service.kakao_login(
|
||||||
code=body.code,
|
code=code,
|
||||||
session=session,
|
session=session,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@
|
||||||
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
|
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.user.exceptions import (
|
from app.user.exceptions import (
|
||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
|
|
@ -221,14 +224,19 @@ class AuthService:
|
||||||
kakao_account = kakao_user_info.kakao_account
|
kakao_account = kakao_user_info.kakao_account
|
||||||
profile = kakao_account.profile if kakao_account else None
|
profile = kakao_account.profile if kakao_account else None
|
||||||
|
|
||||||
|
logger.info(f"[AUTH] 사용자 조회 시작 - kakao_id: {kakao_id}")
|
||||||
|
|
||||||
# 기존 사용자 조회
|
# 기존 사용자 조회
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(User).where(User.kakao_id == kakao_id)
|
select(User).where(User.kakao_id == kakao_id)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
logger.info(f"[AUTH] DB 조회 결과 - user_exists: {user is not None}, user_id: {user.id if user else None}")
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
# 기존 사용자: 프로필 정보 업데이트
|
# 기존 사용자: 프로필 정보 업데이트
|
||||||
|
logger.info(f"[AUTH] 기존 사용자 발견 - user_id: {user.id}, is_new_user: False")
|
||||||
if profile:
|
if profile:
|
||||||
user.nickname = profile.nickname
|
user.nickname = profile.nickname
|
||||||
user.profile_image_url = profile.profile_image_url
|
user.profile_image_url = profile.profile_image_url
|
||||||
|
|
@ -239,6 +247,7 @@ class AuthService:
|
||||||
return user, False
|
return user, False
|
||||||
|
|
||||||
# 신규 사용자 생성
|
# 신규 사용자 생성
|
||||||
|
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
|
||||||
new_user = User(
|
new_user = User(
|
||||||
kakao_id=kakao_id,
|
kakao_id=kakao_id,
|
||||||
email=kakao_account.email if kakao_account else None,
|
email=kakao_account.email if kakao_account else None,
|
||||||
|
|
@ -249,6 +258,7 @@ class AuthService:
|
||||||
session.add(new_user)
|
session.add(new_user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(new_user)
|
await session.refresh(new_user)
|
||||||
|
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
|
||||||
return new_user, True
|
return new_user, True
|
||||||
|
|
||||||
async def _save_refresh_token(
|
async def _save_refresh_token(
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class ChatgptService:
|
||||||
) -> str:
|
) -> str:
|
||||||
prompt_text = prompt.build_prompt(input_data)
|
prompt_text = prompt.build_prompt(input_data)
|
||||||
|
|
||||||
print(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
||||||
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||||
|
|
||||||
# GPT API 호출
|
# GPT API 호출
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import os, json
|
import os, json
|
||||||
from abc import ABCMeta
|
from abc import ABCMeta
|
||||||
from config import prompt_settings
|
from config import prompt_settings
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("prompt")
|
||||||
|
|
||||||
class Prompt():
|
class Prompt():
|
||||||
prompt_name : str # ex) marketing_prompt
|
prompt_name : str # ex) marketing_prompt
|
||||||
|
|
@ -37,8 +40,8 @@ class Prompt():
|
||||||
def build_prompt(self, input_data:dict) -> str:
|
def build_prompt(self, input_data:dict) -> str:
|
||||||
self.check_input(input_data)
|
self.check_input(input_data)
|
||||||
build_template = self.prompt_template
|
build_template = self.prompt_template
|
||||||
print("build_template", build_template)
|
logger.debug(f"build_template: {build_template}")
|
||||||
print("input_data", input_data)
|
logger.debug(f"input_data: {input_data}")
|
||||||
build_template = build_template.format(**input_data)
|
build_template = build_template.format(**input_data)
|
||||||
return build_template
|
return build_template
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,37 @@ class SunoService:
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
async def get_lyric_timestamp(self, task_id: str, audio_id: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
음악 타임스탬프 정보 추출
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: generate()에서 반환된 작업 ID
|
||||||
|
audio_id: 사용할 audio id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
data.alignedWords: 수노 가사 input - startS endS 시간 데이터 매핑
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"task_id" : task_id,
|
||||||
|
"audio_id" : audio_id
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.BASE_URL}/generate/get-timestamped-lyrics",
|
||||||
|
headers=self.headers,
|
||||||
|
json = payload,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise ValueError("Suno API returned empty response for task status")
|
||||||
|
|
||||||
|
return data['alignedWords']
|
||||||
|
|
||||||
def parse_status_response(self, result: dict | None) -> PollingSongResponse:
|
def parse_status_response(self, result: dict | None) -> PollingSongResponse:
|
||||||
"""Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다.
|
"""Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다.
|
||||||
|
|
||||||
|
|
@ -199,8 +230,6 @@ class SunoService:
|
||||||
success=False,
|
success=False,
|
||||||
status="error",
|
status="error",
|
||||||
message="Suno API 응답이 비어있습니다.",
|
message="Suno API 응답이 비어있습니다.",
|
||||||
clips=None,
|
|
||||||
raw_response=None,
|
|
||||||
error_message="Suno API returned None response",
|
error_message="Suno API returned None response",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -212,8 +241,6 @@ class SunoService:
|
||||||
success=False,
|
success=False,
|
||||||
status="failed",
|
status="failed",
|
||||||
message="Suno API 응답 오류",
|
message="Suno API 응답 오류",
|
||||||
clips=None,
|
|
||||||
raw_response=result,
|
|
||||||
error_message=result.get("msg", "Unknown error"),
|
error_message=result.get("msg", "Unknown error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -255,7 +282,85 @@ class SunoService:
|
||||||
success=True,
|
success=True,
|
||||||
status=status,
|
status=status,
|
||||||
message=status_messages.get(status, f"상태: {status}"),
|
message=status_messages.get(status, f"상태: {status}"),
|
||||||
clips=clips,
|
|
||||||
raw_response=result,
|
|
||||||
error_message=None,
|
error_message=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def align_lyrics(self, word_data: list[dict], sentences: list[str]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
word의 시작/끝 포지션만 저장하고, 시간은 word에서 참조
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Step 1: 전체 텍스트 + word별 포지션 범위 저장
|
||||||
|
full_text = ""
|
||||||
|
word_ranges = [] # [(start_pos, end_pos, entry), ...]
|
||||||
|
|
||||||
|
for entry in word_data:
|
||||||
|
word = entry['word']
|
||||||
|
start_pos = len(full_text)
|
||||||
|
full_text += word
|
||||||
|
end_pos = len(full_text) - 1
|
||||||
|
|
||||||
|
word_ranges.append((start_pos, end_pos, entry))
|
||||||
|
|
||||||
|
# Step 2: 메타데이터 제거 + 포지션 재매핑
|
||||||
|
meta_ranges = []
|
||||||
|
i = 0
|
||||||
|
while i < len(full_text):
|
||||||
|
if full_text[i] == '[':
|
||||||
|
start = i
|
||||||
|
while i < len(full_text) and full_text[i] != ']':
|
||||||
|
i += 1
|
||||||
|
meta_ranges.append((start, i + 1))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
clean_text = ""
|
||||||
|
new_to_old = {} # 클린 포지션 -> 원본 포지션
|
||||||
|
|
||||||
|
for old_pos, char in enumerate(full_text):
|
||||||
|
in_meta = any(s <= old_pos < e for s, e in meta_ranges)
|
||||||
|
if not in_meta:
|
||||||
|
new_to_old[len(clean_text)] = old_pos
|
||||||
|
clean_text += char
|
||||||
|
|
||||||
|
# Step 3: 포지션으로 word 찾기
|
||||||
|
def get_word_at(old_pos: int):
|
||||||
|
for start, end, entry in word_ranges:
|
||||||
|
if start <= old_pos <= end:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 4: 문장 매칭
|
||||||
|
def normalize(text):
|
||||||
|
return ''.join(c for c in text if c not in ' \n\t-')
|
||||||
|
|
||||||
|
norm_clean = normalize(clean_text)
|
||||||
|
norm_to_clean = [i for i, c in enumerate(clean_text) if c not in ' \n\t-']
|
||||||
|
|
||||||
|
results = []
|
||||||
|
search_pos = 0
|
||||||
|
|
||||||
|
for sentence in sentences:
|
||||||
|
norm_sentence = normalize(sentence)
|
||||||
|
found_pos = norm_clean.find(norm_sentence, search_pos)
|
||||||
|
|
||||||
|
if found_pos != -1:
|
||||||
|
clean_start = norm_to_clean[found_pos]
|
||||||
|
clean_end = norm_to_clean[found_pos + len(norm_sentence) - 1]
|
||||||
|
|
||||||
|
old_start = new_to_old[clean_start]
|
||||||
|
old_end = new_to_old[clean_end]
|
||||||
|
|
||||||
|
word_start = get_word_at(old_start)
|
||||||
|
word_end = get_word_at(old_end)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'text': sentence,
|
||||||
|
'startS': word_start['startS'],
|
||||||
|
'endS': word_end['endS'],
|
||||||
|
})
|
||||||
|
|
||||||
|
search_pos = found_pos + len(norm_sentence)
|
||||||
|
else:
|
||||||
|
results.append({'text': sentence, 'startS': None, 'endS': None})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
@ -716,7 +716,7 @@ from app.services.image_processor import ImageProcessorService
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/images", tags=["images"])
|
router = APIRouter(prefix="/images", tags=["Images"])
|
||||||
|
|
||||||
# 외부 서비스 인스턴스
|
# 외부 서비스 인스턴스
|
||||||
processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL)
|
processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL)
|
||||||
|
|
|
||||||
2
main.py
2
main.py
|
|
@ -68,7 +68,7 @@ tags_metadata = [
|
||||||
## 노래 생성 흐름
|
## 노래 생성 흐름
|
||||||
|
|
||||||
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
|
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
|
||||||
2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인
|
2. `GET /api/v1/song/status/{song_id}` - Suno API 상태 확인
|
||||||
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
|
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,42 +0,0 @@
|
||||||
from openai import OpenAI
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import List, Tuple
|
|
||||||
import aiohttp, json
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TimestampedLyric:
|
|
||||||
text: str
|
|
||||||
start: float
|
|
||||||
end: float
|
|
||||||
|
|
||||||
SUNO_BASE_URL="https://api.sunoapi.org"
|
|
||||||
SUNO_TIMESTAMP_ROUTE = "/api/v1/generate/get-timestamped-lyrics"
|
|
||||||
SUNO_DETAIL_ROUTE = "/api/v1/generate/record-info"
|
|
||||||
|
|
||||||
class LyricTimestampMapper:
|
|
||||||
suno_api_key : str
|
|
||||||
def __init__(self, suno_api_key):
|
|
||||||
self.suno_api_key = suno_api_key
|
|
||||||
|
|
||||||
async def get_suno_audio_id_from_task_id(self, suno_task_id): # expire if db save audio id
|
|
||||||
url = f"{SUNO_BASE_URL}{SUNO_DETAIL_ROUTE}"
|
|
||||||
headers = {"Authorization": f"Bearer {self.suno_api_key}"}
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(url, headers=headers, params={"taskId" : suno_task_id}) as response:
|
|
||||||
detail = await response.json()
|
|
||||||
result = detail['data']['response']['sunoData'][0]['id']
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def get_suno_timestamp(self, suno_task_id, suno_audio_id): # expire if db save audio id
|
|
||||||
url = f"{SUNO_BASE_URL}{SUNO_TIMESTAMP_ROUTE}"
|
|
||||||
headers = {"Authorization": f"Bearer {self.suno_api_key}"}
|
|
||||||
payload = {
|
|
||||||
"task_id" : suno_task_id,
|
|
||||||
"audio_id" : suno_audio_id
|
|
||||||
}
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(url, headers=headers, data=json.dumps(payload)) as response:
|
|
||||||
result = await response.json()
|
|
||||||
return result
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
from lyric_timestamp_mapper import LyricTimestampMapper
|
|
||||||
|
|
||||||
API_KEY = "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"
|
|
||||||
AUDIO_PATH = "test_audio.mp3"
|
|
||||||
|
|
||||||
GROUND_TRUTH_LYRICS = [
|
|
||||||
"첫 번째 가사 라인입니다",
|
|
||||||
"두 번째 가사 라인입니다",
|
|
||||||
"세 번째 가사 라인입니다",
|
|
||||||
]
|
|
||||||
|
|
||||||
mapper = LyricTimestampMapper(api_key=API_KEY)
|
|
||||||
result = mapper.map_ground_truth(AUDIO_PATH, GROUND_TRUTH_LYRICS)
|
|
||||||
|
|
||||||
for lyric in result:
|
|
||||||
if lyric.start >= 0:
|
|
||||||
print(f"[{lyric.start:.2f} - {lyric.end:.2f}] {lyric.text}")
|
|
||||||
else:
|
|
||||||
print(f"[매칭 실패] {lyric.text}")
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue