"""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))