diff --git a/.gitignore b/.gitignore index b8ab999..9d68df5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ dist/ build/ *.pyc *.pyo -.eggs/ \ No newline at end of file +.eggs/ + +# Media files +*.mp3 +*.mp4 +media/ \ No newline at end of file diff --git a/README.md b/README.md index e69de29..cc56677 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,112 @@ +# CastAD Backend + +AI 기반 광고 음악 생성 서비스의 백엔드 API 서버입니다. + +## 기술 스택 + +- **Framework**: FastAPI +- **Database**: MySQL (asyncmy 비동기 드라이버) +- **ORM**: SQLAlchemy (async) +- **AI Services**: + - OpenAI ChatGPT (가사 생성, 마케팅 분석) + - Suno AI (음악 생성) + +## 프로젝트 구조 + +```text +app/ +├── core/ # 핵심 설정 및 공통 모듈 +├── database/ # 데이터베이스 세션 및 설정 +├── home/ # 홈 API (크롤링, 영상 생성 요청) +├── lyric/ # 가사 API (가사 생성) +├── song/ # 노래 API (Suno AI 음악 생성) +├── video/ # 비디오 관련 모듈 +└── utils/ # 유틸리티 (ChatGPT, Suno, 크롤러) +``` + +## API 엔드포인트 + +### Home API + +| Method | Endpoint | 설명 | +| ------ | ------------------ | ------------------------------ | +| POST | `/crawling` | 네이버 지도 장소 크롤링 | +| POST | `/generate` | 기본 영상 생성 요청 | +| POST | `/generate/urls` | URL 기반 영상 생성 요청 | +| POST | `/generate/upload` | 파일 업로드 기반 영상 생성 요청 | + +### Lyric API + +| Method | Endpoint | 설명 | +| ------ | ------------------------- | --------------------------- | +| POST | `/lyric/generate` | ChatGPT를 이용한 가사 생성 | +| GET | `/lyric/status/{task_id}` | 가사 생성 상태 조회 | +| GET | `/lyric/{task_id}` | 가사 상세 조회 | +| GET | `/lyrics` | 가사 목록 조회 (페이지네이션) | + +### Song API + +| Method | Endpoint | 설명 | +| ------ | -------------------------- | ----------------------------- | +| POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 | +| GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) | +| GET | `/song/download/{task_id}` | 생성된 노래 MP3 다운로드 | + +## 환경 설정 + +`.env` 파일에 다음 환경 변수를 설정합니다: + +```env +# 프로젝트 설정 +PROJECT_NAME=CastAD +PROJECT_DOMAIN=localhost:8000 +DEBUG=True + +# MySQL 설정 +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=your_user +MYSQL_PASSWORD=your_password +MYSQL_DB=castad + +# API Keys +CHATGPT_API_KEY=your_openai_api_key +SUNO_API_KEY=your_suno_api_key +SUNO_CALLBACK_URL=https://your-domain.com/api/suno/callback +``` + +## 실행 방법 + +### uv 설치 + +```bash +# Windows (PowerShell) +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" + +# macOS / Linux +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +### 의존성 설치 + +```bash +# 기본 설치 (uv가 자동으로 가상환경 생성) +uv sync + +# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요) +uv sync --active +``` + +### 서버 실행 + +```bash +# 개발 서버 실행 +fastapi dev main.py + +# 프로덕션 서버 실행 +fastapi run main.py +``` + +## API 문서 + +서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다. diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index ec460eb..deee8c1 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -6,21 +6,30 @@ Song API Router 엔드포인트 목록: - POST /song/generate: 노래 생성 요청 - GET /song/status/{task_id}: 노래 생성 상태 조회 + - GET /song/download/{task_id}: 노래 다운로드 사용 예시: from app.song.api.routers.v1.song import router app.include_router(router, prefix="/api/v1") """ +from datetime import date +from pathlib import Path + +import aiofiles +import httpx from fastapi import APIRouter +from uuid_extensions import uuid7str from app.song.schemas.song_schema import ( + DownloadSongResponse, GenerateSongRequest, GenerateSongResponse, PollingSongResponse, SongClipData, ) from app.utils.suno import SunoService +from config import prj_settings def _parse_suno_status_response(result: dict) -> PollingSongResponse: @@ -39,25 +48,30 @@ def _parse_suno_status_response(result: dict) -> PollingSongResponse: ) status = data.get("status", "unknown") - clips_data = data.get("data", []) - # 상태별 메시지 + # 클립 데이터는 data.response.sunoData에 있음 (camelCase) + response_data = data.get("response", {}) + clips_data = response_data.get("sunoData", []) + + # 상태별 메시지 (Suno API는 다양한 상태값 반환) status_messages = { "pending": "노래 생성 대기 중입니다.", "processing": "노래를 생성하고 있습니다.", "complete": "노래 생성이 완료되었습니다.", + "SUCCESS": "노래 생성이 완료되었습니다.", + "TEXT_SUCCESS": "노래 생성이 완료되었습니다.", "failed": "노래 생성에 실패했습니다.", } - # 클립 데이터 파싱 + # 클립 데이터 파싱 (Suno API는 camelCase 사용) clips = None if clips_data: clips = [ SongClipData( id=clip.get("id"), - audio_url=clip.get("audio_url"), - stream_audio_url=clip.get("stream_audio_url"), - image_url=clip.get("image_url"), + audio_url=clip.get("audioUrl"), + stream_audio_url=clip.get("streamAudioUrl"), + image_url=clip.get("imageUrl"), title=clip.get("title"), status=clip.get("status"), duration=clip.get("duration"), @@ -194,3 +208,129 @@ async def get_song_status( raw_response=None, error_message=str(e), ) + + +@router.get( + "/download/{task_id}", + summary="노래 다운로드", + description=""" +완료된 노래를 서버에 다운로드하고 접근 가능한 URL을 반환합니다. + +## 경로 파라미터 +- **task_id**: 노래 생성 시 반환된 작업 ID (필수) + +## 반환 정보 +- **success**: 다운로드 성공 여부 +- **message**: 응답 메시지 +- **file_path**: 저장된 파일의 상대 경로 +- **file_url**: 프론트엔드에서 접근 가능한 파일 URL + +## 사용 예시 +``` +GET /song/download/abc123... +``` + +## 참고 +- 노래 생성이 완료된 상태(complete)에서만 다운로드 가능합니다. +- 파일은 /media/{날짜}/{uuid7}/song.mp3 경로에 저장됩니다. +- 반환된 file_url을 사용하여 프론트엔드에서 MP3를 재생할 수 있습니다. + """, + response_model=DownloadSongResponse, + responses={ + 200: {"description": "다운로드 성공"}, + 400: {"description": "노래 생성이 완료되지 않음"}, + 500: {"description": "다운로드 실패"}, + }, +) +async def download_song( + task_id: str, +) -> DownloadSongResponse: + """완료된 노래를 다운로드하여 서버에 저장하고 접근 URL을 반환합니다.""" + try: + suno_service = SunoService() + result = await suno_service.get_task_status(task_id) + + # API 응답 확인 + if result.get("code") != 200: + return DownloadSongResponse( + success=False, + message="Suno API 응답 오류", + error_message=result.get("msg", "Unknown error"), + ) + + data = result.get("data", {}) + status = data.get("status", "unknown") + + # 완료 상태 확인 (Suno API는 다양한 완료 상태값 반환) + completed_statuses = {"complete", "SUCCESS", "TEXT_SUCCESS"} + if status not in completed_statuses: + return DownloadSongResponse( + success=False, + message=f"노래 생성이 완료되지 않았습니다. 현재 상태: {status}", + error_message="노래 생성 완료 후 다운로드해 주세요.", + ) + + # 클립 데이터는 data.response.sunoData에 있음 (camelCase) + response_data = data.get("response", {}) + clips_data = response_data.get("sunoData", []) + if not clips_data: + return DownloadSongResponse( + success=False, + message="생성된 노래 클립이 없습니다.", + error_message="sunoData is empty", + ) + + # 첫 번째 클립의 streamAudioUrl 가져오기 (camelCase) + first_clip = clips_data[0] + stream_audio_url = first_clip.get("streamAudioUrl") + + if not stream_audio_url: + return DownloadSongResponse( + success=False, + message="스트리밍 오디오 URL을 찾을 수 없습니다.", + error_message="stream_audio_url is missing", + ) + + # 저장 경로 생성: media/{날짜}/{uuid7}/song.mp3 + today = date.today().isoformat() + unique_id = uuid7str() + relative_dir = f"{today}/{unique_id}" + file_name = "song.mp3" + + # 절대 경로 생성 + media_dir = Path("media") / today / unique_id + media_dir.mkdir(parents=True, exist_ok=True) + file_path = media_dir / file_name + + # 오디오 파일 다운로드 (비동기 파일 쓰기) + async with httpx.AsyncClient() as client: + response = await client.get(stream_audio_url, timeout=60.0) + response.raise_for_status() + + async with aiofiles.open(file_path, "wb") as f: + await f.write(response.content) + + # 프론트엔드에서 접근 가능한 URL 생성 + relative_path = f"/media/{relative_dir}/{file_name}" + base_url = f"http://{prj_settings.PROJECT_DOMAIN}" + file_url = f"{base_url}{relative_path}" + + return DownloadSongResponse( + success=True, + message="노래 다운로드가 완료되었습니다.", + file_path=relative_path, + file_url=file_url, + ) + + except httpx.HTTPError as e: + return DownloadSongResponse( + success=False, + message="오디오 파일 다운로드에 실패했습니다.", + error_message=str(e), + ) + except Exception as e: + return DownloadSongResponse( + success=False, + message="노래 다운로드에 실패했습니다.", + error_message=str(e), + ) diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index 2e1c327..57c1d79 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -122,6 +122,29 @@ class PollingSongResponse(BaseModel): error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") +class DownloadSongResponse(BaseModel): + """노래 다운로드 응답 스키마 + + Usage: + GET /song/download/{task_id} + Downloads the generated song and returns the local file path. + + Example Response: + { + "success": true, + "message": "노래 다운로드가 완료되었습니다.", + "file_path": "/media/2025-01-15/01234567-89ab-7def-0123-456789abcdef/song.mp3", + "file_url": "http://localhost:8000/media/2025-01-15/01234567-89ab-7def-0123-456789abcdef/song.mp3" + } + """ + + success: bool = Field(..., description="다운로드 성공 여부") + message: str = Field(..., description="응답 메시지") + file_path: Optional[str] = Field(None, description="저장된 파일 경로 (상대 경로)") + file_url: Optional[str] = Field(None, description="파일 접근 URL") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + # ============================================================================= # Dataclass Schemas (Legacy) # =============================================================================