Compare commits

..

7 Commits

Author SHA1 Message Date
jaehwang 94be8a0746 upload db on timestamped lyric 2026-01-20 15:11:03 +09:00
jaehwang da59f3d6e3 update main 2026-01-20 14:52:35 +09:00
Dohyun Lim ee6069e5d5 feat: update song model and related routers, schemas, worker 2026-01-20 14:40:58 +09:00
Dohyun Lim ece201f92b update README.md 2026-01-20 11:26:13 +09:00
Dohyun Lim 56069a04a1 modify url parameter 2026-01-19 16:59:25 +09:00
Dohyun Lim f362effe7a fix callback style 2026-01-19 15:05:11 +09:00
Dohyun Lim 1562aee998 first commit 2026-01-19 13:56:48 +09:00
29 changed files with 3731 additions and 1968 deletions

198
README.md
View File

@ -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 토큰 발급 │ │ │
│◀───────────────│ │ │
│ │ │ │
```

View File

View File

@ -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),
)

View File

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

View File

@ -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}')>"

View File

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

View File

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

View File

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

View File

@ -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(

View File

@ -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개씩 조회
``` ```
## 참고 ## 참고

View File

@ -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(

View File

@ -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")>"
)

View File

@ -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="지역명")

View File

@ -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,

View File

@ -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,

View File

@ -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(

View File

@ -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 호출

View File

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

View File

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

View File

@ -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)

View File

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

View File

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

View File

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