/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>
159 lines
6.7 KiB
Python
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
|