o2o-site-AEO/solution/backend/services/external/suno.py
hbyang 38bb4be9e5 [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno
/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.

★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
  먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
  값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
  단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
  실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.

★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
  것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
  노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
  가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
  없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.

★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
  죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
  "요청은 성공했는데 결과가 영영 안 옴" 이 된다.

- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
  수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
  (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
  패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
  못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
  업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL

검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:21:16 +09:00

159 lines
6.7 KiB
Python

"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다.
API 문서 https://docs.sunoapi.org
가사 services/external/gemini_text.generate_song (여기는 작곡만 한다)
쓰는 곳 services/song_service (잡 흐름·저장)
★ **콜백을 쓰지 않고 폴링한다.**
Suno 는 완료 시 `callBackUrl` 로 POST 를 보내 주는데, 그러려면 Suno 쪽에서 우리 백엔드에
닿아야 한다. 이 서버는 로컬(:9800)이거나 사내망(킹서버)이라 그런 주소가 없다 —
콜백을 믿게 만들어 두면 "요청은 성공했는데 결과가 영영 안 옴" 이 되고, 그건 화면상
아무 일도 안 일어나는 종류의 실패다. 그래서 `generate` 로 taskId 를 받고 `record-info` 를
직접 물어본다. 잡 워커에서 도는 코드라 몇 분 기다리는 것이 문제가 되지 않는다.
(`callBackUrl` 은 API 가 필수로 요구해서 값만 채워 보낸다. 우리는 그 주소를 듣지 않는다.)
★ **한 요청에 곡이 두 편 온다.** Suno 는 같은 가사로 변주 두 개를 만들어 준다(sunoData 배열).
우리는 **첫 번째 한 곡만** 쓴다 — 사장님에게 고르라고 묻는 화면이 없고, 두 곡을 다 실으면
손님이 무엇을 듣는지 우리도 모른다.
★ **오디오 주소는 만료된다.** 여기서 돌려주는 `audio_url` 을 그대로 사이트에 싣지 않는다.
받는 쪽(song_service)이 파일을 내려받아 우리 쪽에 보관한다.
"""
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"
# 실측(참고 프로젝트 o2o-castad-backend): 스트림 주소는 30~40초, 내려받을 수 있는 주소는 2~3분.
# 우리는 파일을 받아야 하므로 뒤쪽 기준으로 기다린다.
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):
"""호출 실패·거절. 잡의 last_error 로 남는다."""
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:
"""작곡 요청. taskId 를 돌려준다.
★ `customMode=True` 다 — prompt 를 '주제' 가 아니라 **가사 그대로** 쓰라는 뜻이다.
false 로 두면 Suno 가 가사를 자기가 새로 쓴다. 우리는 이 숙소의 사실로 쓴 가사를
넘기는 것이므로, 그걸 버리면 이 기능의 의미가 없다.
"""
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]:
"""완성된 클립 하나. 아직이면 None.
★ 상태 문자열을 믿기 전에 **주소가 실제로 있는지** 본다. SUCCESS 인데 audioUrl 이
아직 비어 오는 응답을 참고 프로젝트가 겪었다(스트림만 먼저 나오는 구간).
"""
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]:
"""완성될 때까지 물어본다. 돌려주는 것은 첫 클립 하나.
★ 상한(POLL_TIMEOUT_SEC)을 둔다. Suno 가 영영 안 끝내는 경우 잡이 그대로 매달리면
워커 한 자리를 계속 차지한다 — 노래 하나 때문에 다른 사업장의 수집이 멈춘다.
"""
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