o2o-site-AEO/solution/backend/services/external/suno.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

123 lines
4.0 KiB
Python

"""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