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를 이용한 노래 생성 요청 | | POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 |
| GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) | | 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 연결) - POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
- GET /song/status/{song_id}: Suno API 노래 생성 상태 조회 - GET /song/status/{song_id}: Suno API 노래 생성 상태 조회
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
사용 예시: 사용 예시:
from app.song.api.routers.v1.song import router from app.song.api.routers.v1.song import router
@ -14,28 +13,20 @@ Song API Router
""" """
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.dependencies.pagination import (
PaginationParams,
get_pagination_params,
)
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, SongTimestamp from app.song.models import Song, SongTimestamp
from app.song.schemas.song_schema import ( from app.song.schemas.song_schema import (
DownloadSongResponse,
GenerateSongRequest, GenerateSongRequest,
GenerateSongResponse, GenerateSongResponse,
PollingSongResponse, PollingSongResponse,
SongListItem,
) )
from app.song.worker.song_task import download_and_upload_song_by_suno_task_id 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.suno import SunoService from app.utils.suno import SunoService
logger = get_logger("song") logger = get_logger("song")
@ -346,7 +337,6 @@ GET /song/status/abc123...
## 참고 ## 참고
- 엔드포인트는 Suno API의 상태를 반환합니다 - 엔드포인트는 Suno API의 상태를 반환합니다
- SUCCESS 응답 백그라운드에서 MP3 다운로드 Azure Blob Storage 업로드가 시작됩니다 - SUCCESS 응답 백그라운드에서 MP3 다운로드 Azure Blob Storage 업로드가 시작됩니다
- 최종 완료 상태는 `/song/download/{task_id}` 엔드포인트에서 확인하세요
- Song 테이블 상태: processing uploading completed - Song 테이블 상태: processing uploading completed
""", """,
response_model=PollingSongResponse, response_model=PollingSongResponse,
@ -487,6 +477,7 @@ async def get_song_status(
session.add(song_timestamp) session.add(song_timestamp)
await session.commit() await session.commit()
parsed_response.status = "processing"
elif song and song.status == "uploading": elif song and song.status == "uploading":
logger.info( logger.info(
@ -497,6 +488,29 @@ async def get_song_status(
logger.info( logger.info(
f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}" 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}") logger.info(f"[get_song_status] END - song_id: {suno_task_id}")
return parsed_response return parsed_response
@ -511,264 +525,3 @@ async def get_song_status(
message="상태 조회에 실패했습니다.", message="상태 조회에 실패했습니다.",
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}", 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="요청 성공 여부") 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)")
song_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="에러 메시지 (실패 시)")
@ -124,6 +145,7 @@ class PollingSongResponse(BaseModel):
상태 (Suno API 응답): 상태 (Suno API 응답):
- PENDING: Suno API 대기 - PENDING: Suno API 대기
- processing: Suno API에서 노래 생성 - processing: Suno API에서 노래 생성
- uploading: MP3 다운로드 Azure Blob 업로드
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작) - SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
- TEXT_SUCCESS: Suno API 노래 생성 완료 - TEXT_SUCCESS: Suno API 노래 생성 완료
- failed: Suno API 노래 생성 실패 - failed: Suno API 노래 생성 실패
@ -133,13 +155,15 @@ class PollingSongResponse(BaseModel):
- 백그라운드에서 MP3 파일 다운로드 Azure Blob 업로드 시작 - 백그라운드에서 MP3 파일 다운로드 Azure Blob 업로드 시작
- Song 테이블의 status가 uploading으로 변경 - Song 테이블의 status가 uploading으로 변경
- 업로드 완료 status가 completed로 변경, song_result_url에 Blob URL 저장 - 업로드 완료 status가 completed로 변경, song_result_url에 Blob URL 저장
- completed 상태인 경우 song_result_url 반환
Example Response (Pending): Example Response (Pending):
{ {
"success": true, "success": true,
"status": "PENDING", "status": "PENDING",
"message": "노래 생성 대기 중입니다.", "message": "노래 생성 대기 중입니다.",
"error_message": null "error_message": null,
"song_result_url": null
} }
Example Response (Processing): Example Response (Processing):
@ -147,15 +171,26 @@ class PollingSongResponse(BaseModel):
"success": true, "success": true,
"status": "processing", "status": "processing",
"message": "노래를 생성하고 있습니다.", "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, "success": true,
"status": "SUCCESS", "status": "SUCCESS",
"message": "노래 생성이 완료되었습니다.", "message": "노래 생성이 완료되었습니다.",
"error_message": null "error_message": null,
"song_result_url": "https://blob.azure.com/.../song.mp3"
} }
Example Response (Failure): Example Response (Failure):
@ -163,131 +198,42 @@ class PollingSongResponse(BaseModel):
"success": false, "success": false,
"status": "error", "status": "error",
"message": "상태 조회에 실패했습니다.", "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="조회 성공 여부") success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field( 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="상태 메시지") message: str = Field(..., description="상태 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
song_result_url: Optional[str] = Field(
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
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="에러 메시지 (실패 시)")
# ============================================================================= # =============================================================================

View File

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

View File

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