Compare commits
12 Commits
c8793cc3cf
...
f152545d3b
| Author | SHA1 | Date | |
|---|---|---|---|
| f152545d3b | |||
| bf3d6ee694 | |||
| 44bc065796 | |||
| 9127616669 | |||
| 900f45c2ff | |||
| eb34cbc0f7 | |||
| 0de09613d2 | |||
| a5274a4fa7 | |||
| 4e72f8ca2e | |||
| 44c6811352 | |||
| 9eef2c5015 | |||
| b05e5714e3 |
@@ -23,3 +23,29 @@ services:
|
|||||||
interval: 60s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
music-render:
|
||||||
|
build:
|
||||||
|
context: ./music-render
|
||||||
|
container_name: music-render
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18711:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||||
|
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18600}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- SUNO_API_KEY=${SUNO_API_KEY:-}
|
||||||
|
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-http://host.docker.internal:8765}
|
||||||
|
- MUSIC_MEDIA_ROOT=${MUSIC_MEDIA_ROOT:-/mnt/nas/webpage/data/music}
|
||||||
|
- MUSIC_MEDIA_URL_PREFIX=${MUSIC_MEDIA_URL_PREFIX:-/media/music}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- /mnt/nas/webpage/data/music:/mnt/nas/webpage/data/music
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|||||||
20
services/music-render/.env.example
Normal file
20
services/music-render/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Plan-B-Music — Windows music-render worker
|
||||||
|
|
||||||
|
# NAS Redis 큐
|
||||||
|
REDIS_URL=redis://192.168.45.54:6379
|
||||||
|
|
||||||
|
# NAS internal webhook
|
||||||
|
NAS_BASE_URL=http://192.168.45.54:18600
|
||||||
|
INTERNAL_API_KEY=__copy_from_nas_dotenv__
|
||||||
|
|
||||||
|
# Suno API (sunoapi.org 래퍼) — NAS .env에서 옮겨옴
|
||||||
|
SUNO_API_KEY=__paste_suno_key_here__
|
||||||
|
|
||||||
|
# MusicGen 호스트 (Windows native Python — 박재오 PC localhost)
|
||||||
|
MUSIC_AI_SERVER_URL=http://host.docker.internal:8765
|
||||||
|
|
||||||
|
# NAS SMB mount 안의 음악 디렉토리
|
||||||
|
MUSIC_MEDIA_ROOT=/mnt/nas/webpage/data/music
|
||||||
|
|
||||||
|
# nginx 서빙 prefix (NAS webhook payload용)
|
||||||
|
MUSIC_MEDIA_URL_PREFIX=/media/music
|
||||||
17
services/music-render/Dockerfile
Normal file
17
services/music-render/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# requests SSL 의존성만 필요 (Chromium 불필요)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
88
services/music-render/main.py
Normal file
88
services/music-render/main.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""music-render FastAPI entry — health + lifespan + sync forward endpoints.
|
||||||
|
|
||||||
|
NAS music-lab이 sync helpers(lyrics, credits, timestamped, style-boost)를
|
||||||
|
httpx로 forward해서 이 endpoint들을 호출.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import worker
|
||||||
|
from providers.sync_ops import (
|
||||||
|
generate_lyrics, get_credits,
|
||||||
|
get_timestamped_lyrics, generate_style_boost,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
worker_task = asyncio.create_task(worker.worker_loop())
|
||||||
|
logger.info("music-render lifespan 시작")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
worker_task.cancel()
|
||||||
|
try:
|
||||||
|
await worker_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("music-render lifespan 종료")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "music-render"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sync forward endpoints ──────────────────────────────────────────────
|
||||||
|
# NAS music-lab의 /api/music/lyrics 등 sync helpers가 이 endpoint들로 forward.
|
||||||
|
|
||||||
|
class LyricsRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/music-render/sync/lyrics")
|
||||||
|
def sync_lyrics(req: LyricsRequest):
|
||||||
|
result = generate_lyrics(req.prompt)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(502, "가사 생성 실패")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/music-render/sync/credits")
|
||||||
|
def sync_credits():
|
||||||
|
result = get_credits()
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(502, "크레딧 조회 실패")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/music-render/sync/timestamped-lyrics")
|
||||||
|
def sync_timestamped_lyrics(task_id: str, suno_id: str):
|
||||||
|
result = get_timestamped_lyrics(task_id, suno_id)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(502, "타임스탬프 가사 조회 실패")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class StyleBoostRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/music-render/sync/style-boost")
|
||||||
|
def sync_style_boost(req: StyleBoostRequest):
|
||||||
|
result = generate_style_boost(req.content)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(502, "스타일 부스트 생성 실패")
|
||||||
|
return result
|
||||||
80
services/music-render/nas_client.py
Normal file
80
services/music-render/nas_client.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""NAS webhook 어댑터 — Windows worker가 NAS DB 직접 접근 못하므로 HTTP로 위임.
|
||||||
|
|
||||||
|
기존 NAS suno_provider/local_provider의 `update_task`, `add_track` 호출을
|
||||||
|
이 모듈의 webhook_update_task/webhook_add_track으로 치환.
|
||||||
|
|
||||||
|
webhook 실패는 raise하지 않고 logger.error로 기록 (provider 로직 흐름 유지).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _post(payload: Dict[str, Any]) -> None:
|
||||||
|
nas_base_url = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18600")
|
||||||
|
internal_api_key = os.getenv("INTERNAL_API_KEY", "")
|
||||||
|
url = f"{nas_base_url}/api/internal/music/update"
|
||||||
|
try:
|
||||||
|
r = httpx.post(
|
||||||
|
url,
|
||||||
|
headers={"X-Internal-Key": internal_api_key},
|
||||||
|
json=payload,
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error("webhook %s returned %d: %s",
|
||||||
|
payload.get("task_id"), r.status_code, r.text[:200])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("webhook %s 호출 실패", payload.get("task_id"))
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_update_task(
|
||||||
|
task_id: str,
|
||||||
|
status: str,
|
||||||
|
progress: int,
|
||||||
|
message: str = "",
|
||||||
|
audio_url: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""기존 update_task(task_id, status, progress, message, audio_url=None, error=None) 대체."""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if audio_url is not None:
|
||||||
|
payload["audio_url"] = audio_url
|
||||||
|
if error is not None:
|
||||||
|
payload["error"] = error
|
||||||
|
_post(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_add_track(
|
||||||
|
task_id: str,
|
||||||
|
status: str,
|
||||||
|
progress: int,
|
||||||
|
message: str = "",
|
||||||
|
audio_url: Optional[str] = None,
|
||||||
|
track: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""update + add_track을 한 webhook 호출로 결합 (NAS internal_router가 둘 다 처리)."""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if audio_url is not None:
|
||||||
|
payload["audio_url"] = audio_url
|
||||||
|
if track is not None:
|
||||||
|
payload["track"] = track
|
||||||
|
_post(payload)
|
||||||
0
services/music-render/providers/__init__.py
Normal file
0
services/music-render/providers/__init__.py
Normal file
106
services/music-render/providers/local.py
Normal file
106
services/music-render/providers/local.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Local MusicGen Provider — Windows AI 머신의 native MusicGen 서버(:8765) 호출.
|
||||||
|
|
||||||
|
NAS music-lab/app/local_provider.py 이식. DB 호출만 webhook으로 변환.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from nas_client import webhook_update_task, webhook_add_track
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
|
||||||
|
MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")
|
||||||
|
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music")
|
||||||
|
|
||||||
|
|
||||||
|
def run_local_generation(task_id: str, params: dict) -> None:
|
||||||
|
"""MusicGen 생성 → /mnt/nas/.../music/{task_id}.mp3 저장 → add_track."""
|
||||||
|
try:
|
||||||
|
webhook_update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
|
||||||
|
if not MUSIC_AI_SERVER_URL:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL 미설정")
|
||||||
|
return
|
||||||
|
|
||||||
|
webhook_update_task(task_id, "processing", 30, "음악 생성 중...")
|
||||||
|
resp = requests.post(f"{MUSIC_AI_SERVER_URL}/generate", json=params, timeout=30)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "",
|
||||||
|
error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
ai_task_id = resp.json().get("task_id")
|
||||||
|
if not ai_task_id:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="AI 서버 응답에 task_id 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
remote_url = None
|
||||||
|
for _ in range(120):
|
||||||
|
time.sleep(5)
|
||||||
|
sr = requests.get(f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10)
|
||||||
|
sd = sr.json()
|
||||||
|
st = sd.get("status")
|
||||||
|
prog = sd.get("progress", 0)
|
||||||
|
msg = sd.get("message", "음악 생성 중...")
|
||||||
|
scaled = 30 + int(prog * 0.49)
|
||||||
|
webhook_update_task(task_id, "processing", scaled, msg)
|
||||||
|
|
||||||
|
if st == "succeeded":
|
||||||
|
remote_url = sd.get("audio_url")
|
||||||
|
break
|
||||||
|
elif st == "failed":
|
||||||
|
webhook_update_task(task_id, "failed", 0, "",
|
||||||
|
error=sd.get("error", "AI 서버 생성 실패"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not remote_url:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분)")
|
||||||
|
return
|
||||||
|
|
||||||
|
webhook_update_task(task_id, "processing", 80, "파일 저장 중...")
|
||||||
|
filename = f"{task_id}.mp3"
|
||||||
|
os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)
|
||||||
|
file_path = os.path.join(MUSIC_MEDIA_ROOT, filename)
|
||||||
|
|
||||||
|
dl = requests.get(remote_url, 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)
|
||||||
|
|
||||||
|
audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
|
||||||
|
genre = params.get("genre", "")
|
||||||
|
moods = params.get("moods", [])
|
||||||
|
mood_str = moods[0] if moods else "Original"
|
||||||
|
title = params.get("title") or (
|
||||||
|
f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix"
|
||||||
|
)
|
||||||
|
|
||||||
|
track = {
|
||||||
|
"title": title,
|
||||||
|
"genre": genre,
|
||||||
|
"moods": moods,
|
||||||
|
"instruments": params.get("instruments", []),
|
||||||
|
"duration_sec": params.get("duration_sec"),
|
||||||
|
"bpm": params.get("bpm"),
|
||||||
|
"key": params.get("key", ""),
|
||||||
|
"scale": params.get("scale", ""),
|
||||||
|
"prompt": params.get("prompt", ""),
|
||||||
|
"audio_url": audio_url,
|
||||||
|
"file_path": f"/app/data/{filename}",
|
||||||
|
"task_id": task_id,
|
||||||
|
"provider": "local",
|
||||||
|
}
|
||||||
|
webhook_add_track(task_id, "succeeded", 100, "생성 완료",
|
||||||
|
audio_url=audio_url, track=track)
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("local generation error task=%s", task_id)
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
||||||
690
services/music-render/providers/suno.py
Normal file
690
services/music-render/providers/suno.py
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
"""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))
|
||||||
131
services/music-render/providers/sync_ops.py
Normal file
131
services/music-render/providers/sync_ops.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Sync Suno API helpers — main.py FastAPI sync endpoints에서 호출.
|
||||||
|
|
||||||
|
NAS music-lab/app/suno_provider.py의 sync 함수들 이식.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUNO_BASE_URL = "https://api.sunoapi.org/api/v1"
|
||||||
|
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {SUNO_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_lyrics(prompt: str) -> Optional[dict]:
|
||||||
|
"""Suno 가사 생성 API — 폴링 결과 반환."""
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SUNO_BASE_URL}/lyrics",
|
||||||
|
headers=_headers(),
|
||||||
|
json={"prompt": prompt[:200]},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
body = resp.json()
|
||||||
|
if body.get("code") != 200:
|
||||||
|
return body
|
||||||
|
task_id = body.get("data", {}).get("taskId", "")
|
||||||
|
if not task_id:
|
||||||
|
return body
|
||||||
|
return _poll_lyrics(task_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Suno lyrics API error: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_lyrics(lyrics_task_id: str) -> Optional[dict]:
|
||||||
|
for _ in range(15):
|
||||||
|
time.sleep(3)
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{SUNO_BASE_URL}/lyrics/record-info",
|
||||||
|
headers=_headers(),
|
||||||
|
params={"taskId": lyrics_task_id},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
body = resp.json()
|
||||||
|
data = body.get("data", {})
|
||||||
|
if data.get("status") == "complete":
|
||||||
|
items = data.get("data") or data.get("sunoData") or []
|
||||||
|
if items and isinstance(items, list):
|
||||||
|
return {
|
||||||
|
"id": lyrics_task_id,
|
||||||
|
"status": "complete",
|
||||||
|
"text": items[0].get("text", ""),
|
||||||
|
"title": items[0].get("title", ""),
|
||||||
|
}
|
||||||
|
return {"id": lyrics_task_id, "status": "complete", "text": ""}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_credits() -> Optional[dict]:
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
return None
|
||||||
|
for path in ["/generate/credit", "/get-credits"]:
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{SUNO_BASE_URL}{path}", headers=_headers(), timeout=15)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
body = resp.json()
|
||||||
|
data = body.get("data", body)
|
||||||
|
if isinstance(data, (int, float)):
|
||||||
|
return {"credits_left": int(data)}
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Suno credits API error (%s): %s", path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]:
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics",
|
||||||
|
headers=_headers(),
|
||||||
|
json={"taskId": suno_task_id, "audioId": suno_id},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
body = resp.json()
|
||||||
|
return body.get("data", body)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Timestamped lyrics error: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_style_boost(content: str) -> Optional[dict]:
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SUNO_BASE_URL}/style/generate",
|
||||||
|
headers=_headers(),
|
||||||
|
json={"content": content},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
body = resp.json()
|
||||||
|
return body.get("data", body)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Style boost error: %s", e)
|
||||||
|
return None
|
||||||
9
services/music-render/requirements.txt
Normal file
9
services/music-render/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
requests==2.32.3
|
||||||
|
redis>=5.0
|
||||||
|
httpx>=0.27
|
||||||
|
mutagen==1.47.0
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.24
|
||||||
|
respx>=0.21
|
||||||
80
services/music-render/tests/test_nas_client.py
Normal file
80
services/music-render/tests/test_nas_client.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""nas_client — webhook adapter tests."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nas_client import webhook_update_task, webhook_add_track
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _env(monkeypatch):
|
||||||
|
monkeypatch.setenv("NAS_BASE_URL", "http://nas-test:18600")
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "test-key")
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_webhook_update_task_sends_x_internal_key():
|
||||||
|
route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
webhook_update_task("task-1", "processing", 30, message="downloading")
|
||||||
|
assert route.called
|
||||||
|
req = route.calls[0].request
|
||||||
|
assert req.headers["X-Internal-Key"] == "test-key"
|
||||||
|
import json
|
||||||
|
body = json.loads(req.content)
|
||||||
|
assert body["task_id"] == "task-1"
|
||||||
|
assert body["status"] == "processing"
|
||||||
|
assert body["progress"] == 30
|
||||||
|
assert body["message"] == "downloading"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_webhook_update_task_with_audio_url():
|
||||||
|
route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
webhook_update_task("task-2", "succeeded", 100, message="완료",
|
||||||
|
audio_url="/media/music/task-2.mp3")
|
||||||
|
import json
|
||||||
|
payload = json.loads(route.calls[0].request.content)
|
||||||
|
assert payload["audio_url"] == "/media/music/task-2.mp3"
|
||||||
|
assert payload["status"] == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_webhook_update_task_with_error():
|
||||||
|
route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
webhook_update_task("task-3", "failed", 0, error="API rate limit")
|
||||||
|
import json
|
||||||
|
payload = json.loads(route.calls[0].request.content)
|
||||||
|
assert payload["error"] == "API rate limit"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_webhook_add_track_uses_track_field():
|
||||||
|
"""add_track은 update와 동시에 (succeeded 시)."""
|
||||||
|
route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
track = {"title": "x", "audio_url": "/media/music/t.mp3", "provider": "suno"}
|
||||||
|
webhook_add_track("task-4", "succeeded", 100, message="ok",
|
||||||
|
audio_url="/media/music/t.mp3", track=track)
|
||||||
|
import json
|
||||||
|
payload = json.loads(route.calls[0].request.content)
|
||||||
|
assert payload["track"]["title"] == "x"
|
||||||
|
assert payload["status"] == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_webhook_swallows_network_error(caplog):
|
||||||
|
"""webhook 실패해도 raise 안 함 (logger.error)."""
|
||||||
|
respx.post("http://nas-test:18600/api/internal/music/update").mock(
|
||||||
|
side_effect=httpx.ConnectError("no host")
|
||||||
|
)
|
||||||
|
# raise 안 하면 통과
|
||||||
|
webhook_update_task("task-5", "processing", 10)
|
||||||
|
assert "task-5" in caplog.text
|
||||||
32
services/music-render/tests/test_suno_provider.py
Normal file
32
services/music-render/tests/test_suno_provider.py
Normal file
@@ -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"
|
||||||
109
services/music-render/tests/test_worker.py
Normal file
109
services/music-render/tests/test_worker.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""worker.py — job_type 디스패처 + paused 체크."""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_suno_generation_calls_run_suno_generation():
|
||||||
|
payload = {
|
||||||
|
"task_id": "t1",
|
||||||
|
"job_type": "suno_generation",
|
||||||
|
"params": {"genre": "lofi", "title": "x"},
|
||||||
|
}
|
||||||
|
with patch("worker.run_suno_generation") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t1", {"genre": "lofi", "title": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_local_generation_calls_run_local_generation():
|
||||||
|
payload = {
|
||||||
|
"task_id": "t2",
|
||||||
|
"job_type": "local_generation",
|
||||||
|
"params": {"genre": "ambient"},
|
||||||
|
}
|
||||||
|
with patch("worker.run_local_generation") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t2", {"genre": "ambient"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_unknown_job_type_logs_error():
|
||||||
|
payload = {"task_id": "t3", "job_type": "weird_type", "params": {}}
|
||||||
|
with patch("worker.webhook_update_task") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
# 알 수 없는 job_type은 failed로 보고
|
||||||
|
m.assert_called_once()
|
||||||
|
args = m.call_args[0]
|
||||||
|
assert args[0] == "t3"
|
||||||
|
assert args[1] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_suno_extend_calls_run_suno_extend():
|
||||||
|
payload = {"task_id": "t4", "job_type": "suno_extend", "params": {"suno_id": "abc"}}
|
||||||
|
with patch("worker.run_suno_extend") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t4", {"suno_id": "abc"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_vocal_removal_calls_run_vocal_removal():
|
||||||
|
payload = {"task_id": "t5", "job_type": "vocal_removal", "params": {"suno_id": "abc"}}
|
||||||
|
with patch("worker.run_vocal_removal") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t5", {"suno_id": "abc"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_cover_image_calls_run_cover_image():
|
||||||
|
payload = {"task_id": "t6", "job_type": "cover_image", "params": {"suno_task_id": "x"}}
|
||||||
|
with patch("worker.run_cover_image") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t6", {"suno_task_id": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_wav_convert_calls_run_wav_convert():
|
||||||
|
payload = {"task_id": "t7", "job_type": "wav_convert", "params": {"suno_task_id": "x", "suno_id": "y"}}
|
||||||
|
with patch("worker.run_wav_convert") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t7", {"suno_task_id": "x", "suno_id": "y"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_stem_split_calls_run_stem_split():
|
||||||
|
payload = {"task_id": "t8", "job_type": "stem_split", "params": {"suno_task_id": "x", "suno_id": "y"}}
|
||||||
|
with patch("worker.run_stem_split") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t8", {"suno_task_id": "x", "suno_id": "y"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_video_generate_calls_run_video_generate():
|
||||||
|
payload = {"task_id": "t9", "job_type": "video_generate", "params": {"suno_task_id": "x", "suno_id": "y"}}
|
||||||
|
with patch("worker.run_video_generate") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t9", {"suno_task_id": "x", "suno_id": "y"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_upload_cover_calls_run_upload_cover():
|
||||||
|
payload = {"task_id": "t10", "job_type": "upload_cover", "params": {"upload_url": "u"}}
|
||||||
|
with patch("worker.run_upload_cover") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t10", {"upload_url": "u"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_upload_extend_calls_run_upload_extend():
|
||||||
|
payload = {"task_id": "t11", "job_type": "upload_extend", "params": {"upload_url": "u"}}
|
||||||
|
with patch("worker.run_upload_extend") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t11", {"upload_url": "u"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_add_vocals_calls_run_add_vocals():
|
||||||
|
payload = {"task_id": "t12", "job_type": "add_vocals", "params": {"upload_url": "u"}}
|
||||||
|
with patch("worker.run_add_vocals") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t12", {"upload_url": "u"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_add_instrumental_calls_run_add_instrumental():
|
||||||
|
payload = {"task_id": "t13", "job_type": "add_instrumental", "params": {"upload_url": "u"}}
|
||||||
|
with patch("worker.run_add_instrumental") as m:
|
||||||
|
worker._dispatch(payload)
|
||||||
|
m.assert_called_once_with("t13", {"upload_url": "u"})
|
||||||
95
services/music-render/worker.py
Normal file
95
services/music-render/worker.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Redis BLPOP worker — queue:music-render → job_type 디스패치 → NAS webhook.
|
||||||
|
|
||||||
|
queue:paused 가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from nas_client import webhook_update_task
|
||||||
|
from providers.suno import (
|
||||||
|
run_suno_generation, run_suno_extend, run_vocal_removal,
|
||||||
|
run_cover_image, run_wav_convert, run_stem_split,
|
||||||
|
run_upload_cover, run_upload_extend, run_add_vocals,
|
||||||
|
run_add_instrumental, run_video_generate,
|
||||||
|
)
|
||||||
|
from providers.local import run_local_generation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||||
|
QUEUE_KEY = "queue:music-render"
|
||||||
|
PAUSED_KEY = "queue:paused"
|
||||||
|
|
||||||
|
# Maps job_type → module-level function name (string).
|
||||||
|
# _dispatch resolves the name via globals() at call time so unittest.mock.patch
|
||||||
|
# on "worker.<name>" is correctly intercepted.
|
||||||
|
_DISPATCH_TABLE: dict[str, str] = {
|
||||||
|
"suno_generation": "run_suno_generation",
|
||||||
|
"local_generation": "run_local_generation",
|
||||||
|
"suno_extend": "run_suno_extend",
|
||||||
|
"vocal_removal": "run_vocal_removal",
|
||||||
|
"cover_image": "run_cover_image",
|
||||||
|
"wav_convert": "run_wav_convert",
|
||||||
|
"stem_split": "run_stem_split",
|
||||||
|
"upload_cover": "run_upload_cover",
|
||||||
|
"upload_extend": "run_upload_extend",
|
||||||
|
"add_vocals": "run_add_vocals",
|
||||||
|
"add_instrumental": "run_add_instrumental",
|
||||||
|
"video_generate": "run_video_generate",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(payload: dict) -> None:
|
||||||
|
"""payload[job_type] → provider 함수 호출 (sync, asyncio.to_thread로 래핑)."""
|
||||||
|
import sys
|
||||||
|
_self = sys.modules[__name__]
|
||||||
|
job_type = payload.get("job_type", "")
|
||||||
|
task_id = payload.get("task_id", "")
|
||||||
|
params = payload.get("params", {})
|
||||||
|
fn_name = _DISPATCH_TABLE.get(job_type)
|
||||||
|
if fn_name is None:
|
||||||
|
logger.error("unknown job_type=%s task=%s", job_type, task_id)
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=f"unknown job_type: {job_type}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fn = getattr(_self, fn_name)
|
||||||
|
except AttributeError:
|
||||||
|
logger.error("dispatch table typo for job_type=%s name=%s task=%s", job_type, fn_name, task_id)
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=f"internal dispatch error: {fn_name}")
|
||||||
|
return
|
||||||
|
fn(task_id, params)
|
||||||
|
|
||||||
|
|
||||||
|
async def worker_loop():
|
||||||
|
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
logger.info("music-render worker started (queue=%s)", QUEUE_KEY)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
paused = await redis.get(PAUSED_KEY)
|
||||||
|
if paused == b"1":
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
continue
|
||||||
|
item = await redis.blpop(QUEUE_KEY, timeout=1)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
_, raw = item
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("invalid queue payload: %r", raw[:200])
|
||||||
|
continue
|
||||||
|
# sync provider 함수 — thread로 실행해서 이벤트 루프 블로킹 방지
|
||||||
|
await asyncio.to_thread(_dispatch, payload)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("worker_loop cancelled")
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("worker_loop iteration 실패, 5초 후 재시도")
|
||||||
|
await asyncio.sleep(5)
|
||||||
Reference in New Issue
Block a user