Remove the song endpoint and send the song URL when pulling if the status is SUCCESS.

insta 20260126-v1.1.0
Dohyun Lim 2026-01-26 11:54:47 +09:00
parent b48d218a1d
commit 6d2961cee2
5 changed files with 93 additions and 397 deletions

View File

@ -55,7 +55,6 @@ app/
| ------ | -------------------------- | ----------------------------- |
| POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 |
| GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) |
| GET | `/song/download/{task_id}` | 생성된 노래 MP3 다운로드 |
## 환경 설정

View File

@ -6,7 +6,6 @@ Song API Router
엔드포인트 목록:
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
- GET /song/status/{song_id}: Suno API 노래 생성 상태 조회
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
사용 예시:
from app.song.api.routers.v1.song import router
@ -14,28 +13,20 @@ Song API Router
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.dependencies.pagination import (
PaginationParams,
get_pagination_params,
)
from app.home.models import Project
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.song.schemas.song_schema import (
DownloadSongResponse,
GenerateSongRequest,
GenerateSongResponse,
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
logger = get_logger("song")
@ -346,7 +337,6 @@ GET /song/status/abc123...
## 참고
- 엔드포인트는 Suno API의 상태를 반환합니다
- SUCCESS 응답 백그라운드에서 MP3 다운로드 Azure Blob Storage 업로드가 시작됩니다
- 최종 완료 상태는 `/song/download/{task_id}` 엔드포인트에서 확인하세요
- Song 테이블 상태: processing uploading completed
""",
response_model=PollingSongResponse,
@ -487,6 +477,7 @@ async def get_song_status(
session.add(song_timestamp)
await session.commit()
parsed_response.status = "processing"
elif song and song.status == "uploading":
logger.info(
@ -497,6 +488,29 @@ async def get_song_status(
logger.info(
f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}"
)
parsed_response.song_result_url = song.song_result_url
else:
# audio_url이 없는 경우 에러 반환
logger.error(
f"[get_song_status] ERROR - audio_url not found in clips_data, song_id: {suno_task_id}"
)
return PollingSongResponse(
success=False,
status="error",
message="Suno API 응답에서 audio_url을 찾을 수 없습니다.",
error_message="audio_url not found in Suno API response",
)
else:
# clips_data가 없는 경우 에러 반환
logger.error(
f"[get_song_status] ERROR - clips_data not found, song_id: {suno_task_id}"
)
return PollingSongResponse(
success=False,
status="error",
message="Suno API 응답에서 클립 데이터를 찾을 수 없습니다.",
error_message="clips_data not found in Suno API response",
)
logger.info(f"[get_song_status] END - song_id: {suno_task_id}")
return parsed_response
@ -511,264 +525,3 @@ async def get_song_status(
message="상태 조회에 실패했습니다.",
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
)
@router.get(
"/download/{task_id}",
summary="노래 다운로드 상태 조회 (DB Polling)",
description="""
task_id를 기반으로 Song 테이블의 상태를 조회하고,
completed인 경우 Project 정보와 노래 URL을 반환합니다.
## 경로 파라미터
- **task_id**: 프로젝트 task_id (필수)
## 반환 정보
- **success**: 조회 성공 여부
- **status**: DB 처리 상태 (processing, uploading, completed, failed, not_found, error)
- **message**: 응답 메시지
- **store_name**: 업체명 (completed )
- **region**: 지역명 (completed )
- **detail_region_info**: 상세 지역 정보 (completed )
- **task_id**: 작업 고유 식별자
- **language**: 언어 (completed )
- **song_result_url**: 노래 결과 URL (completed , Azure Blob Storage URL)
- **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**: 조회 오류 발생
## 참고
- 엔드포인트는 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": "조회 성공 (모든 상태에서 200 반환)"},
},
)
async def download_song(
task_id: str,
session: AsyncSession = Depends(get_session),
) -> DownloadSongResponse:
"""task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다."""
logger.info(f"[download_song] START - task_id: {task_id}")
try:
# task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택)
song_result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = song_result.scalar_one_or_none()
if not song:
logger.warning(f"[download_song] Song NOT FOUND - task_id: {task_id}")
return DownloadSongResponse(
success=False,
status="not_found",
message=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
error_message="Song not found",
)
logger.info(
f"[download_song] Song found - task_id: {task_id}, status: {song.status}"
)
# processing 상태인 경우
if song.status == "processing":
logger.info(f"[download_song] PROCESSING - task_id: {task_id}")
return DownloadSongResponse(
success=True,
status="processing",
message="노래 생성이 진행 중입니다.",
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}")
return DownloadSongResponse(
success=False,
status="failed",
message="노래 생성에 실패했습니다.",
task_id=task_id,
error_message="Song generation failed",
)
# completed 상태인 경우 - Project 정보 조회
project_result = await session.execute(
select(Project).where(Project.id == song.project_id)
)
project = project_result.scalar_one_or_none()
logger.info(
f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}"
)
return DownloadSongResponse(
success=True,
status="completed",
message="노래 다운로드가 완료되었습니다.",
store_name=project.store_name if project else None,
region=project.region if project else None,
detail_region_info=project.detail_region_info if project else None,
task_id=task_id,
language=project.language if project else None,
song_result_url=song.song_result_url,
created_at=song.created_at,
)
except Exception as e:
logger.error(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}")
return DownloadSongResponse(
success=False,
status="error",
message="노래 다운로드 조회에 실패했습니다.",
error_message=str(e),
)
@router.get(
"s/",
summary="생성된 노래 목록 조회",
description="""
완료된 노래 목록을 페이지네이션하여 조회합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /songs/?page=1&page_size=10
```
## 참고
- status가 'completed' 노래만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
""",
response_model=PaginatedResponse[SongListItem],
responses={
200: {"description": "노래 목록 조회 성공"},
},
)
async def get_songs(
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[SongListItem]:
"""완료된 노래 목록을 페이지네이션하여 반환합니다."""
logger.info(
f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}"
)
try:
offset = (pagination.page - 1) * pagination.page_size
# 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태, created_at 기준)
from sqlalchemy import and_
# task_id별 최신 created_at 조회
latest_subquery = (
select(Song.task_id, func.max(Song.created_at).label("max_created_at"))
.where(Song.status == "completed")
.group_by(Song.task_id)
.subquery()
)
# 전체 개수 조회 (task_id별 최신 1개만)
count_query = select(func.count()).select_from(latest_subquery)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 데이터 조회 (completed 상태, task_id별 created_at 기준 최신 1개만, 최신순)
query = (
select(Song)
.join(
latest_subquery,
and_(
Song.task_id == latest_subquery.c.task_id,
Song.created_at == latest_subquery.c.max_created_at,
),
)
.where(Song.status == "completed")
.order_by(Song.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(query)
songs = result.scalars().all()
# Project 정보 일괄 조회 (N+1 문제 해결)
project_ids = [s.project_id for s in songs if s.project_id]
projects_map: dict = {}
if project_ids:
projects_result = await session.execute(
select(Project).where(Project.id.in_(project_ids))
)
projects_map = {p.id: p for p in projects_result.scalars().all()}
# SongListItem으로 변환
items = []
for song in songs:
project = projects_map.get(song.project_id)
item = SongListItem(
store_name=project.store_name if project else None,
region=project.region if project else None,
task_id=song.task_id,
language=song.language,
song_result_url=song.song_result_url,
created_at=song.created_at,
)
items.append(item)
response = PaginatedResponse.create(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
logger.info(
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}"
)
return response
except Exception as e:
logger.error(f"[get_songs] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"노래 목록 조회에 실패했습니다: {str(e)}",
)

View File

@ -79,9 +79,30 @@ class GenerateSongResponse(BaseModel):
}
"""
model_config = {
"json_schema_extra": {
"examples": [
{
"success": True,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"song_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
"error_message": None,
},
{
"success": False,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"song_id": None,
"message": "노래 생성 요청에 실패했습니다.",
"error_message": "Suno API connection error",
},
]
}
}
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
song_id: Optional[str] = Field(None, description="Suno API 작업 ID")
song_id: Optional[str] = Field(None, description="Suno API 작업 ID (상태 조회에 사용)")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
@ -124,6 +145,7 @@ class PollingSongResponse(BaseModel):
상태 (Suno API 응답):
- PENDING: Suno API 대기
- processing: Suno API에서 노래 생성
- uploading: MP3 다운로드 Azure Blob 업로드
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
- TEXT_SUCCESS: Suno API 노래 생성 완료
- failed: Suno API 노래 생성 실패
@ -133,13 +155,15 @@ class PollingSongResponse(BaseModel):
- 백그라운드에서 MP3 파일 다운로드 Azure Blob 업로드 시작
- Song 테이블의 status가 uploading으로 변경
- 업로드 완료 status가 completed로 변경, song_result_url에 Blob URL 저장
- completed 상태인 경우 song_result_url 반환
Example Response (Pending):
{
"success": true,
"status": "PENDING",
"message": "노래 생성 대기 중입니다.",
"error_message": null
"error_message": null,
"song_result_url": null
}
Example Response (Processing):
@ -147,15 +171,26 @@ class PollingSongResponse(BaseModel):
"success": true,
"status": "processing",
"message": "노래를 생성하고 있습니다.",
"error_message": null
"error_message": null,
"song_result_url": null
}
Example Response (Success):
Example Response (Uploading):
{
"success": true,
"status": "uploading",
"message": "노래 생성이 완료되었습니다.",
"error_message": null,
"song_result_url": null
}
Example Response (Success - Completed):
{
"success": true,
"status": "SUCCESS",
"message": "노래 생성이 완료되었습니다.",
"error_message": null
"error_message": null,
"song_result_url": "https://blob.azure.com/.../song.mp3"
}
Example Response (Failure):
@ -163,131 +198,42 @@ class PollingSongResponse(BaseModel):
"success": false,
"status": "error",
"message": "상태 조회에 실패했습니다.",
"error_message": "ConnectionError: ..."
"error_message": "ConnectionError: ...",
"song_result_url": null
}
"""
model_config = {
"json_schema_extra": {
"examples": [
{
"success": True,
"status": "processing",
"message": "노래를 생성하고 있습니다.",
"error_message": None,
"song_result_url": None,
},
{
"success": True,
"status": "SUCCESS",
"message": "노래 생성이 완료되었습니다.",
"error_message": None,
"song_result_url": "https://blob.azure.com/.../song.mp3",
},
]
}
}
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="Suno API 작업 상태 (PENDING, processing, SUCCESS, TEXT_SUCCESS, failed, error)"
None,
description="작업 상태 (PENDING, processing, uploading, SUCCESS, TEXT_SUCCESS, failed, error)",
)
message: str = Field(..., description="상태 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class SongListItem(BaseModel):
"""노래 목록 아이템 스키마
Usage:
GET /songs 응답의 개별 노래 정보
Example:
{
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00"
}
"""
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
class DownloadSongResponse(BaseModel):
"""노래 다운로드 응답 스키마 (DB Polling)
Usage:
GET /song/download/{task_id}
DB의 Song 테이블 상태를 조회하고 완료 Project 정보와 노래 URL을 반환합니다.
Note:
상태 (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: 조회 오류 발생
Example Response (Processing):
{
"success": true,
"status": "processing",
"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 (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,
"status": "completed",
"message": "노래 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "https://blob.azure.com/.../song.mp3",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
Example Response (Not Found):
{
"success": false,
"status": "not_found",
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
"store_name": null,
"region": null,
"detail_region_info": null,
"task_id": null,
"language": null,
"song_result_url": null,
"created_at": null,
"error_message": "Song not found"
}
"""
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="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
song_result_url: Optional[str] = Field(
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
)
# =============================================================================

View File

@ -111,7 +111,6 @@ async def get_item(
**적용 엔드포인트:**
- `GET /videos/` - 목록 조회
- `GET /video/download/{task_id}` - 상태 조회
- `GET /songs/` - 목록 조회
#### 패턴 2: 명시적 세션 관리 (외부 API 호출 포함)

View File

@ -69,7 +69,6 @@ tags_metadata = [
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
2. `GET /api/v1/song/status/{song_id}` - Suno API 상태 확인
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
""",
},
{