Code review found: f"{task_id}_v2" / "_inst" synthetic task IDs never
exist in NAS music_tasks table -> webhook returns 404 -> silent fail.
NAS music-lab/main.py._sync_library_with_disk() auto-registers any
.mp3 in the disk that has no DB row on next GET /api/music/library.
So Windows worker just writes the file to SMB; NAS picks it up on
the next library fetch -- matches NAS source behavior at file level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
691 lines
32 KiB
Python
691 lines
32 KiB
Python
"""Suno API Provider — sunoapi.org 래퍼.
|
|
|
|
NAS music-lab/app/suno_provider.py에서 이식. 차이점:
|
|
- DB 호출(update_task, add_track 등)을 nas_client.webhook_* 으로 변환
|
|
- 결과 MP3는 MUSIC_MEDIA_ROOT (/mnt/nas/webpage/data/music/)에 직접 저장
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Optional
|
|
|
|
import requests
|
|
|
|
from nas_client import webhook_update_task, webhook_add_track
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SUNO_BASE_URL = "https://api.sunoapi.org/api/v1"
|
|
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")
|
|
MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")
|
|
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music")
|
|
|
|
POLL_INTERVAL = 8
|
|
POLL_MAX_ATTEMPTS = 40
|
|
|
|
|
|
def _headers() -> dict:
|
|
return {
|
|
"Authorization": f"Bearer {SUNO_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
def _build_suno_payload(params: dict) -> dict:
|
|
"""프론트엔드 params → sunoapi.org 요청 형식 (NAS 코드 그대로 이식)."""
|
|
instrumental = params.get("instrumental", False)
|
|
has_lyrics = bool(params.get("lyrics"))
|
|
custom_mode = has_lyrics or bool(params.get("genre")) or bool(params.get("moods"))
|
|
|
|
payload = {
|
|
"customMode": custom_mode,
|
|
"instrumental": instrumental,
|
|
"model": params.get("model", "V4"),
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
|
|
if custom_mode:
|
|
if instrumental:
|
|
payload["prompt"] = ""
|
|
elif has_lyrics:
|
|
payload["prompt"] = params["lyrics"][:3000]
|
|
else:
|
|
prompt_text = params.get("prompt", "")
|
|
payload["prompt"] = prompt_text[:3000] if prompt_text else ""
|
|
|
|
style_parts = []
|
|
if params.get("genre"):
|
|
style_parts.append(params["genre"])
|
|
if params.get("moods"):
|
|
style_parts.extend(params["moods"])
|
|
if params.get("instruments"):
|
|
style_parts.extend(params["instruments"][:3])
|
|
if style_parts:
|
|
payload["style"] = ", ".join(style_parts)[:200]
|
|
|
|
if params.get("title"):
|
|
payload["title"] = params["title"][:80]
|
|
else:
|
|
parts = []
|
|
if params.get("prompt"):
|
|
parts.append(params["prompt"])
|
|
if params.get("genre"):
|
|
parts.append(params["genre"])
|
|
if params.get("moods"):
|
|
parts.append(", ".join(params["moods"]))
|
|
payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music"
|
|
|
|
if params.get("vocal_gender"):
|
|
payload["vocalGender"] = params["vocal_gender"]
|
|
if params.get("negative_tags"):
|
|
payload["negativeTags"] = params["negative_tags"]
|
|
if params.get("style_weight") is not None:
|
|
payload["styleWeight"] = params["style_weight"]
|
|
if params.get("audio_weight") is not None:
|
|
payload["audioWeight"] = params["audio_weight"]
|
|
|
|
return payload
|
|
|
|
|
|
def _poll_suno_record(
|
|
record_info_path: str,
|
|
suno_task_id: str,
|
|
task_id: str,
|
|
max_attempts: int = POLL_MAX_ATTEMPTS,
|
|
interval: int = POLL_INTERVAL,
|
|
progress_msg_map: dict = None,
|
|
) -> Optional[dict]:
|
|
"""범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환."""
|
|
error_statuses = {
|
|
"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
|
|
"CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
|
|
}
|
|
default_msgs = {
|
|
"PENDING": "대기열에서 대기 중...",
|
|
"TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
|
|
"FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
|
|
"GENERATING": "생성 중...",
|
|
}
|
|
msgs = {**default_msgs, **(progress_msg_map or {})}
|
|
|
|
for attempt in range(max_attempts):
|
|
time.sleep(interval)
|
|
try:
|
|
resp = requests.get(
|
|
f"{SUNO_BASE_URL}{record_info_path}",
|
|
headers=_headers(),
|
|
params={"taskId": suno_task_id},
|
|
timeout=15,
|
|
)
|
|
if resp.status_code != 200:
|
|
continue
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
continue
|
|
data = body.get("data", {})
|
|
status = data.get("status", "")
|
|
progress = min(15 + int((attempt / max_attempts) * 65), 79)
|
|
|
|
if status == "SUCCESS":
|
|
return data.get("response", data)
|
|
elif status in error_statuses:
|
|
error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
|
|
webhook_update_task(task_id, "failed", 0, "", error=error_msg)
|
|
return None
|
|
else:
|
|
msg = msgs.get(status, f"처리 중... ({status})")
|
|
if status == "FIRST_SUCCESS":
|
|
progress = max(progress, 60)
|
|
webhook_update_task(task_id, "processing", progress, msg)
|
|
except Exception as e:
|
|
logger.warning("Suno poll error (attempt %d): %s", attempt, e)
|
|
continue
|
|
|
|
webhook_update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
|
|
return None
|
|
|
|
|
|
def _download_and_register(
|
|
task_id: str, song: dict, params: dict, filename_suffix: str = "",
|
|
) -> Optional[dict]:
|
|
"""Suno CDN에서 MP3 다운로드 → /mnt/nas/...에 직접 저장 → webhook으로 add_track."""
|
|
audio_url_remote = song.get("audioUrl") or song.get("audio_url", "")
|
|
if not audio_url_remote:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 audioUrl이 없습니다")
|
|
return None
|
|
|
|
filename = f"{task_id}{filename_suffix}.mp3"
|
|
os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)
|
|
file_path = os.path.join(MUSIC_MEDIA_ROOT, filename)
|
|
|
|
try:
|
|
dl = requests.get(audio_url_remote, timeout=120, stream=True)
|
|
dl.raise_for_status()
|
|
with open(file_path, "wb") as f:
|
|
for chunk in dl.iter_content(chunk_size=8192):
|
|
f.write(chunk)
|
|
except Exception as e:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}")
|
|
return None
|
|
|
|
local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
|
|
|
|
genre = params.get("genre", song.get("tags", ""))
|
|
moods = params.get("moods", [])
|
|
mood_str = moods[0] if moods else "Original"
|
|
title = (
|
|
song.get("title")
|
|
or params.get("title")
|
|
or (f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix")
|
|
)
|
|
|
|
track_data = {
|
|
"title": title,
|
|
"genre": genre,
|
|
"moods": moods,
|
|
"instruments": params.get("instruments", []),
|
|
"duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"),
|
|
"bpm": params.get("bpm"),
|
|
"key": params.get("key", ""),
|
|
"scale": params.get("scale", ""),
|
|
"prompt": song.get("prompt", params.get("prompt", "")),
|
|
"audio_url": local_audio_url,
|
|
# NAS file_path는 NAS 관점 — /app/data 안의 경로
|
|
"file_path": f"/app/data/{filename}",
|
|
"task_id": task_id,
|
|
"provider": "suno",
|
|
"lyrics": song.get("prompt", params.get("lyrics", "")),
|
|
"image_url": song.get("imageUrl") or song.get("image_url", ""),
|
|
"suno_id": song.get("id", ""),
|
|
}
|
|
return track_data
|
|
|
|
|
|
def run_suno_generation(task_id: str, params: dict) -> None:
|
|
"""BackgroundTask: Suno API로 곡 생성 → MP3 → NAS SMB 저장 → webhook add_track."""
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정 (Windows .env)")
|
|
return
|
|
|
|
webhook_update_task(task_id, "processing", 5, "Suno API에 연결 중...")
|
|
payload = _build_suno_payload(params)
|
|
resp = requests.post(f"{SUNO_BASE_URL}/generate", headers=_headers(), json=payload, timeout=30)
|
|
|
|
if resp.status_code != 200:
|
|
err = resp.text[:300] if resp.text else f"HTTP {resp.status_code}"
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {err}")
|
|
return
|
|
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 거부: {body.get('msg', '?')}")
|
|
return
|
|
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 taskId 없음")
|
|
return
|
|
|
|
webhook_update_task(task_id, "processing", 15, "곡 생성 대기열에 등록됨...")
|
|
|
|
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Suno 완료했으나 트랙 데이터 없음")
|
|
return
|
|
|
|
webhook_update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
|
|
track = _download_and_register(task_id, completed[0], params)
|
|
if not track:
|
|
return
|
|
|
|
webhook_add_track(task_id, "succeeded", 100, "생성 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
|
|
if len(completed) > 1:
|
|
try:
|
|
# 보조 변형은 SMB에 파일만 저장. NAS _sync_library_with_disk가 다음
|
|
# GET /api/music/library 호출 시 자동으로 라이브러리에 등록.
|
|
_download_and_register(f"{task_id}_v2", completed[1], params)
|
|
except Exception:
|
|
pass
|
|
|
|
except requests.Timeout:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Suno API 타임아웃")
|
|
except Exception as e:
|
|
logger.exception("Suno generation error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_suno_extend(task_id: str, params: dict) -> None:
|
|
"""기존 곡을 특정 지점부터 연장."""
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정")
|
|
return
|
|
webhook_update_task(task_id, "processing", 5, "곡 연장 요청 중...")
|
|
payload = {
|
|
"audioId": params["suno_id"],
|
|
"defaultParamFlag": not bool(params.get("prompt")),
|
|
"prompt": params.get("prompt", ""),
|
|
"continueAt": params.get("continue_at", 0),
|
|
"model": params.get("model", "V4"),
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
if params.get("style"):
|
|
payload["style"] = params["style"]
|
|
if params.get("title"):
|
|
payload["title"] = params["title"]
|
|
|
|
resp = requests.post(f"{SUNO_BASE_URL}/generate/extend", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Suno Extend 오류: {resp.text[:300]}")
|
|
return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Extend 거부: {body.get('msg', '?')}")
|
|
return
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Extend 응답에 taskId 없음")
|
|
return
|
|
webhook_update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...")
|
|
|
|
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="연장 완료했으나 트랙 없음")
|
|
return
|
|
webhook_update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...")
|
|
track = _download_and_register(task_id, completed[0], params)
|
|
if track:
|
|
webhook_add_track(task_id, "succeeded", 100, "곡 연장 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
except Exception as e:
|
|
logger.exception("Suno extend error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_vocal_removal(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정")
|
|
return
|
|
webhook_update_task(task_id, "processing", 5, "보컬 분리 요청 중...")
|
|
payload = {"audioId": params["suno_id"], "callBackUrl": "https://example.com/noop"}
|
|
resp = requests.post(f"{SUNO_BASE_URL}/vocal-removal/generate", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Vocal Removal 오류: {resp.text[:300]}")
|
|
return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Vocal Removal 거부: {body.get('msg', '?')}")
|
|
return
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음")
|
|
return
|
|
webhook_update_task(task_id, "processing", 15, "보컬 분리 처리 중...")
|
|
response = _poll_suno_record("/vocal-removal/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="분리 완료했으나 트랙 없음")
|
|
return
|
|
webhook_update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...")
|
|
vp = {**params, "title": f"{params.get('title', 'Track')} (Vocals)"}
|
|
track = _download_and_register(task_id, completed[0], vp)
|
|
if len(completed) > 1:
|
|
ip = {**params, "title": f"{params.get('title', 'Track')} (Instrumental)"}
|
|
# Instrumental 변형은 SMB에 파일만 저장. NAS _sync_library_with_disk가 자동 등록.
|
|
_download_and_register(f"{task_id}_inst", completed[1], ip)
|
|
if track:
|
|
webhook_add_track(task_id, "succeeded", 100, "보컬 분리 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
except Exception as e:
|
|
logger.exception("vocal removal error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_cover_image(task_id: str, params: dict) -> None:
|
|
"""Suno 곡의 커버 이미지 2장 (URL JSON 반환)."""
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...")
|
|
suno_task_id = params.get("suno_task_id", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="suno_task_id 필요"); return
|
|
payload = {"taskId": suno_task_id, "callBackUrl": "https://example.com/noop"}
|
|
resp = requests.post(f"{SUNO_BASE_URL}/suno/cover/generate", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Cover API 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Cover 거부: {body.get('msg', '?')}"); return
|
|
cover_task_id = body.get("data", {}).get("taskId", suno_task_id)
|
|
webhook_update_task(task_id, "processing", 15, "커버 이미지 생성 중...")
|
|
response = _poll_suno_record(
|
|
"/suno/cover/record-info", cover_task_id, task_id,
|
|
max_attempts=30, interval=5,
|
|
progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."},
|
|
)
|
|
if not response:
|
|
return
|
|
images = response.get("images") or response.get("sunoData") or []
|
|
urls = []
|
|
if isinstance(images, list):
|
|
for img in images:
|
|
if isinstance(img, str):
|
|
urls.append(img)
|
|
elif isinstance(img, dict):
|
|
urls.append(img.get("imageUrl") or img.get("image_url", ""))
|
|
webhook_update_task(task_id, "succeeded", 100, "커버 완료",
|
|
audio_url=json.dumps(urls))
|
|
except Exception as e:
|
|
logger.exception("cover image error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_wav_convert(task_id: str, params: dict) -> None:
|
|
"""곡을 WAV 포맷으로 변환 (URL만)."""
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "WAV 변환 요청 중...")
|
|
payload = {
|
|
"taskId": params["suno_task_id"],
|
|
"audioId": params["suno_id"],
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
resp = requests.post(f"{SUNO_BASE_URL}/wav/generate", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code == 409:
|
|
body = resp.json()
|
|
wav_url = body.get("data", {}).get("audioWavUrl", "")
|
|
if wav_url:
|
|
webhook_update_task(task_id, "succeeded", 100, "WAV 캐시", audio_url=wav_url)
|
|
return
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"WAV 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"WAV 거부: {body.get('msg', '?')}"); return
|
|
wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"])
|
|
webhook_update_task(task_id, "processing", 15, "WAV 변환 처리 중...")
|
|
response = _poll_suno_record(
|
|
"/wav/record-info", wav_task_id, task_id,
|
|
max_attempts=30, interval=5,
|
|
progress_msg_map={"PENDING": "WAV 대기 중...", "GENERATING": "WAV 변환 중..."},
|
|
)
|
|
if not response:
|
|
return
|
|
wav_url = ""
|
|
sd = response.get("sunoData") or []
|
|
if sd and isinstance(sd, list) and isinstance(sd[0], dict):
|
|
wav_url = sd[0].get("audioWavUrl", "")
|
|
if not wav_url:
|
|
wav_url = response.get("audioWavUrl", "")
|
|
webhook_update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url)
|
|
except Exception as e:
|
|
logger.exception("wav convert error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_stem_split(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "12스템 분리 요청 중...")
|
|
payload = {
|
|
"taskId": params["suno_task_id"],
|
|
"audioId": params["suno_id"],
|
|
"type": "split_stem",
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
resp = requests.post(f"{SUNO_BASE_URL}/vocal-removal/generate", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Stem API 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Stem 거부: {body.get('msg', '?')}"); return
|
|
stem_task_id = body.get("data", {}).get("taskId", "")
|
|
if not stem_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
|
|
webhook_update_task(task_id, "processing", 15, "12스템 분리 처리 중 (2~3분)...")
|
|
response = _poll_suno_record(
|
|
"/vocal-removal/record-info", stem_task_id, task_id,
|
|
max_attempts=40, interval=8,
|
|
progress_msg_map={"PENDING": "스템 대기 중...", "GENERATING": "스템 분리 중..."},
|
|
)
|
|
if not response:
|
|
return
|
|
sd = response.get("sunoData") or []
|
|
stems = {}
|
|
names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard",
|
|
"strings", "brass", "woodwinds", "percussion", "synth", "fx"]
|
|
for i, item in enumerate(sd):
|
|
if isinstance(item, dict):
|
|
nm = names[i] if i < len(names) else f"stem_{i}"
|
|
stems[nm] = item.get("audioUrl") or item.get("audio_url", "")
|
|
webhook_update_task(task_id, "succeeded", 100, "12스템 완료",
|
|
audio_url=json.dumps(stems))
|
|
except Exception as e:
|
|
logger.exception("stem split error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_upload_cover(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "AI Cover 요청 중...")
|
|
payload = {
|
|
"uploadUrl": params["upload_url"],
|
|
"customMode": params.get("custom_mode", True),
|
|
"instrumental": params.get("instrumental", False),
|
|
"model": params.get("model", "V4"),
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
for k, ak in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
|
|
("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
|
|
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
|
|
if params.get(k):
|
|
payload[ak] = params[k]
|
|
resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Upload Cover 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', '?')}"); return
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
|
|
webhook_update_task(task_id, "processing", 15, "AI Cover 생성 중...")
|
|
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Cover 완료했으나 트랙 없음"); return
|
|
track = _download_and_register(task_id, completed[0], params)
|
|
if track:
|
|
webhook_add_track(task_id, "succeeded", 100, "AI Cover 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
except Exception as e:
|
|
logger.exception("upload cover error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_upload_extend(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "Upload Extend 요청 중...")
|
|
payload = {
|
|
"uploadUrl": params["upload_url"],
|
|
"defaultParamFlag": params.get("default_param_flag", True),
|
|
"model": params.get("model", "V4"),
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
for k, ak in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
|
|
("continue_at", "continueAt"), ("instrumental", "instrumental"),
|
|
("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
|
|
if params.get(k) is not None:
|
|
payload[ak] = params[k]
|
|
resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Upload Extend 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', '?')}"); return
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
|
|
webhook_update_task(task_id, "processing", 15, "Upload Extend 생성 중...")
|
|
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음"); return
|
|
track = _download_and_register(task_id, completed[0], params)
|
|
if track:
|
|
webhook_add_track(task_id, "succeeded", 100, "Upload Extend 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
except Exception as e:
|
|
logger.exception("upload extend error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_add_vocals(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "보컬 추가 요청 중...")
|
|
payload = {
|
|
"uploadUrl": params["upload_url"],
|
|
"prompt": params.get("prompt", ""),
|
|
"title": params.get("title", ""),
|
|
"style": params.get("style", ""),
|
|
"negativeTags": params.get("negative_tags", ""),
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
for k, ak in [("vocal_gender", "vocalGender"), ("model", "model"),
|
|
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
|
|
if params.get(k) is not None:
|
|
payload[ak] = params[k]
|
|
resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Add Vocals 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', '?')}"); return
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
|
|
webhook_update_task(task_id, "processing", 15, "AI 보컬 생성 중...")
|
|
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음"); return
|
|
track = _download_and_register(task_id, completed[0], params)
|
|
if track:
|
|
webhook_add_track(task_id, "succeeded", 100, "보컬 추가 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
except Exception as e:
|
|
logger.exception("add vocals error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_add_instrumental(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")
|
|
payload = {
|
|
"uploadUrl": params["upload_url"],
|
|
"title": params.get("title", ""),
|
|
"tags": params.get("tags", ""),
|
|
"negativeTags": params.get("negative_tags", ""),
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
for k, ak in [("vocal_gender", "vocalGender"), ("model", "model"),
|
|
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
|
|
if params.get(k) is not None:
|
|
payload[ak] = params[k]
|
|
resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Add Inst 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Add Inst 거부: {body.get('msg', '?')}"); return
|
|
suno_task_id = body.get("data", {}).get("taskId", "")
|
|
if not suno_task_id:
|
|
webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
|
|
webhook_update_task(task_id, "processing", 15, "AI 반주 생성 중...")
|
|
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
|
if not response:
|
|
return
|
|
completed = response.get("sunoData") or []
|
|
if not completed:
|
|
webhook_update_task(task_id, "failed", 0, "", error="Add Inst 완료했으나 트랙 없음"); return
|
|
track = _download_and_register(task_id, completed[0], params)
|
|
if track:
|
|
webhook_add_track(task_id, "succeeded", 100, "Add Instrumental 완료",
|
|
audio_url=track["audio_url"], track=track)
|
|
except Exception as e:
|
|
logger.exception("add instrumental error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
|
|
|
|
|
def run_video_generate(task_id: str, params: dict) -> None:
|
|
try:
|
|
if not SUNO_API_KEY:
|
|
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
|
|
webhook_update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")
|
|
payload = {
|
|
"taskId": params["suno_task_id"],
|
|
"audioId": params["suno_id"],
|
|
"callBackUrl": "https://example.com/noop",
|
|
}
|
|
if params.get("author"):
|
|
payload["author"] = params["author"][:50]
|
|
if params.get("domain_name"):
|
|
payload["domainName"] = params["domain_name"][:50]
|
|
resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
|
|
if resp.status_code != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Video 오류: {resp.text[:300]}"); return
|
|
body = resp.json()
|
|
if body.get("code") != 200:
|
|
webhook_update_task(task_id, "failed", 0, "", error=f"Video 거부: {body.get('msg', '?')}"); return
|
|
video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
|
|
webhook_update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")
|
|
response = _poll_suno_record(
|
|
"/mp4/record-info", video_task_id, task_id,
|
|
max_attempts=60, interval=10,
|
|
progress_msg_map={"PENDING": "비디오 대기 중...", "GENERATING": "비디오 렌더링 중..."},
|
|
)
|
|
if not response:
|
|
return
|
|
video_url = ""
|
|
sd = response.get("sunoData") or []
|
|
if sd and isinstance(sd, list) and isinstance(sd[0], dict):
|
|
video_url = sd[0].get("videoUrl") or sd[0].get("video_url", "")
|
|
if not video_url:
|
|
video_url = response.get("video_url") or response.get("videoUrl", "")
|
|
webhook_update_task(task_id, "succeeded", 100, "뮤직비디오 완료", audio_url=video_url)
|
|
except Exception as e:
|
|
logger.exception("video generate error task=%s", task_id)
|
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|