From 4e72f8ca2e1055a0174b02c2c156b09efbb2b2a7 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 19 May 2026 04:48:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-render):=20providers/suno.py=20?= =?UTF-8?q?=E2=80=94=2013=20Suno=20API=20=ED=95=A8=EC=88=98=20=EC=9D=B4?= =?UTF-8?q?=EC=8B=9D=20(SP-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAS music-lab/app/suno_provider.py를 Windows worker로 이식. DB 호출(update_task, add_track 등)을 nas_client.webhook_*으로 변환. 결과 MP3는 MUSIC_MEDIA_ROOT(/mnt/nas/...)에 직접 저장. 13 함수: generation, extend, vocal_removal, cover_image, wav, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate + _build_suno_payload + _poll_suno_record + _download_and_register Plan-B-Music Phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/music-render/providers/__init__.py | 0 services/music-render/providers/suno.py | 694 ++++++++++++++++++ .../music-render/tests/test_suno_provider.py | 32 + 3 files changed, 726 insertions(+) create mode 100644 services/music-render/providers/__init__.py create mode 100644 services/music-render/providers/suno.py create mode 100644 services/music-render/tests/test_suno_provider.py diff --git a/services/music-render/providers/__init__.py b/services/music-render/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/music-render/providers/suno.py b/services/music-render/providers/suno.py new file mode 100644 index 0000000..f5954e7 --- /dev/null +++ b/services/music-render/providers/suno.py @@ -0,0 +1,694 @@ +"""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: + second = _download_and_register(f"{task_id}_v2", completed[1], params) + if second: + # 두 번째 변형은 별도 task가 아니라 별도 track으로만 등록 + webhook_add_track(f"{task_id}_v2", "succeeded", 100, "두 번째 변형", + audio_url=second["audio_url"], track=second) + 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)"} + second = _download_and_register(f"{task_id}_inst", completed[1], ip) + if second: + webhook_add_track(f"{task_id}_inst", "succeeded", 100, "Instrumental", + audio_url=second["audio_url"], track=second) + 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)) diff --git a/services/music-render/tests/test_suno_provider.py b/services/music-render/tests/test_suno_provider.py new file mode 100644 index 0000000..fed8ed8 --- /dev/null +++ b/services/music-render/tests/test_suno_provider.py @@ -0,0 +1,32 @@ +"""providers/suno.py — _build_suno_payload 단위 테스트 + 1개 함수 mock 검증.""" +import pytest +from providers.suno import _build_suno_payload + + +def test_payload_custom_mode_with_lyrics(): + params = {"lyrics": "[Verse]\nhello", "genre": "lofi", "moods": ["chill"], "model": "V4"} + p = _build_suno_payload(params) + assert p["customMode"] is True + assert p["prompt"] == "[Verse]\nhello" + assert "lofi" in p["style"] + assert "chill" in p["style"] + + +def test_payload_simple_mode_no_lyrics_no_genre(): + params = {"prompt": "happy summer", "model": "V4"} + p = _build_suno_payload(params) + assert p["customMode"] is False + assert "happy summer" in p["prompt"] + + +def test_payload_instrumental_clears_prompt(): + params = {"genre": "ambient", "instrumental": True, "model": "V5"} + p = _build_suno_payload(params) + assert p["instrumental"] is True + assert p["prompt"] == "" + + +def test_payload_includes_optional_vocal_gender(): + params = {"genre": "pop", "vocal_gender": "f", "model": "V4"} + p = _build_suno_payload(params) + assert p["vocalGender"] == "f"