"""Suno 음악 생성 클라이언트 — sunoapi.org 서드파티 게이트웨이. 공식 Suno API 가 없어 게이트웨이를 쓴다. 흐름: POST /api/v1/generate (customMode: 가사·스타일·제목) → taskId GET /api/v1/generate/record-info?taskId= → status 폴링 → sunoData[] (보통 2곡) 오디오/커버는 게이트웨이 CDN URL 을 그대로 쓴다 (당일 소비 콘텐츠). """ from __future__ import annotations import logging from ..config import settings log = logging.getLogger("triplepick.suno") # record-info status 값 SUNO_DONE = "SUCCESS" SUNO_FAILED = { "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR", } class SunoUnavailable(RuntimeError): """SUNO_API_KEY 미설정 등으로 호출 불가.""" class SunoError(RuntimeError): """게이트웨이가 에러 코드를 반환.""" def _headers() -> dict: if not settings.suno_api_key: raise SunoUnavailable("SUNO_API_KEY 미설정") return { "Authorization": f"Bearer {settings.suno_api_key}", "Content-Type": "application/json", } async def start_generation(title: str, style: str, lyrics: str) -> str: """생성 작업 시작 → taskId. customMode: 가사(prompt)·style·title 을 그대로 사용.""" import httpx payload = { "prompt": lyrics[:4900], "style": style[:950], "title": title[:80], "customMode": True, "instrumental": False, "model": settings.suno_model, # 게이트웨이 필수 파라미터 — 실제 완료 감지는 폴링으로 한다(콜백은 싱크대). "callBackUrl": f"{settings.public_origin}/api/songs/callback", } async with httpx.AsyncClient(timeout=30) as c: r = await c.post( f"{settings.suno_api_base}/api/v1/generate", json=payload, headers=_headers(), ) r.raise_for_status() data = r.json() if data.get("code") != 200 or not (data.get("data") or {}).get("taskId"): raise SunoError(f"generate 실패: {data.get('code')} {data.get('msg')}") return data["data"]["taskId"] async def get_task(task_id: str) -> dict: """작업 상태 조회 — {status, response: {sunoData: [...]}} 형태의 data 반환.""" import httpx async with httpx.AsyncClient(timeout=30) as c: r = await c.get( f"{settings.suno_api_base}/api/v1/generate/record-info", params={"taskId": task_id}, headers=_headers(), ) r.raise_for_status() data = r.json() if data.get("code") != 200: raise SunoError(f"record-info 실패: {data.get('code')} {data.get('msg')}") return data.get("data") or {} def extract_tracks(task_data: dict) -> list[dict]: """record-info data → 표준 트랙 목록 [{title, audioUrl, imageUrl, duration}].""" items = ((task_data.get("response") or {}).get("sunoData")) or [] out = [] for it in items: url = it.get("audioUrl") or it.get("sourceAudioUrl") or "" if not url: continue out.append( { "title": it.get("title") or "", "audioUrl": url, "imageUrl": it.get("imageUrl") or it.get("sourceImageUrl") or "", "duration": it.get("duration"), } ) return out