From 6d2961cee2ba2e2333eff4fc41d2c280209b012f Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Mon, 26 Jan 2026 11:54:47 +0900 Subject: [PATCH] Remove the song endpoint and send the song URL when pulling if the status is SUCCESS. --- README.md | 1 - app/song/api/routers/v1/song.py | 297 ++---------------- app/song/schemas/song_schema.py | 190 ++++------- .../async_architecture_design_report.md | 1 - main.py | 1 - 5 files changed, 93 insertions(+), 397 deletions(-) diff --git a/README.md b/README.md index 89975ad..de19512 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ app/ | ------ | -------------------------- | ----------------------------- | | POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 | | GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) | -| GET | `/song/download/{task_id}` | 생성된 노래 MP3 다운로드 | ## 환경 설정 diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 9a3aa34..6362a00 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -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)}", - ) diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index cfde1a7..2656646 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -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일 때 반환)" + ) # ============================================================================= diff --git a/docs/analysis/async_architecture_design_report.md b/docs/analysis/async_architecture_design_report.md index 81ca07d..4546428 100644 --- a/docs/analysis/async_architecture_design_report.md +++ b/docs/analysis/async_architecture_design_report.md @@ -111,7 +111,6 @@ async def get_item( **적용 엔드포인트:** - `GET /videos/` - 목록 조회 - `GET /video/download/{task_id}` - 상태 조회 -- `GET /songs/` - 목록 조회 #### 패턴 2: 명시적 세션 관리 (외부 API 호출 포함) diff --git a/main.py b/main.py index 78e1c03..b205341 100644 --- a/main.py +++ b/main.py @@ -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 조회 """, }, {