feat: update song model and related routers, schemas, worker

This commit is contained in:
Dohyun Lim 2026-01-20 14:40:58 +09:00
parent ece201f92b
commit ee6069e5d5
7 changed files with 222 additions and 148 deletions

View File

@ -379,7 +379,7 @@ print(response.json())
200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
},
tags=["image"],
tags=["Image"],
)
async def upload_images(
images_json: Optional[str] = Form(

View File

@ -418,9 +418,9 @@ async def get_lyric_status(
## 사용 예시
```
GET /lyrics # 기본 조회 (1페이지, 20개)
GET /lyrics?page=2 # 2페이지 조회
GET /lyrics?page=1&page_size=50 # 50개씩 조회
GET /lyrics/ # 기본 조회 (1페이지, 20개)
GET /lyrics/?page=2 # 2페이지 조회
GET /lyrics/?page=1&page_size=50 # 50개씩 조회
```
## 참고

View File

@ -13,7 +13,7 @@ Song API Router
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.ext.asyncio import AsyncSession
@ -32,6 +32,7 @@ from app.song.schemas.song_schema import (
PollingSongResponse,
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.pagination import PaginatedResponse
from app.utils.suno import SunoService
@ -294,17 +295,17 @@ async def generate_song(
@router.get(
"/status/{song_id}",
summary="노래 생성 상태 조회",
summary="노래 생성 상태 조회 (Suno API)",
description="""
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
## 경로 파라미터
- **song_id**: 노래 생성 시 반환된 작업 ID (필수)
- **song_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수)
## 반환 정보
- **success**: 조회 성공 여부
- **status**: 작업 상태 (PENDING, processing, SUCCESS, failed)
- **status**: Suno API 작업 상태
- **message**: 상태 메시지
## 사용 예시
@ -312,27 +313,28 @@ SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
GET /song/status/abc123...
```
## 상태 값
- **PENDING**: 대기 중
- **processing**: 생성 중
- **SUCCESS**: 생성 완료
- **failed**: 생성 실패
## 상태 값 (Suno API 응답)
- **PENDING**: Suno API 대기 중
- **processing**: Suno API에서 노래 생성 중
- **SUCCESS**: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
- **TEXT_SUCCESS**: Suno API 노래 생성 완료
- **failed**: Suno API 노래 생성 실패
- **error**: API 조회 오류
## 참고
- 스트림 URL: 30-40초 내 생성
- 다운로드 URL: 2-3분 내 생성
- SUCCESS 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드 → Song 테이블 업데이트 진행
- 저장 경로: Azure Blob Storage ({BASE_URL}/{task_id}/song/{store_name}.mp3)
- Song 테이블의 song_result_url에 Blob URL이 저장됩니다
- 이 엔드포인트는 Suno API의 상태를 반환합니다
- SUCCESS 응답 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드가 시작됩니다
- 최종 완료 상태는 `/song/download/{task_id}` 엔드포인트에서 확인하세요
- Song 테이블 상태: processing → uploading → completed
""",
response_model=PollingSongResponse,
responses={
200: {"description": "상태 조회 성공"},
500: {"description": "상태 조회 실패"},
},
)
async def get_song_status(
song_id: str,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
) -> PollingSongResponse:
"""song_id로 노래 생성 작업의 상태를 조회합니다.
@ -345,10 +347,11 @@ async def get_song_status(
try:
suno_service = SunoService()
result = await suno_service.get_task_status(song_id)
logger.debug(f"[get_song_status] Suno API raw response - song_id: {song_id}, result: {result}")
parsed_response = suno_service.parse_status_response(result)
logger.info(f"[get_song_status] Suno API response - song_id: {song_id}, status: {parsed_response.status}")
# SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
if parsed_response.status == "SUCCESS" and result:
# result에서 직접 clips 데이터 추출
data = result.get("data", {})
@ -372,14 +375,33 @@ async def get_song_status(
)
song = song_result.scalar_one_or_none()
if song and song.status != "completed":
# 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장
song.status = "completed"
song.song_result_url = audio_url
if clip_duration is not None:
song.duration = clip_duration
# 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 updated - song_id: {song_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
logger.info(f"[get_song_status] Song status changed to uploading - song_id: {song_id}")
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
background_tasks.add_task(
download_and_upload_song_by_suno_task_id,
suno_task_id=song_id,
audio_url=audio_url,
store_name=store_name,
duration=clip_duration,
)
logger.info(f"[get_song_status] Background task scheduled - song_id: {song_id}, store_name: {store_name}")
elif song and song.status == "uploading":
logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {song_id}")
elif song and song.status == "completed":
logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {song_id}")
@ -400,7 +422,7 @@ async def get_song_status(
@router.get(
"/download/{task_id}",
summary="노래 생성 URL 조회",
summary="노래 다운로드 상태 조회 (DB Polling)",
description="""
task_id를 기반으로 Song 테이블의 상태를 조회하고,
completed인 경우 Project 정보와 노래 URL을 반환합니다.
@ -410,31 +432,38 @@ completed인 경우 Project 정보와 노래 URL을 반환합니다.
## 반환 정보
- **success**: 조회 성공 여부
- **status**: 처리 상태 (processing, completed, failed, not_found)
- **status**: DB 처리 상태 (processing, uploading, completed, failed, not_found, error)
- **message**: 응답 메시지
- **store_name**: 업체명
- **region**: 지역명
- **detail_region_info**: 상세 지역 정보
- **store_name**: 업체명 (completed 시)
- **region**: 지역명 (completed 시)
- **detail_region_info**: 상세 지역 정보 (completed 시)
- **task_id**: 작업 고유 식별자
- **language**: 언어
- **language**: 언어 (completed 시)
- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL)
- **created_at**: 생성 일시
- **created_at**: 생성 일시 (completed 시)
- **error_message**: 에러 메시지 (실패 시)
## 사용 예시
```
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입니다.
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다.
- 이 엔드포인트는 DB의 Song 테이블 상태를 반환합니다
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
""",
response_model=DownloadSongResponse,
responses={
200: {"description": "조회 성공"},
404: {"description": "Song을 찾을 수 없음"},
500: {"description": "조회 실패"},
200: {"description": "조회 성공 (모든 상태에서 200 반환)"},
},
)
async def download_song(
@ -474,6 +503,16 @@ async def download_song(
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 상태인 경우
if song.status == "failed":
logger.warning(f"[download_song] FAILED - task_id: {task_id}")
@ -546,7 +585,6 @@ GET /songs/?page=1&page_size=10
response_model=PaginatedResponse[SongListItem],
responses={
200: {"description": "노래 목록 조회 성공"},
500: {"description": "조회 실패"},
},
)
async def get_songs(

View File

@ -1,7 +1,7 @@
from datetime import datetime
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 app.database.session import Base
@ -25,7 +25,7 @@ class Song(Base):
lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (pending, processing, completed, failed 등)
status: 처리 상태 (processing, uploading, completed, failed)
song_prompt: 노래 생성에 사용된 프롬프트
song_result_url: 생성 결과 URL (선택)
language: 출력 언어
@ -82,10 +82,16 @@ class Song(Base):
comment="Suno API 작업 고유 식별자",
)
suno_audio_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="Suno 첫번째 노래의 고유 식별자",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
comment="처리 상태 (processing, uploading, completed, failed)",
)
song_prompt: Mapped[str] = mapped_column(
@ -150,3 +156,92 @@ class Song(Base):
f"status='{self.status}'"
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

@ -114,24 +114,33 @@ class SongClipData(BaseModel):
class PollingSongResponse(BaseModel):
"""노래 생성 상태 조회 응답 스키마
"""노래 생성 상태 조회 응답 스키마 (Suno API)
Usage:
GET /song/status/{song_id}
Suno API 작업 상태를 조회합니다.
Note:
상태 값:
- PENDING: 대기 중
- processing: 생성 중
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
- failed: 생성 실패
상태 값 (Suno API 응답):
- PENDING: Suno API 대기 중
- processing: Suno API에서 노래 생성 중
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
- TEXT_SUCCESS: Suno API 노래 생성 완료
- failed: Suno API 노래 생성 실패
- error: API 조회 오류
SUCCESS 상태 시:
- 백그라운드에서 MP3 파일 다운로드 시작
- Song 테이블의 status를 completed로 업데이트
- song_result_url에 로컬 파일 경로 저장
- 백그라운드에서 MP3 파일 다운로드 및 Azure Blob 업로드 시작
- Song 테이블의 status가 uploading으로 변경
- 업로드 완료 시 status가 completed로 변경, song_result_url에 Blob URL 저장
Example Response (Pending):
{
"success": true,
"status": "PENDING",
"message": "노래 생성 대기 중입니다.",
"error_message": null
}
Example Response (Processing):
{
@ -160,7 +169,7 @@ class PollingSongResponse(BaseModel):
success: bool = Field(..., description="조회 성공 여부")
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="상태 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
@ -192,17 +201,18 @@ class SongListItem(BaseModel):
class DownloadSongResponse(BaseModel):
"""노래 다운로드 응답 스키마
"""노래 다운로드 응답 스키마 (DB Polling)
Usage:
GET /song/download/{task_id}
Polls for song completion and returns project info with song URL.
DB의 Song 테이블 상태를 조회하고 완료 시 Project 정보와 노래 URL을 반환합니다.
Note:
상태 값:
- processing: 노래 생성 진행 중 (song_result_url은 null)
- completed: 노래 생성 완료 (song_result_url 포함)
- failed: 노래 생성 실패
상태 값 (DB 상태):
- processing: Suno API에서 노래 생성 중 (song_result_url은 null)
- uploading: MP3 다운로드 및 Azure Blob 업로드 중 (song_result_url은 null)
- completed: 모든 작업 완료 (song_result_url에 Azure Blob URL 포함)
- failed: 노래 생성 또는 업로드 실패
- not_found: task_id에 해당하는 Song 없음
- error: 조회 중 오류 발생
@ -221,6 +231,21 @@ class DownloadSongResponse(BaseModel):
"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):
{
"success": true,
@ -231,7 +256,7 @@ class DownloadSongResponse(BaseModel):
"detail_region_info": "군산 신흥동 말랭이 마을",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"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",
"error_message": null
}
@ -252,8 +277,8 @@ class DownloadSongResponse(BaseModel):
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
success: bool = Field(..., description="조회 성공 여부")
status: str = Field(..., description="DB 처리 상태 (processing, uploading, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: 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")
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(
suno_task_id: str,
audio_url: str,

View File

@ -716,7 +716,7 @@ from app.services.image_processor import ImageProcessorService
from app.config import settings
router = APIRouter(prefix="/images", tags=["images"])
router = APIRouter(prefix="/images", tags=["Images"])
# 외부 서비스 인스턴스
processor_service = ImageProcessorService(settings.IMAGE_PROCESSOR_URL)