"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다.""" import asyncio from typing import Any, Optional import httpx from common.logger import LOG from config.server_configs import external_api_config BASE_URL = "https://api.sunoapi.org/api/v1" POLL_INTERVAL_SEC = 10 POLL_TIMEOUT_SEC = 300 REQUEST_TIMEOUT = httpx.Timeout(60.0, connect=10.0) # 40초짜리를 만든다. SONG_SECONDS = 40 MODEL = "V5" class SunoNotConfigured(RuntimeError): """SUNO_API_KEY 가 없다 — 노래만 건너뛰고 발행은 계속한다.""" class SunoError(RuntimeError): """호출 실패·거절.""" def is_configured() -> bool: return bool((external_api_config.suno_api_key or "").strip()) def _headers() -> dict: return { "Authorization": f"Bearer {external_api_config.suno_api_key}", "Content-Type": "application/json", } async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncClient) -> str: """작곡 요청.""" if not is_configured(): raise SunoNotConfigured("SUNO_API_KEY 미설정") body = { "model": MODEL, "customMode": True, "instrumental": False, # 길이는 API 파라미터가 아니라 프롬프트로 지시한다(참고 프로젝트와 같은 방식). "prompt": f"[Song Duration: Around {SONG_SECONDS} seconds]\n{lyrics}", "title": title[:80], "style": style, # 듣지 않는 주소다(머리주석). "callBackUrl": external_api_config.suno_callback_url or "https://example.com/api/suno/callback", } try: res = await client.post(f"{BASE_URL}/generate", headers=_headers(), json=body, timeout=REQUEST_TIMEOUT) except httpx.HTTPError as ex: raise SunoError(f"generate 호출 실패: {type(ex).__name__}: {ex}") from ex if res.status_code != 200: raise SunoError(f"generate HTTP {res.status_code}: {res.text[:300]}") data = res.json() or {} if data.get("code") != 200: raise SunoError(f"generate 거절: {data.get('msg')}") task_id = ((data.get("data") or {}).get("taskId")) if not task_id: raise SunoError(f"generate 응답에 taskId 가 없다: {str(data)[:300]}") return task_id def _first_clip(payload: dict) -> Optional[dict]: """완성된 클립 하나.""" data = (payload or {}).get("data") or {} status = (data.get("status") or "").upper() if status in {"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR"}: raise SunoError(f"작곡 실패: {status} {data.get('errorMessage') or ''}".strip()) clips = ((data.get("response") or {}).get("sunoData")) or [] for clip in clips: if clip.get("audioUrl") or clip.get("sourceAudioUrl"): return clip return None async def wait_for_clip(task_id: str, *, client: httpx.AsyncClient) -> dict[str, Any]: """완성될 때까지 물어본다.""" waited = 0 while waited < POLL_TIMEOUT_SEC: await asyncio.sleep(POLL_INTERVAL_SEC) waited += POLL_INTERVAL_SEC try: res = await client.get( f"{BASE_URL}/generate/record-info", headers=_headers(), params={"taskId": task_id}, timeout=REQUEST_TIMEOUT, ) res.raise_for_status() except httpx.HTTPError as ex: # 폴링 한 번 실패는 실패가 아니다 — 다음 차례에 다시 묻는다. LOG.w(f"[suno] 상태 조회 실패(계속 기다린다) task={task_id}: {type(ex).__name__}") continue clip = _first_clip(res.json() or {}) if clip: LOG.i(f"[suno] 작곡 완료 task={task_id} ({waited}초)") return clip raise SunoError(f"{POLL_TIMEOUT_SEC}초 안에 완성되지 않았다 task={task_id}") async def download(url: str, *, client: httpx.AsyncClient) -> bytes: """오디오 파일을 받아 온다.""" res = await client.get(url, timeout=httpx.Timeout(180.0, connect=10.0), follow_redirects=True) res.raise_for_status() return res.content