From dd855ff11a0d11511cd7aa2177b38938720578ac Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 22 Dec 2025 18:05:55 +0900 Subject: [PATCH] lyric, song finished --- .gitignore | 8 +- app/home/schemas/home.py | 6 + app/home/worker/main_task.py | 6 +- app/lyric/api/routers/v1/lyric.py | 81 ++++++++++++ app/lyric/schemas/lyric.py | 59 +++++++++ app/song/api/routers/v1/song.py | 196 ++++++++++++++++++++++++++++++ app/song/schemas/song_schema.py | 124 ++++++++++++++++++- app/utils/chatgpt_prompt.py | 53 ++++++-- app/utils/suno.py | 172 ++++++++++++++++++++++++++ config.py | 4 + main.py | 2 + 11 files changed, 701 insertions(+), 10 deletions(-) create mode 100644 app/song/api/routers/v1/song.py create mode 100644 app/utils/suno.py diff --git a/.gitignore b/.gitignore index 7b72bcf..b8ab999 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,11 @@ __pycache__/ # VSCode settings .vscode/ -# Python package metadata +# Build artifacts *.egg-info/ +*.egg +dist/ +build/ +*.pyc +*.pyo +.eggs/ \ No newline at end of file diff --git a/app/home/schemas/home.py b/app/home/schemas/home.py index 9a4a997..89a17c1 100644 --- a/app/home/schemas/home.py +++ b/app/home/schemas/home.py @@ -26,6 +26,10 @@ class GenerateRequestInfo(BaseModel): region: str = Field(..., description="지역명") detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") attribute: AttributeInfo = Field(..., description="음악 속성 정보") + language: str = Field( + default="Korean", + description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) class GenerateRequest(GenerateRequestInfo): @@ -46,6 +50,7 @@ class GenerateRequest(GenerateRequestInfo): "tempo": "110 BPM", "mood": "happy", }, + "language": "Korean", } } ) @@ -69,6 +74,7 @@ class GenerateUrlsRequest(GenerateRequestInfo): "tempo": "110 BPM", "mood": "happy", }, + "language": "Korean", "images": [ {"url": "https://example.com/images/image_001.jpg"}, {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, diff --git a/app/home/worker/main_task.py b/app/home/worker/main_task.py index 7300b5f..84c77d8 100644 --- a/app/home/worker/main_task.py +++ b/app/home/worker/main_task.py @@ -44,12 +44,14 @@ async def lyric_task( customer_name: str, region: str, detail_region_info: str, + language: str = "Korean", ) -> None: """가사 생성 작업: ChatGPT로 가사 생성 및 Lyric 테이블 저장/업데이트""" service = ChatgptService( customer_name=customer_name, region=region, detail_region_info=detail_region_info, + language=language, ) # Lyric 레코드 저장 (status=processing, lyric_result=null) @@ -73,13 +75,15 @@ async def _task_process_async(request_body: GenerateRequest, task_id: str, proje customer_name = request_body.customer_name region = request_body.region detail_region_info = request_body.detail_region_info or "" + language = request_body.language print(f"customer_name: {customer_name}") print(f"region: {region}") print(f"detail_region_info: {detail_region_info}") + print(f"language: {language}") # 가사 생성 작업 - await lyric_task(task_id, project_id, customer_name, region, detail_region_info) + await lyric_task(task_id, project_id, customer_name, region, detail_region_info, language) def task_process(request_body: GenerateRequest, task_id: str, project_id: int) -> None: diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index f1c0d84..6eafb39 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -32,11 +32,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session from app.lyric.models import Lyric from app.lyric.schemas.lyric import ( + GenerateLyricRequest, + GenerateLyricResponse, LyricDetailResponse, LyricListItem, LyricStatusResponse, PaginatedResponse, ) +from app.utils.chatgpt_prompt import ChatgptService router = APIRouter(prefix="/lyric", tags=["lyric"]) @@ -214,6 +217,84 @@ async def get_lyrics_paginated( # ============================================================================= +@router.post( + "/generate", + summary="가사 생성", + description=""" +고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. + +## 요청 필드 +- **customer_name**: 고객명/가게명 (필수) +- **region**: 지역명 (필수) +- **detail_region_info**: 상세 지역 정보 (선택) +- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese) + +## 반환 정보 +- **success**: 생성 성공 여부 +- **lyric**: 생성된 가사 +- **language**: 가사 언어 +- **prompt_used**: 사용된 프롬프트 +- **error_message**: 에러 메시지 (실패 시) + +## 사용 예시 +``` +POST /lyric/generate +{ + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "language": "English" +} +``` + """, + response_model=GenerateLyricResponse, + responses={ + 200: {"description": "가사 생성 성공"}, + 500: {"description": "가사 생성 실패"}, + }, +) +async def generate_lyric( + request_body: GenerateLyricRequest, +) -> GenerateLyricResponse: + """고객 정보를 기반으로 가사를 생성합니다.""" + try: + service = ChatgptService( + customer_name=request_body.customer_name, + region=request_body.region, + detail_region_info=request_body.detail_region_info or "", + language=request_body.language, + ) + + prompt = service.build_lyrics_prompt() + result = await service.generate(prompt=prompt) + + # ERROR가 포함되어 있으면 실패 처리 + if "ERROR:" in result: + return GenerateLyricResponse( + success=False, + lyric=None, + language=request_body.language, + prompt_used=prompt, + error_message=result, + ) + + return GenerateLyricResponse( + success=True, + lyric=result, + language=request_body.language, + prompt_used=prompt, + error_message=None, + ) + except Exception as e: + return GenerateLyricResponse( + success=False, + lyric=None, + language=request_body.language, + prompt_used=None, + error_message=str(e), + ) + + @router.get( "/status/{task_id}", summary="가사 생성 상태 조회", diff --git a/app/lyric/schemas/lyric.py b/app/lyric/schemas/lyric.py index fcfe18a..ac091d6 100644 --- a/app/lyric/schemas/lyric.py +++ b/app/lyric/schemas/lyric.py @@ -29,6 +29,65 @@ from typing import Generic, List, Optional, TypeVar from pydantic import BaseModel, Field +class GenerateLyricRequest(BaseModel): + """가사 생성 요청 스키마 + + Usage: + POST /lyric/generate + Request body for generating lyrics. + + Example Request: + { + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "language": "Korean" + } + """ + + model_config = { + "json_schema_extra": { + "example": { + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "language": "Korean", + } + } + } + + customer_name: str = Field(..., description="고객명/가게명") + region: str = Field(..., description="지역명") + detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") + language: str = Field( + default="Korean", + description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + +class GenerateLyricResponse(BaseModel): + """가사 생성 응답 스키마 + + Usage: + POST /lyric/generate + Returns the generated lyrics. + + Example Response: + { + "success": true, + "lyric": "생성된 가사...", + "language": "Korean", + "prompt_used": "..." + } + """ + + success: bool = Field(..., description="생성 성공 여부") + lyric: Optional[str] = Field(None, description="생성된 가사") + language: str = Field(..., description="가사 언어") + prompt_used: Optional[str] = Field(None, description="사용된 프롬프트") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + class LyricStatusResponse(BaseModel): """가사 상태 조회 응답 스키마 diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py new file mode 100644 index 0000000..ec460eb --- /dev/null +++ b/app/song/api/routers/v1/song.py @@ -0,0 +1,196 @@ +""" +Song API Router + +이 모듈은 Suno API를 통한 노래 생성 관련 API 엔드포인트를 정의합니다. + +엔드포인트 목록: + - POST /song/generate: 노래 생성 요청 + - GET /song/status/{task_id}: 노래 생성 상태 조회 + +사용 예시: + from app.song.api.routers.v1.song import router + app.include_router(router, prefix="/api/v1") +""" + +from fastapi import APIRouter + +from app.song.schemas.song_schema import ( + GenerateSongRequest, + GenerateSongResponse, + PollingSongResponse, + SongClipData, +) +from app.utils.suno import SunoService + + +def _parse_suno_status_response(result: dict) -> PollingSongResponse: + """Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다.""" + code = result.get("code", 0) + data = result.get("data", {}) + + if code != 200: + return PollingSongResponse( + success=False, + status="failed", + message="Suno API 응답 오류", + clips=None, + raw_response=result, + error_message=result.get("msg", "Unknown error"), + ) + + status = data.get("status", "unknown") + clips_data = data.get("data", []) + + # 상태별 메시지 + status_messages = { + "pending": "노래 생성 대기 중입니다.", + "processing": "노래를 생성하고 있습니다.", + "complete": "노래 생성이 완료되었습니다.", + "failed": "노래 생성에 실패했습니다.", + } + + # 클립 데이터 파싱 + 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"), + title=clip.get("title"), + status=clip.get("status"), + duration=clip.get("duration"), + ) + for clip in clips_data + ] + + return PollingSongResponse( + success=True, + status=status, + message=status_messages.get(status, f"상태: {status}"), + clips=clips, + raw_response=result, + error_message=None, + ) + +router = APIRouter(prefix="/song", tags=["song"]) + + +@router.post( + "/generate", + summary="노래 생성 요청", + description=""" +Suno API를 통해 노래 생성을 요청합니다. + +## 요청 필드 +- **lyrics**: 노래에 사용할 가사 (필수) +- **genre**: 음악 장르 (필수) - K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등 +- **language**: 노래 언어 (선택, 기본값: Korean) + +## 반환 정보 +- **success**: 요청 성공 여부 +- **task_id**: Suno 작업 ID (폴링에 사용) +- **message**: 응답 메시지 + +## 사용 예시 +``` +POST /song/generate +{ + "lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께", + "genre": "K-Pop", + "language": "Korean" +} +``` + +## 참고 +- 생성되는 노래는 약 1분 이내 길이입니다. +- task_id를 사용하여 /status/{task_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. + """, + response_model=GenerateSongResponse, + responses={ + 200: {"description": "노래 생성 요청 성공"}, + 500: {"description": "노래 생성 요청 실패"}, + }, +) +async def generate_song( + request_body: GenerateSongRequest, +) -> GenerateSongResponse: + """가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.""" + try: + suno_service = SunoService() + + task_id = await suno_service.generate( + prompt=request_body.lyrics, + genre=request_body.genre, + ) + + return GenerateSongResponse( + success=True, + task_id=task_id, + message="노래 생성 요청이 접수되었습니다. task_id로 상태를 조회하세요.", + error_message=None, + ) + except Exception as e: + return GenerateSongResponse( + success=False, + task_id=None, + message="노래 생성 요청에 실패했습니다.", + error_message=str(e), + ) + + +@router.get( + "/status/{task_id}", + summary="노래 생성 상태 조회", + description=""" +Suno API를 통해 노래 생성 작업의 상태를 조회합니다. + +## 경로 파라미터 +- **task_id**: 노래 생성 시 반환된 작업 ID (필수) + +## 반환 정보 +- **success**: 조회 성공 여부 +- **status**: 작업 상태 (pending, processing, complete, failed) +- **message**: 상태 메시지 +- **clips**: 생성된 노래 클립 목록 (완료 시) +- **raw_response**: Suno API 원본 응답 + +## 사용 예시 +``` +GET /song/status/abc123... +``` + +## 상태 값 +- **pending**: 대기 중 +- **processing**: 생성 중 +- **complete**: 생성 완료 +- **failed**: 생성 실패 + +## 참고 +- 스트림 URL: 30-40초 내 생성 +- 다운로드 URL: 2-3분 내 생성 + """, + response_model=PollingSongResponse, + responses={ + 200: {"description": "상태 조회 성공"}, + 500: {"description": "상태 조회 실패"}, + }, +) +async def get_song_status( + task_id: str, +) -> PollingSongResponse: + """task_id로 노래 생성 작업의 상태를 조회합니다.""" + try: + suno_service = SunoService() + result = await suno_service.get_task_status(task_id) + return _parse_suno_status_response(result) + except Exception as e: + return PollingSongResponse( + success=False, + status="error", + message="상태 조회에 실패했습니다.", + clips=None, + raw_response=None, + error_message=str(e), + ) diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index ec3a5e9..2e1c327 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -1,8 +1,130 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, List +from typing import Any, Dict, List, Optional from fastapi import Request +from pydantic import BaseModel, Field + + +# ============================================================================= +# Pydantic Schemas for Song Generation API +# ============================================================================= + + +class GenerateSongRequest(BaseModel): + """노래 생성 요청 스키마 + + Usage: + POST /song/generate + Request body for generating a song via Suno API. + + Example Request: + { + "lyrics": "인스타 감성의 스테이 머뭄...", + "genre": "k-pop", + "language": "Korean" + } + """ + + model_config = { + "json_schema_extra": { + "example": { + "lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요", + "genre": "k-pop", + "language": "Korean", + } + } + } + + lyrics: str = Field(..., description="노래에 사용할 가사") + genre: str = Field( + ..., + description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)", + ) + language: str = Field( + default="Korean", + description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", + ) + + +class GenerateSongResponse(BaseModel): + """노래 생성 응답 스키마 + + Usage: + POST /song/generate + Returns the task ID for tracking song generation. + + Example Response: + { + "success": true, + "task_id": "abc123...", + "message": "노래 생성 요청이 접수되었습니다." + } + """ + + success: bool = Field(..., description="요청 성공 여부") + task_id: Optional[str] = Field(None, description="Suno 작업 ID") + message: str = Field(..., description="응답 메시지") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class PollingSongRequest(BaseModel): + """노래 생성 상태 조회 요청 스키마 + + Usage: + POST /song/polling + Request body for checking song generation status. + + Example Request: + { + "task_id": "abc123..." + } + """ + + task_id: str = Field(..., description="Suno 작업 ID") + + +class SongClipData(BaseModel): + """생성된 노래 클립 정보""" + + id: Optional[str] = Field(None, description="클립 ID") + audio_url: Optional[str] = Field(None, description="오디오 URL") + stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL") + image_url: Optional[str] = Field(None, description="이미지 URL") + title: Optional[str] = Field(None, description="곡 제목") + status: Optional[str] = Field(None, description="클립 상태") + duration: Optional[float] = Field(None, description="노래 길이 (초)") + + +class PollingSongResponse(BaseModel): + """노래 생성 상태 조회 응답 스키마 + + Usage: + POST /song/polling 또는 GET /song/status/{task_id} + Returns the current status of song generation. + + Example Response: + { + "success": true, + "status": "complete", + "message": "노래 생성이 완료되었습니다.", + "clips": [...] + } + """ + + success: bool = Field(..., description="조회 성공 여부") + status: Optional[str] = Field( + None, description="작업 상태 (pending, processing, complete, failed)" + ) + message: str = Field(..., description="상태 메시지") + clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록") + raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +# ============================================================================= +# Dataclass Schemas (Legacy) +# ============================================================================= @dataclass diff --git a/app/utils/chatgpt_prompt.py b/app/utils/chatgpt_prompt.py index 326684e..4514fba 100644 --- a/app/utils/chatgpt_prompt.py +++ b/app/utils/chatgpt_prompt.py @@ -40,12 +40,13 @@ Deliver outputs optimized for three formats:1 minute. Ensure that each version a LYRICS_PROMPT_TEMPLATE = """ [ROLE] -Content marketing expert specializing in pension/accommodation services in Korea +Content marketing expert and creative songwriter specializing in pension/accommodation services [INPUT] - Business Name: {customer_name} - Region: {region} - Region Details: {detail_region_info} +- Output Language: {language} [INTERNAL ANALYSIS - DO NOT OUTPUT] Analyze the following internally to inform lyrics creation: @@ -53,24 +54,59 @@ Analyze the following internally to inform lyrics creation: - Unique Selling Propositions (USPs) - Regional characteristics and nearby attractions (within 10 min access) - Seasonal appeal points +- Emotional triggers for the target audience [LYRICS REQUIREMENTS] -- Must include: business name, region name, main target audience, nearby famous places -- Keywords to incorporate: 인스타 감성, 사진같은 하루, 힐링, 여행 -- Length: For 1-minute video (approximately 8-12 lines) -- Tone: Emotional, trendy, viral-friendly +1. Must Include Elements: + - Business name (TRANSLATED or TRANSLITERATED to {language}) + - Region name (TRANSLATED or TRANSLITERATED to {language}) + - Main target audience appeal + - Nearby famous places or regional characteristics + +2. Keywords to Incorporate (use language-appropriate trendy expressions): + - Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소 + - English: Instagram vibes, picture-perfect day, healing, travel, getaway + - Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地 + - Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景 + - Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย + - Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp + +3. Structure: + - Length: For 1-minute video (approximately 8-12 lines) + - Flow: Verse structure suitable for music + - Rhythm: Natural speech rhythm in the specified language + +4. Tone: + - Emotional and heartfelt + - Trendy and viral-friendly + - Relatable to target audience + +[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] +ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS +- ALL lyrics content: {language} ONLY +- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language} +- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc. +- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc. +- ZERO Korean characters (한글) allowed when output language is NOT Korean +- ZERO mixing of languages - the entire output must be monolingual in {language} +- This is a NON-NEGOTIABLE requirement +- Any output containing characters from other languages is considered a COMPLETE FAILURE +- Violation of this rule invalidates the entire response [OUTPUT RULES - STRICTLY ENFORCED] - Output lyrics ONLY -- Lyrics MUST be written in Korean (한국어) +- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS +- ALL names and places MUST be in {language} script/alphabet +- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language - NO titles, descriptions, analysis, or explanations - NO greetings or closing remarks - NO additional commentary before or after lyrics +- NO line numbers or labels - Follow the exact format below [OUTPUT FORMAT - SUCCESS] --- -[Lyrics in Korean here] +[Lyrics ENTIRELY in {language} here - no other language characters allowed] --- [OUTPUT FORMAT - FAILURE] @@ -181,6 +217,7 @@ class ChatgptService: customer_name: str, region: str, detail_region_info: str = "", + language: str = "Korean", ): # 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano # 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo @@ -189,6 +226,7 @@ class ChatgptService: self.customer_name = customer_name self.region = region self.detail_region_info = detail_region_info + self.language = language def build_lyrics_prompt(self) -> str: """LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" @@ -196,6 +234,7 @@ class ChatgptService: customer_name=self.customer_name, region=self.region, detail_region_info=self.detail_region_info, + language=self.language, ) def build_market_analysis_prompt(self) -> str: diff --git a/app/utils/suno.py b/app/utils/suno.py new file mode 100644 index 0000000..8b9c42f --- /dev/null +++ b/app/utils/suno.py @@ -0,0 +1,172 @@ +""" +Suno API 클라이언트 모듈 + +API 문서: https://docs.sunoapi.org + +## 사용법 +```python +from app.utils.suno import SunoService + +# config에서 자동으로 API 키를 가져옴 +suno = SunoService() + +# 또는 명시적으로 API 키 전달 +suno = SunoService(api_key="your_api_key") + +# 음악 생성 요청 +task_id = await suno.generate( + prompt="[Verse]\\n오늘도 좋은 하루...", + style="K-Pop, Happy, 110 BPM", + title="좋은 하루" +) + +# 상태 확인 (폴링 방식) +result = await suno.get_task_status(task_id) +``` + +## 콜백 URL 사용법 +generate() 호출 시 callback_url 파라미터를 전달하면 생성 완료 시 해당 URL로 POST 요청이 전송됩니다. +콜백 요청 형식: +```json +{ + "code": 200, + "msg": "All generated successfully.", + "data": { + "callbackType": "complete", + "task_id": "작업ID", + "data": [ + { + "id": "clip_id", + "audio_url": "https://...", + "image_url": "https://...", + "title": "곡 제목", + "status": "complete" + } + ] + } +} +``` +콜백 주의사항: +- HTTPS 프로토콜 권장 +- 15초 내 응답 필수 +- 동일 task_id에 대해 여러 콜백 수신 가능 (멱등성 처리 필요) +""" + +from typing import Any + +import httpx + +from config import apikey_settings + + +class SunoService: + """Suno API를 통한 AI 음악 생성 서비스""" + + BASE_URL = "https://api.sunoapi.org/api/v1" + + def __init__(self, api_key: str | None = None): + """ + Args: + api_key: Suno API 키 (Bearer token으로 사용) + None일 경우 config에서 자동으로 가져옴 + """ + self.api_key = api_key or apikey_settings.SUNO_API_KEY + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + async def generate( + self, + prompt: str, + genre: str | None = None, + callback_url: str | None = None, + ) -> str: + """ + 음악 생성 요청 + + Args: + prompt: 가사 (customMode=true일 때 가사로 사용) + 1분 이내 길이의 노래에 적합한 가사여야 함 + genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM") + None일 경우 style 파라미터를 전송하지 않음 + callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용) + + Returns: + task_id: 작업 추적용 ID + + Note: + - 스트림 URL: 30-40초 내 생성 + - 다운로드 URL: 2-3분 내 생성 + - 생성되는 노래는 약 1분 이내의 길이 + """ + # 1분 이내 노래 생성을 위한 프롬프트 조건 추가 + formatted_prompt = f"[Short Song - Under 1 minute]\n{prompt}" + + # callback_url이 없으면 config에서 기본값 사용 (Suno API 필수 파라미터) + actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL + + payload: dict[str, Any] = { + "model": "V5", + "customMode": True, + "instrumental": False, + "prompt": formatted_prompt, + "callBackUrl": actual_callback_url, + } + + # genre가 있을 때만 style 추가 + if genre: + payload["style"] = genre + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/generate", + headers=self.headers, + json=payload, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + # 응답: {"code": 200, "msg": "success", "data": {"taskId": "..."}} + # API 응답 검증 + if data is None: + raise ValueError("Suno API returned empty response") + + if data.get("code") != 200: + error_msg = data.get("msg", "Unknown error") + raise ValueError(f"Suno API error: {error_msg}") + + response_data = data.get("data") + if response_data is None: + raise ValueError(f"Suno API response missing 'data' field: {data}") + + task_id = response_data.get("taskId") + if task_id is None: + raise ValueError(f"Suno API response missing 'taskId': {response_data}") + + return task_id + + async def get_task_status(self, task_id: str) -> dict[str, Any]: + """ + 음악 생성 작업 상태 확인 + + Args: + task_id: generate()에서 반환된 작업 ID + + Returns: + 작업 상태 정보 (status, audio_url, image_url 등 포함) + + Note: + 폴링 방식으로 상태 확인 시 사용. + 콜백 URL을 사용하면 폴링 없이 결과를 받을 수 있음. + """ + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.BASE_URL}/generate/record-info", + headers=self.headers, + params={"taskId": task_id}, + timeout=30.0, + ) + response.raise_for_status() + return response.json() diff --git a/config.py b/config.py index 751814b..a27c8b4 100644 --- a/config.py +++ b/config.py @@ -25,6 +25,10 @@ class ProjectSettings(BaseSettings): class APIKeySettings(BaseSettings): CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 + SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키 + SUNO_CALLBACK_URL: str = Field( + default="https://example.com/api/suno/callback" + ) # Suno 콜백 URL (필수) model_config = _base_config diff --git a/main.py b/main.py index 7d988b1..0d9bd14 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ from app.core.common import lifespan from app.database.session import engine from app.home.api.routers.v1.home import router as home_router from app.lyric.api.routers.v1.lyric import router as lyric_router +from app.song.api.routers.v1.song import router as song_router from app.utils.cors import CustomCORSMiddleware from config import prj_settings @@ -48,3 +49,4 @@ def get_scalar_docs(): app.include_router(home_router) app.include_router(lyric_router) # Lyric API 라우터 추가 +app.include_router(song_router) # Song API 라우터 추가