diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 2635412..6595df6 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -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( diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index 7eea279..68735b2 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -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개씩 조회 ``` ## 참고 diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 42d0861..2ca4781 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -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( diff --git a/app/song/models.py b/app/song/models.py index 0bfcac8..8f6d7d6 100644 --- a/app/song/models.py +++ b/app/song/models.py @@ -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"" + ) diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index b5daac2..cfde1a7 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -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="지역명") diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index a264145..0f77558 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -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, diff --git a/docs/analysis/pool_problem.md b/docs/analysis/pool_problem.md index f8ffa33..2955c1a 100644 --- a/docs/analysis/pool_problem.md +++ b/docs/analysis/pool_problem.md @@ -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)