여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
123 lines
4.0 KiB
Python
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
|