SP-5 + SP-6 — 모든 Suno(13) + MusicGen(1) 외부 호출 + sync helpers(4)를 Windows music-render로 이전. NAS music-lab은 Redis push(async) + httpx forward(sync)만. SUNO_API_KEY는 Windows .env 단독 보유 (spec §9). 17 task: NAS 수신부(1-2) → Windows worker(3-10) → NAS cutover(11-14) → nginx 차단 + end-to-end 검증(15-17). 박재오 결정: 모든 Suno + MusicGen 일괄 이전 (Plan-B-Insta 패턴 + sync forward 추가). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 KiB
Plan-B-Music — NAS music-lab 분할 + Windows music-render Worker Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: NAS music-lab의 모든 외부 음악 생성 API 호출(Suno 13종 + MusicGen 1종)을 Windows AI 머신(WSL2 Docker)으로 완전 이전. NAS는 게이트(메타·DB·Redis push·HTTP forward)만 담당, Windows worker가 Suno/MusicGen 호출 + 결과 파일을 NAS SMB 볼륨에 직접 저장 + webhook으로 NAS DB 업데이트.
Architecture: Async path — NAS gateway → Redis RPUSH queue:music-render (job_type discriminator) → Windows BLPOP → Suno/MusicGen 호출 + 다운로드 → SMB direct write to /mnt/nas/webpage/data/music/{task_id}.mp3 → HTTP POST /api/internal/music/update. Sync path — NAS gateway → httpx forward to Windows POST/GET /api/music-render/sync/{op} → 결과 반환. 사용자는 폴링 (GET /api/music/status/{task_id}).
Tech Stack: Python 3.12 / FastAPI / Suno API (sunoapi.org) / MusicGen Windows native (192.168.45.59:8765) / redis>=5.0 (async) / httpx / Docker Engine in WSL2 Ubuntu 24.04 / cifs SMB to NAS / mutagen (MP3 duration).
Spec: web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md §4 SP-5·SP-6, §5 Windows Render Worker 패턴, §6 Redis 큐, §8 internal webhook + auth, §9 SUNO_API_KEY 이전, §10 SP-5/SP-6 상세
Prerequisites (✅ 모두 완료):
- Plan-A: web-ai/NAS 캐시 강화
- Plan-B-Base: NAS Redis 컨테이너 + Windows WSL2/Docker/SMB
- Plan-B-Insta: 패턴 검증 + verify_internal_key + Redis 큐 + nginx 차단 인프라
Plan-B-Insta와의 차이:
- Endpoint 수: 인스타 1개(render) vs 음악 14개(13 Suno async + 1 MusicGen async + 4 sync helpers)
- Sync 호출 존재: lyrics·credits·timestamped-lyrics·style-boost는 즉시 응답 필요 → HTTP forward 패턴 추가
- SUNO_API_KEY 이전: NAS
.env에서 완전 제거 (spec §9) - DB 호출 변환: Windows에서 NAS DB 직접 접근 불가 → 모든
add_track/update_task/update_track_*호출을 webhook 페이로드로 변환 job_typediscriminator: 큐 단일이지만 13가지 작업 분기 필요 (worker dispatch byparams.job_type)- 결과 디렉토리:
/mnt/nas/webpage/data/music/{task_id}.mp3(Plan-B-Insta는/insta/{slate_id}/01.png)
Phase 구조
| Phase | 내용 | Task |
|---|---|---|
| 1. NAS gateway 수신부 | auth + webhook endpoint + redis client + docker-compose env | 1~2 |
| 2. Windows worker 신설 | services/music-render — providers + sync handlers + worker + main + tests + compose | 3~10 |
| 3. NAS music-lab cutover | background_tasks→Redis push, sync→HTTP forward, batch_generator 호환, provider stub | 11~14 |
| 4. nginx 차단 + 검증 | 3-layer 차단 + end-to-end Suno 1트랙 검증 + 최종 정리 | 15~17 |
순서 강제 사유: Phase 1·2 먼저(수신부+worker 준비) → Phase 3(전환) → Phase 4(보안+검증). Phase 3 먼저 하면 큐에 쌓이고 worker 미존재로 처리 안 됨 (Plan-B-Insta T11 cutover 직전까지 Phase 1·2 완성 패턴 그대로).
File Structure
Phase 1·3·4 — NAS web-backend
| 파일 | 변경 | 책임 |
|---|---|---|
web-backend/music-lab/app/auth.py (Create) |
verify_internal_key dependency |
X-Internal-Key 검증 (insta-lab/app/auth.py 동일 복제) |
web-backend/music-lab/app/internal_router.py (Create) |
POST /api/internal/music/update |
Windows webhook 수신 — DB update_task + add_track |
web-backend/music-lab/app/sync_forward.py (Create) |
POST /api/music/lyrics, GET /api/music/credits, GET /api/music/timestamped-lyrics, POST /api/music/style-boost — 모두 Windows music-render로 httpx forward |
sync helper 프록시 |
web-backend/music-lab/app/main.py |
redis client 추가 + background_tasks.add_task(run_*) 14개를 Redis push로 + sync helpers를 sync_forward로 + router include |
Redis push 전환 |
web-backend/music-lab/app/suno_provider.py |
외부 API 호출 함수 13개를 1줄 stub으로 — DEPRECATED 마커 | 코드 제거 |
web-backend/music-lab/app/local_provider.py |
run_local_generation stub |
코드 제거 |
web-backend/music-lab/app/batch_generator.py |
_generate_one_track의 run_suno_generation 직접 호출을 Redis push로 변경 |
batch도 worker 경유 |
web-backend/music-lab/requirements.txt |
redis>=5.0 추가 (requests는 mutagen/youtube oauth용 유지) |
의존성 |
web-backend/music-lab/.env (예시) |
REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL 추가. SUNO_API_KEY 제거 |
환경 |
web-backend/docker-compose.yml |
music-lab service에 REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL env 추가 + depends_on redis + SUNO_API_KEY 라인 제거 | compose |
web-backend/nginx/default.conf |
location /api/internal/music/ IP allow + deny all (insta 블록 복제) |
3-layer 차단 |
Phase 2 — Windows web-ai/services/
| 파일 | 변경 | 책임 |
|---|---|---|
web-ai/services/music-render/Dockerfile (Create) |
python:3.12-slim + requests + httpx + redis + mutagen | image |
web-ai/services/music-render/requirements.txt (Create) |
fastapi, uvicorn, requests, redis, httpx, mutagen, pytest | deps |
web-ai/services/music-render/.env.example (Create) |
REDIS_URL, NAS_BASE_URL, INTERNAL_API_KEY, SUNO_API_KEY, MUSIC_AI_SERVER_URL(MusicGen 호스트), MUSIC_MEDIA_ROOT, MUSIC_MEDIA_URL_PREFIX |
secrets |
web-ai/services/music-render/providers/__init__.py (Create) |
빈 패키지 마커 | package |
web-ai/services/music-render/providers/suno.py (Create) |
NAS suno_provider.py 이식 — 13 함수 + add_track/update_task 호출을 webhook 호출로 변환 |
Suno API client |
web-ai/services/music-render/providers/local.py (Create) |
NAS local_provider.py 이식 — MusicGen 호출 (192.168.45.59:8765) + webhook | MusicGen client |
web-ai/services/music-render/providers/sync_ops.py (Create) |
sync Suno API 4종 (lyrics, credits, timestamped-lyrics, style-boost) | sync helper |
web-ai/services/music-render/nas_client.py (Create) |
webhook_update_task, webhook_add_track — NAS POST 호출 헬퍼 (X-Internal-Key) |
webhook adapter |
web-ai/services/music-render/worker.py (Create) |
Redis BLPOP 루프 + queue:paused 체크 + job_type 디스패처 |
dispatcher |
web-ai/services/music-render/main.py (Create) |
FastAPI app + lifespan(worker spawn) + sync endpoints (/api/music-render/sync/*) |
entry |
web-ai/services/music-render/tests/test_worker.py (Create) |
mocked Redis + job_type 디스패치 검증 | TDD |
web-ai/services/music-render/tests/test_nas_client.py (Create) |
httpx mock webhook 호출 검증 | TDD |
web-ai/services/docker-compose.yml |
music-render service 추가 | compose |
Task 1: NAS music-lab — verify_internal_key + /api/internal/music/update endpoint
Files:
-
Create:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/auth.py -
Create:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/internal_router.py -
Test:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_auth.py -
Test:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_internal_router.py -
Step 1:
auth.py실패하는 테스트 작성
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_auth.py:
"""verify_internal_key dependency — Windows music-render webhook 인증."""
import pytest
from fastapi import HTTPException
from app.auth import verify_internal_key
def test_valid_key_passes(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
verify_internal_key(x_internal_key="secret123")
def test_invalid_key_raises_401(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="wrong")
assert exc.value.status_code == 401
def test_missing_env_key_raises_401(monkeypatch):
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="any")
assert exc.value.status_code == 401
- Step 2:
auth.py테스트 실패 확인
Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab && python -m pytest tests/test_auth.py -v
Expected: FAIL — app.auth 모듈 미존재.
- Step 3:
auth.py작성
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/auth.py:
"""SP-6 — Windows worker → NAS internal webhook 인증.
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
서버 측 키 미설정 시 401 (안전한 기본값).
"""
from __future__ import annotations
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")
- Step 4:
auth.py테스트 통과
Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab && python -m pytest tests/test_auth.py -v
Expected: 3 PASS.
- Step 5:
internal_router.py실패하는 테스트 작성
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_internal_router.py:
"""POST /api/internal/music/update — Windows music-render webhook."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal_router import router
from app import db
@pytest.fixture(autouse=True)
def _set_key(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("MUSIC_DATA_DIR", str(tmp_path))
db.init_db()
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _make_task():
tid = "test-task-1"
db.create_task(tid, {"provider": "suno", "title": "T"}, provider="suno")
return tid
def test_update_with_valid_key_updates_db(client):
tid = _make_task()
r = client.post(
"/api/internal/music/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "processing"
assert task["progress"] == 30
def test_update_with_invalid_key_returns_401(client):
tid = _make_task()
r = client.post(
"/api/internal/music/update",
headers={"X-Internal-Key": "wrong"},
json={"task_id": tid, "status": "processing", "progress": 30},
)
assert r.status_code == 401
def test_update_succeeded_with_audio_url(client):
tid = _make_task()
r = client.post(
"/api/internal/music/update",
headers={"X-Internal-Key": "test-secret"},
json={
"task_id": tid, "status": "succeeded", "progress": 100,
"message": "완료", "audio_url": "/media/music/test-task-1.mp3",
},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "succeeded"
assert task["audio_url"] == "/media/music/test-task-1.mp3"
def test_update_failed_records_error(client):
tid = _make_task()
r = client.post(
"/api/internal/music/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Suno API rate limit"},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "failed"
assert "Suno" in (task.get("error") or "")
def test_add_track_action_inserts_library(client):
tid = _make_task()
r = client.post(
"/api/internal/music/update",
headers={"X-Internal-Key": "test-secret"},
json={
"task_id": tid, "status": "succeeded", "progress": 100,
"message": "ok",
"track": {
"title": "My Song", "genre": "lofi", "moods": ["chill"],
"instruments": [], "duration_sec": 180, "bpm": 80,
"key": "C", "scale": "major", "prompt": "",
"audio_url": "/media/music/test-task-1.mp3",
"file_path": "/app/data/test-task-1.mp3",
"task_id": tid, "provider": "suno",
"lyrics": "", "image_url": "", "suno_id": "suno-abc",
},
},
)
assert r.status_code == 200
tracks = db.get_all_tracks()
assert any(t["title"] == "My Song" for t in tracks)
- Step 6:
internal_router.py테스트 실패 확인
Run: python -m pytest tests/test_internal_router.py -v
Expected: FAIL — app.internal_router 미존재.
- Step 7:
internal_router.py작성
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/internal_router.py:
"""SP-6 — Windows music-render → NAS internal webhook.
POST /api/internal/music/update
- X-Internal-Key 인증 필수
- music_tasks 테이블 row update (status, progress, message, audio_url, error)
- 옵션 `track` 페이로드가 있으면 music_library에 add_track 호출
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from . import db
from .auth import verify_internal_key
logger = logging.getLogger(__name__)
router = APIRouter()
class UpdatePayload(BaseModel):
task_id: str
status: str = Field(..., description="processing|succeeded|failed")
progress: int = Field(..., ge=0, le=100)
message: str = ""
audio_url: Optional[str] = None
error: Optional[str] = None
# Optional: 라이브러리 등록을 함께 요청. add_track 페이로드 그대로.
track: Optional[Dict[str, Any]] = None
@router.post(
"/api/internal/music/update",
dependencies=[Depends(verify_internal_key)],
)
def music_update(payload: UpdatePayload):
task = db.get_task(payload.task_id)
if task is None:
raise HTTPException(404, f"task not found: {payload.task_id}")
db.update_task(
payload.task_id,
payload.status,
payload.progress,
message=payload.message,
audio_url=payload.audio_url,
error=payload.error,
)
if payload.track:
try:
db.add_track(payload.track)
except Exception:
logger.exception("add_track 실패 task=%s (무시)", payload.task_id)
logger.info(
"internal/music/update task=%s status=%s progress=%d",
payload.task_id, payload.status, payload.progress,
)
return {"ok": True}
- Step 8:
internal_router.py테스트 통과
Run: python -m pytest tests/test_internal_router.py -v
Expected: 5 PASS.
- Step 9: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add music-lab/app/auth.py music-lab/app/internal_router.py music-lab/tests/test_auth.py music-lab/tests/test_internal_router.py
git commit -m "$(cat <<'EOF'
feat(music-lab): verify_internal_key + /api/internal/music/update (SP-6)
X-Internal-Key 헤더 검증 dependency (insta-lab 동일 패턴).
Windows music-render webhook 수신 endpoint — update_task + 옵션 add_track.
Plan-B-Music Phase 1 (수신부).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: NAS music-lab — main.py wire (redis client + router include) + docker-compose env
Files:
-
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py:1-50(imports + redis client + router) -
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/requirements.txt -
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml:55-90(music-lab section) -
Step 1:
requirements.txt에 redis 추가
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/requirements.txt에 다음 라인 추가 (mutagen==1.47.0 다음):
redis>=5.0
- Step 2:
main.py— redis client + internal_router include
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py의 import 블록 (현재 line 1-39) 끝에 추가:
import redis.asyncio as aioredis
from .internal_router import router as internal_router
app = FastAPI() (현재 line 40) 바로 뒤에 추가:
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
app.include_router(internal_router)
- Step 3: smoke test — import만 확인
Run: cd music-lab && python -c "from app.main import app, redis_client; print('OK')"
Expected: OK 출력.
- Step 4:
docker-compose.yml— music-lab 환경변수 추가
C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml의 music-lab service (line 55-90) 내 environment: 섹션에서:
제거: - SUNO_API_KEY=${SUNO_API_KEY:-} (spec §9 — NAS .env에서 완전 제거)
추가:
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}
같은 service 블록 끝(healthcheck 앞)에 depends_on: 추가 (insta-lab과 동일):
depends_on:
- redis
(이미 있다면 redis만 항목 추가)
- Step 5: docker-compose 검증
Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose config | grep -A 30 "music-lab:"
Expected: REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL 표시. SUNO_API_KEY 미표시.
- Step 6: 커밋
git add music-lab/app/main.py music-lab/requirements.txt docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(music-lab): wire redis client + internal_router + compose env (SP-6)
main.py에 redis.asyncio client 추가 + internal_router include.
docker-compose의 music-lab에 REDIS_URL/INTERNAL_API_KEY/MUSIC_RENDER_URL.
SUNO_API_KEY 라인 제거 (spec §9 — Windows로 이전).
Plan-B-Music Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: Windows music-render — Dockerfile + requirements + .env.example
Files:
-
Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/Dockerfile -
Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/requirements.txt -
Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/.env.example -
Step 1:
requirements.txt작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/requirements.txt:
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
- Step 2:
Dockerfile작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/Dockerfile:
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"]
- Step 3:
.env.example작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/.env.example:
# 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
- Step 4: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/Dockerfile services/music-render/requirements.txt services/music-render/.env.example
git commit -m "$(cat <<'EOF'
feat(music-render): Dockerfile + requirements + env.example (SP-5)
Windows WSL2 Docker 컨테이너 스캐폴드.
Plan-B-Insta보다 가벼움 — Chromium 미포함, requests + httpx + redis + mutagen만.
.env.example에 SUNO_API_KEY 자리 (NAS에서 옮겨올 값).
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: Windows music-render — nas_client.py (webhook 헬퍼)
Files:
- Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/nas_client.py - Test:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_nas_client.py
NAS DB 직접 접근이 불가하므로 모든 update_task/add_track 호출을 webhook으로 변환. 이를 위한 어댑터 모듈.
- Step 1: 실패하는 테스트 작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_nas_client.py:
"""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")
body = httpx.URL(route.calls[0].request.url)
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)
- Step 2: 테스트 실패 확인
Run: cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render && python -m pytest tests/test_nas_client.py -v
Expected: FAIL — nas_client 모듈 미존재.
- Step 3:
nas_client.py작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/nas_client.py:
"""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__)
NAS_BASE_URL = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18600")
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "")
_TIMEOUT = 10.0
def _post(payload: Dict[str, Any]) -> None:
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, error) 대체."""
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)
- Step 4: 테스트 통과
Run: python -m pytest tests/test_nas_client.py -v
Expected: 5 PASS.
- Step 5: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/nas_client.py services/music-render/tests/test_nas_client.py
git commit -m "$(cat <<'EOF'
feat(music-render): nas_client webhook adapter (SP-5)
NAS DB 직접 접근 불가 → webhook_update_task/webhook_add_track으로 변환.
X-Internal-Key 헤더 자동 첨부. 실패 시 raise 안 함 (logger.error).
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 5: Windows music-render — providers/suno.py (13 함수 이식)
Files:
- Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/__init__.py - Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/suno.py - Test:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py
NAS music-lab/app/suno_provider.py를 이식. 차이점:
-
from .db import update_task, add_track, ...→from nas_client import webhook_update_task, webhook_add_track -
MUSIC_DATA_DIR = "/app/data"→MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")— NAS SMB 직접 저장 -
MUSIC_MEDIA_BASE그대로 사용 (/media/music) -
update_track_cover_images,update_track_wav_url등은 webhook에 새 action을 만들지 않고 일단webhook_update_task로 message에 JSON encode (기능 손실 최소 — track 컬럼 업데이트는 batch 미사용) -
Step 1: 빈
__init__.py만들기
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/__init__.py:
(빈 파일)
- Step 2: 실패하는 테스트 작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py:
"""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"
- Step 3: 테스트 실패 확인
Run: python -m pytest tests/test_suno_provider.py -v
Expected: FAIL — providers.suno 미존재.
- Step 4:
providers/suno.py작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/suno.py:
"""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))
- Step 5: 테스트 통과
Run: python -m pytest tests/test_suno_provider.py -v
Expected: 4 PASS.
- Step 6: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/providers/__init__.py services/music-render/providers/suno.py services/music-render/tests/test_suno_provider.py
git commit -m "$(cat <<'EOF'
feat(music-render): providers/suno.py — 13 Suno API 함수 이식 (SP-5)
NAS music-lab/app/suno_provider.py를 Windows worker로 이식.
DB 호출(update_task, add_track 등)을 nas_client.webhook_*으로 변환.
결과 MP3는 MUSIC_MEDIA_ROOT(/mnt/nas/...)에 직접 저장.
13 함수: generation, extend, vocal_removal, cover_image, wav, stem_split,
upload_cover, upload_extend, add_vocals, add_instrumental, video_generate
+ _build_suno_payload + _poll_suno_record + _download_and_register
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6: Windows music-render — providers/local.py (MusicGen 이식)
Files:
-
Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/local.py -
Step 1:
providers/local.py작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/local.py:
"""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)
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))
- Step 2: 임포트 smoke 테스트
Run: python -c "from providers.local import run_local_generation; print('OK')"
Expected: OK.
- Step 3: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/providers/local.py
git commit -m "$(cat <<'EOF'
feat(music-render): providers/local.py — MusicGen client (SP-5)
NAS music-lab/app/local_provider.py 이식. DB 호출 webhook 변환.
MusicGen 호스트는 host.docker.internal:8765 (Windows native).
결과 MP3는 /mnt/nas/webpage/data/music/에 직접 저장.
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: Windows music-render — providers/sync_ops.py (sync Suno API 4종)
Files:
- Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/sync_ops.py
NAS music-lab의 generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost 동기 호출을 그대로 이식.
- Step 1:
sync_ops.py작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/sync_ops.py:
"""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
- Step 2: 임포트 smoke 테스트
Run: python -c "from providers.sync_ops import generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost; print('OK')"
Expected: OK.
- Step 3: 커밋
git add services/music-render/providers/sync_ops.py
git commit -m "$(cat <<'EOF'
feat(music-render): providers/sync_ops.py — sync Suno helpers (SP-5)
NAS sync 함수 4종 이식: generate_lyrics, get_credits,
get_timestamped_lyrics, generate_style_boost.
NAS main.py가 httpx로 forward하여 호출.
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 8: Windows music-render — worker.py (Redis BLPOP + job_type 디스패처)
Files:
- Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/worker.py - Test:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_worker.py
큐 단일 (queue:music-render)에 모든 job 종류가 들어옴. payload의 job_type 필드로 디스패치.
- Step 1: 실패하는 테스트 작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_worker.py:
"""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"})
- Step 2: 테스트 실패 확인
Run: python -m pytest tests/test_worker.py -v
Expected: FAIL — worker 모듈 미존재.
- Step 3:
worker.py작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/worker.py:
"""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"
_DISPATCH_TABLE = {
"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로 래핑)."""
job_type = payload.get("job_type", "")
task_id = payload.get("task_id", "")
params = payload.get("params", {})
fn = _DISPATCH_TABLE.get(job_type)
if fn 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
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)
- Step 4: 테스트 통과
Run: python -m pytest tests/test_worker.py -v
Expected: 13 PASS.
- Step 5: 커밋
git add services/music-render/worker.py services/music-render/tests/test_worker.py
git commit -m "$(cat <<'EOF'
feat(music-render): worker.py — Redis BLPOP + 12 job_type dispatch (SP-5)
queue:music-render BLPOP, queue:paused 체크 후 job_type별 provider 호출.
sync provider는 asyncio.to_thread로 래핑 (이벤트 루프 블로킹 방지).
12 job_types (suno_*, local_*, vocal_removal, cover_image, wav_convert,
stem_split, upload_cover, upload_extend, add_vocals, add_instrumental,
video_generate).
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: Windows music-render — main.py (FastAPI + lifespan + sync endpoints)
Files:
-
Create:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/main.py -
Step 1:
main.py작성
C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/main.py:
"""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
- Step 2: 임포트 smoke 테스트
Run: python -c "from main import app; print(len(app.routes))"
Expected: 숫자 출력 (>= 5 — health + sync 4종).
- Step 3: 커밋
git add services/music-render/main.py
git commit -m "$(cat <<'EOF'
feat(music-render): main.py — FastAPI + lifespan + sync endpoints (SP-5)
lifespan에서 worker_loop 스폰. sync forward 4 endpoint:
/api/music-render/sync/{lyrics, credits, timestamped-lyrics, style-boost}.
NAS music-lab이 이 endpoint들을 httpx forward로 호출.
Plan-B-Music Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 10: Windows services/docker-compose — music-render 추가 + 박재오 빌드
Files:
-
Modify:
C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml -
Step 1:
docker-compose.yml에 music-render 서비스 추가
C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml 끝(insta-render 아래)에 추가:
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
- Step 2: 박재오에게
.env작성 요청 (구두 가이드)
박재오 Windows AI 머신 (WSL2):
cd /workspace/web-ai/services/music-render
cp .env.example .env
# .env 편집:
# INTERNAL_API_KEY = NAS .env의 동일값 복사
# SUNO_API_KEY = NAS .env에서 옮겨오는 값
nano .env
플랜 실행자는 박재오에게 .env 작성 완료 확인 후 다음 step 진행.
- Step 3: 박재오 머신에서 빌드 + 시작
박재오 WSL2:
cd /workspace/web-ai/services
docker compose build music-render
docker compose up -d music-render
docker compose logs -f music-render
Expected logs:
music-render | INFO ... music-render lifespan 시작
music-render | INFO ... music-render worker started (queue=queue:music-render)
- Step 4: 헬스체크 확인
박재오 WSL2:
curl http://localhost:18711/health
Expected: {"ok": true, "service": "music-render"}
NAS SMB write 확인 (worker가 컨테이너에서 mount 접근 가능한지):
docker exec music-render ls -la /mnt/nas/webpage/data/music | head
Expected: 에러 없이 디렉토리 리스트 출력.
- Step 5: 커밋 + push
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(music-render): services/docker-compose에 music-render 서비스 (SP-5)
포트 18711, REDIS_URL/NAS_BASE_URL/INTERNAL_API_KEY/SUNO_API_KEY/MUSIC_AI_SERVER_URL env.
host.docker.internal 매핑(MusicGen native 호스트).
SMB /mnt/nas/webpage/data/music 마운트.
Plan-B-Music Phase 2 — 빌드 + 박재오 머신 시작 확인.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push
Task 11: NAS music-lab — main.py background_tasks → Redis push (13 endpoint)
Files:
- Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py— 13개 endpoint의background_tasks.add_task(run_*, ...)호출을 Redis push로 변경
각 endpoint별로 동일 패턴 적용: task_id 생성 + create_task + redis_client.rpush("queue:music-render", json.dumps(payload)) + 응답.
helper 함수 1개를 추가하여 DRY 유지.
- Step 1:
main.pyimport 블록 끝(internal_router import 다음 줄)에 helper 추가
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py에서 app = FastAPI() 위에 추가:
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
"""Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리."""
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "music",
"job_type": job_type,
"params": params,
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:music-render", json.dumps(payload))
(json import는 이미 line 1에 있음.)
- Step 2:
/api/music/generate변경
generate_music 함수(현재 line 132-154) 본문 변경:
@app.post("/api/music/generate")
async def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
"""음악 생성 작업 — Redis 큐로 Windows music-render에 위임."""
provider = req.provider
# SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X).
# 실패 시 worker가 webhook으로 failed 보고.
if provider not in ("suno", "local"):
raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"):
# 이 env는 NAS에는 더 이상 없지만 사용자 친화 검증으로 유지 — 실제 호출은 Windows
pass
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider=provider)
job_type = "suno_generation" if provider == "suno" else "local_generation"
await _push_render_job(task_id, job_type, params)
return {"task_id": task_id, "provider": provider}
(주의: async def로 변경 + await _push_render_job(...).)
- Step 3:
/api/music/extend변경
extend_music(현재 line 397-406) 본문 변경:
@app.post("/api/music/extend")
async def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "suno_extend", params)
return {"task_id": task_id, "provider": "suno"}
- Step 4:
/api/music/vocal-removal변경
vocal_removal 본문 (line 417-426):
@app.post("/api/music/vocal-removal")
async def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "vocal_removal", params)
return {"task_id": task_id, "provider": "suno"}
- Step 5:
/api/music/cover-image,/api/music/wav,/api/music/stem-split변경
각각:
@app.post("/api/music/cover-image")
async def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "cover_image", params)
return {"task_id": task_id, "provider": "suno"}
@app.post("/api/music/wav")
async def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "wav_convert", params)
return {"task_id": task_id, "provider": "suno"}
@app.post("/api/music/stem-split")
async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "stem_split", params)
return {"task_id": task_id, "provider": "suno"}
- Step 6:
/api/music/upload-cover,/api/music/upload-extend,/api/music/add-vocals,/api/music/add-instrumental,/api/music/video변경
@app.post("/api/music/upload-cover")
async def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "upload_cover", params)
return {"task_id": task_id, "provider": "suno"}
@app.post("/api/music/upload-extend")
async def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "upload_extend", params)
return {"task_id": task_id, "provider": "suno"}
@app.post("/api/music/add-vocals")
async def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "add_vocals", params)
return {"task_id": task_id, "provider": "suno"}
@app.post("/api/music/add-instrumental")
async def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "add_instrumental", params)
return {"task_id": task_id, "provider": "suno"}
@app.post("/api/music/video")
async def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params, provider="suno")
await _push_render_job(task_id, "video_generate", params)
return {"task_id": task_id, "provider": "suno"}
- Step 7: smoke test — import + endpoint 라우트 확인
Run: cd music-lab && python -c "from app.main import app; print([r.path for r in app.routes if hasattr(r, 'path')])"
Expected: /api/music/generate, /api/music/extend, ... 13 endpoint 모두 포함.
- Step 8: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add music-lab/app/main.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): 13 background_tasks → Redis push (SP-6)
generate, extend, vocal-removal, cover-image, wav, stem-split,
upload-cover, upload-extend, add-vocals, add-instrumental, video
모두 _push_render_job 헬퍼로 queue:music-render에 push.
job_type 디스크리미네이터로 Windows worker가 분기.
Plan-B-Music Phase 3 (cutover 1/4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 12: NAS music-lab — sync helpers를 Windows HTTP forward로
Files:
-
Create:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/sync_forward.py -
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py— sync endpoint 4종(lyrics, credits, timestamped, style-boost)이 sync_forward 호출 -
Step 1:
sync_forward.py작성
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/sync_forward.py:
"""SP-6 sync helpers forward — NAS → Windows music-render.
NAS music-lab의 /api/music/lyrics, /api/music/credits,
/api/music/timestamped-lyrics, /api/music/style-boost 호출을
Windows music-render의 /api/music-render/sync/* 로 forward.
SUNO_API_KEY는 NAS에 없으므로 NAS에서 직접 호출 불가.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
MUSIC_RENDER_URL = os.getenv("MUSIC_RENDER_URL", "http://192.168.45.59:18711")
_TIMEOUT = 60.0 # 가사 생성은 폴링 포함 ~45초
def forward_lyrics(prompt: str) -> Optional[dict]:
try:
r = httpx.post(
f"{MUSIC_RENDER_URL}/api/music-render/sync/lyrics",
json={"prompt": prompt},
timeout=_TIMEOUT,
)
if r.status_code == 200:
return r.json()
logger.warning("forward_lyrics returned %d", r.status_code)
except Exception:
logger.exception("forward_lyrics 실패")
return None
def forward_credits() -> Optional[dict]:
try:
r = httpx.get(
f"{MUSIC_RENDER_URL}/api/music-render/sync/credits",
timeout=30.0,
)
if r.status_code == 200:
return r.json()
except Exception:
logger.exception("forward_credits 실패")
return None
def forward_timestamped_lyrics(task_id: str, suno_id: str) -> Optional[dict]:
try:
r = httpx.get(
f"{MUSIC_RENDER_URL}/api/music-render/sync/timestamped-lyrics",
params={"task_id": task_id, "suno_id": suno_id},
timeout=30.0,
)
if r.status_code == 200:
return r.json()
except Exception:
logger.exception("forward_timestamped_lyrics 실패")
return None
def forward_style_boost(content: str) -> Optional[dict]:
try:
r = httpx.post(
f"{MUSIC_RENDER_URL}/api/music-render/sync/style-boost",
json={"content": content},
timeout=30.0,
)
if r.status_code == 200:
return r.json()
except Exception:
logger.exception("forward_style_boost 실패")
return None
- Step 2:
main.py— sync endpoint 4종 변경
main.py의 gen_lyrics (현재 line 190-198), check_credits (line 374-381), timestamped_lyrics (line 491-498), style_boost (line 508-515) 변경:
@app.post("/api/music/lyrics")
def gen_lyrics(req: LyricsRequest):
"""Suno AI 가사 생성 — Windows music-render로 forward."""
from .sync_forward import forward_lyrics
result = forward_lyrics(req.prompt)
if not result:
raise HTTPException(status_code=502, detail="가사 생성 실패 (Windows worker 응답 없음)")
return result
@app.get("/api/music/credits")
def check_credits():
"""Suno 잔여 크레딧 조회 — Windows music-render로 forward."""
from .sync_forward import forward_credits
result = forward_credits()
if result is None:
raise HTTPException(status_code=502, detail="크레딧 조회 실패")
return result
@app.get("/api/music/timestamped-lyrics")
def timestamped_lyrics(task_id: str, suno_id: str):
"""타임스탬프 가사 — Windows music-render로 forward."""
from .sync_forward import forward_timestamped_lyrics
result = forward_timestamped_lyrics(task_id, suno_id)
if not result:
raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
return result
@app.post("/api/music/style-boost")
def style_boost(req: StyleBoostRequest):
"""스타일 부스트 — Windows music-render로 forward."""
from .sync_forward import forward_style_boost
result = forward_style_boost(req.content)
if not result:
raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
return result
SUNO_API_KEY 검증 라인은 제거 (모두 Windows로 위임). from .suno_provider import (...) import 라인에서 generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost, SUNO_API_KEY는 더 이상 필요 없음. 다만 SUNO_MODELS는 /api/music/models endpoint에서 사용하므로 유지. import 정리:
기존 (line 31-37):
from .suno_provider 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,
generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
SUNO_API_KEY, SUNO_MODELS,
)
→ 변경:
from .suno_provider import SUNO_MODELS
(run_* 함수들은 Task 11에서 _push_render_job로 대체했으므로 import 불필요.)
또한 /api/music/providers endpoint(line 86-104)의 if SUNO_API_KEY: 체크는 항상 True 가정 (Windows로 이전됨). 변경:
@app.get("/api/music/providers")
def get_providers():
providers = []
if os.getenv("MUSIC_AI_SERVER_URL"):
providers.append({
"id": "local", "name": "MusicGen",
"description": "로컬 AI 서버 (인스트루멘탈 전용)",
"features": ["instrumental"],
})
# SUNO는 Windows music-render에서 처리 — 항상 가용 (Suno 키 누락 시 worker가 failed 보고)
providers.append({
"id": "suno", "name": "Suno",
"description": "Suno AI (보컬·가사·인스트루멘탈)",
"features": ["vocals", "lyrics", "instrumental"],
})
return {"providers": providers}
local_provider도 main.py에서 import 안 함 (run_local_generation도 미사용). 기존 line 28 from .local_provider import run_local_generation를 제거.
- Step 3: import smoke test
Run: cd music-lab && python -c "from app.main import app; print('OK')"
Expected: OK.
- Step 4: 커밋
git add music-lab/app/sync_forward.py music-lab/app/main.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): sync helpers → Windows HTTP forward (SP-6)
/api/music/{lyrics, credits, timestamped-lyrics, style-boost}
모두 sync_forward 모듈로 위임 → Windows :18711/api/music-render/sync/*.
SUNO_API_KEY가 NAS에 없으므로 직접 호출 불가.
run_*, generate_*, get_* import 제거 (Windows로 이전됨).
SUNO_MODELS만 잔존 (정적 데이터).
Plan-B-Music Phase 3 (cutover 2/4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 13: NAS music-lab — batch_generator를 Redis push 호환으로
Files:
- Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/batch_generator.py:103-148(_generate_one_track함수)
기존 _generate_one_track은 run_suno_generation을 직접 호출하지만, 그 함수는 이제 NAS에서 사라짐. Redis push 후 task 상태 polling 패턴으로 변경.
- Step 1:
_generate_one_track재작성
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/batch_generator.py의 _generate_one_track 함수 전체 교체:
async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
params: dict) -> int | None:
"""Redis 큐에 push + task 상태 polling. 성공 시 새 track id, 실패 시 None."""
import json
from datetime import datetime, timezone, timedelta
from .main import redis_client # 같은 컨테이너 — 동일 redis 클라이언트 공유
task_id = str(uuid.uuid4())
suno_params = {
"title": title,
"genre": genre,
"moods": params["moods"],
"instruments": params["instruments"],
"duration_sec": duration_sec,
"bpm": params["bpm"],
"key": params["key"],
"scale": params["scale"],
"prompt": params.get("prompt_modifier", ""),
"provider": "suno",
"model": "V4",
"instrumental": False,
"lyrics": "",
}
db.create_task(task_id, suno_params, provider="suno")
# Redis push (Windows music-render가 BLPOP 처리)
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "music",
"job_type": "suno_generation",
"params": suno_params,
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:music-render", json.dumps(payload))
waited = 0
while waited < TRACK_GEN_TIMEOUT_S:
await asyncio.sleep(POLL_INTERVAL_S)
waited += POLL_INTERVAL_S
task = db.get_task(task_id)
if not task:
continue
status = task.get("status")
if status == "succeeded":
# Windows webhook이 add_track 했으므로 task_id로 검색
track = db.get_track_by_task_id(task_id)
if track:
return track.get("id")
return None
if status == "failed":
return None
return None # timeout
(주의: from .suno_provider import run_suno_generation import 제거, asyncio.create_task(asyncio.to_thread(...)) 제거.)
- Step 2: import smoke test
Run: cd music-lab && python -c "from app.batch_generator import run_batch; print('OK')"
Expected: OK.
- Step 3: 커밋
git add music-lab/app/batch_generator.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): batch_generator _generate_one_track → Redis push (SP-6)
기존 직접 run_suno_generation 호출 + asyncio.to_thread를
Redis push (queue:music-render, job_type=suno_generation) +
task 상태 polling 패턴으로 변경. 결과는 task_id로 music_library 조회.
Plan-B-Music Phase 3 (cutover 3/4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 14: NAS music-lab — suno_provider/local_provider stub + Dockerfile 슬림화
Files:
-
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/suno_provider.py(전체 교체) -
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/local_provider.py(전체 교체) -
Step 1:
suno_provider.py전체 교체 (stub만 남김)
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/suno_provider.py 전체를 다음으로 교체:
"""DEPRECATED 2026-05-19 — Suno API 호출 코드는 모두 Windows music-render로 이전.
기존 13 함수 (run_suno_generation, run_suno_extend, ...)는
web-ai/services/music-render/providers/suno.py로 이식됨.
NAS는 Redis push (queue:music-render)만 담당.
SUNO_MODELS는 frontend 응답용 정적 데이터만 잔존.
"""
from __future__ import annotations
SUNO_API_KEY = "" # NAS에서 더 이상 보유 X — sentinel 유지 (다른 import 호환)
SUNO_MODELS = [
{"id": "V4", "name": "V4", "max_duration": "4분", "description": "안정적 품질, 빠른 생성"},
{"id": "V4_5", "name": "V4.5", "max_duration": "8분", "description": "향상된 장르 블렌딩"},
{"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"},
{"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"},
{"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성"},
{"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델"},
]
- Step 2:
local_provider.py전체 교체 (stub만 남김)
C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/local_provider.py 전체를 다음으로 교체:
"""DEPRECATED 2026-05-19 — MusicGen 호출은 Windows music-render로 이전.
기존 run_local_generation은 web-ai/services/music-render/providers/local.py로 이식.
NAS는 Redis push (queue:music-render, job_type=local_generation)만 담당.
"""
- Step 3: 다른 모듈의 dangling import 확인
Run: cd music-lab && grep -rn "from .suno_provider import\|from .local_provider import\|from app.suno_provider\|from app.local_provider" app/ tests/
Expected: app/main.py에서 SUNO_MODELS만 import, app/batch_generator.py에서 어떤 것도 import 안 함.
만약 추가 dangling이 보이면 그 파일도 정리. (compiler.py나 video_producer.py 등 — 일반적으로 NAS local 처리이므로 영향 없음.)
- Step 4: import smoke test + pytest
Run:
cd music-lab
python -c "from app.main import app; print('OK')"
python -m pytest tests/test_auth.py tests/test_internal_router.py -v
Expected: OK + 8 PASS.
- Step 5: 커밋
git add music-lab/app/suno_provider.py music-lab/app/local_provider.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): suno_provider/local_provider → stub (SP-6)
기존 13+1 외부 API 호출 함수는 web-ai/services/music-render/providers로 이식.
NAS는 SUNO_MODELS (정적 데이터)만 잔존. SUNO_API_KEY = "" sentinel.
Plan-B-Music Phase 3 (cutover 4/4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 15: NAS push + deployer rebuild + 헬스 확인
Files:
-
(변경 없음 — push + 원격 배포만)
-
Step 1: 모든 NAS 변경 push
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push
Expected: push 성공. 만약 자격증명 실패 시 박재오에게 수동 push 요청.
- Step 2: 박재오에게 NAS 컨테이너 rebuild 확인 요청
NAS의 Gitea webhook이 자동으로 deployer를 트리거. 박재오 측에서 NAS deployer 로그 확인:
ssh nas
docker logs webpage-deployer --tail 50
Expected: music-lab rebuild + restart 라인 확인.
- Step 3: 헬스 확인
박재오 NAS:
curl https://gahusb.synology.me/api/music/providers
Expected: {"providers": [{"id": "local", ...}, {"id": "suno", ...}]}
docker logs music-lab --tail 20
Expected: 시작 로그, Application startup complete 표시.
docker exec music-lab python -c "from app.main import redis_client; import asyncio; print(asyncio.run(redis_client.ping()))"
Expected: True (Redis 연결 확인).
- Step 4: SUNO_API_KEY 잔존 검사
박재오 NAS:
docker exec music-lab env | grep SUNO
Expected: SUNO_API_KEY 없음 (또는 빈 문자열).
- Step 5: 커밋할 추가 변경 없으면 다음 task로 진행
Task 16: nginx 3-layer 차단 /api/internal/music/
Files:
-
Modify:
C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf—/api/internal/insta/블록 옆에/api/internal/music/추가 -
Step 1:
default.conf에 차단 블록 추가
C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf의 /api/internal/insta/ 블록(line 196-211) 바로 다음에 추가:
# Plan-B-Music — Windows music-render → NAS music-lab internal webhook
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
# Layer 3: X-Internal-Key (FastAPI dependency)
location /api/internal/music/ {
allow 192.168.45.0/24; # LAN 화이트리스트
allow 100.64.0.0/10; # Tailscale CGNAT
allow 127.0.0.1; # NAS 내부
deny all;
resolver 127.0.0.11 valid=10s;
set $music_internal_backend music-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://$music_internal_backend$request_uri;
}
- Step 2: nginx 설정 검증
Run: grep -c "location /api/internal/" C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf
Expected: 2 (insta + music).
- Step 3: NAS nginx reload (push 후 webhook 자동)
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add nginx/default.conf
git commit -m "$(cat <<'EOF'
feat(nginx): /api/internal/music/ 3-layer 차단 (SP-6)
LAN(192.168.45.0/24) + Tailscale(100.64.0.0/10) + 127.0.0.1 allow.
deny all. X-Internal-Key forward → music-lab:8000.
insta 블록과 동일 패턴.
Plan-B-Music Phase 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push
- Step 4: NAS frontend(nginx) 재시작 확인
박재오 NAS:
docker compose -f /volume1/docker/webpage/docker-compose.yml restart frontend
docker logs frontend --tail 20 | grep -i music
Expected: 시작 로그에 nginx 재로드 + /api/internal/music/ 차단 활성화.
- Step 5: 차단 테스트 (외부에서 호출)
박재오 외부 PC(WAN)에서:
curl -X POST https://gahusb.synology.me/api/internal/music/update \
-H "X-Internal-Key: anything" \
-H "Content-Type: application/json" \
-d '{"task_id":"x","status":"processing","progress":0}'
Expected: HTTP 403 (nginx deny).
박재오 LAN PC에서 (또는 NAS 내부):
curl -X POST http://192.168.45.54:8080/api/internal/music/update \
-H "X-Internal-Key: WRONG" \
-H "Content-Type: application/json" \
-d '{"task_id":"x","status":"processing","progress":0}'
Expected: HTTP 401 (FastAPI verify_internal_key 거부 — nginx 통과 확인).
Task 17: end-to-end Suno 1 트랙 생성 검증 + 최종 정리
Files:
-
(변경 없음 — 검증만)
-
Step 1: 박재오 머신에서 SUNO 1 트랙 생성 트리거
박재오 LAN:
curl -X POST https://gahusb.synology.me/api/music/generate \
-H "Content-Type: application/json" \
-d '{
"provider": "suno",
"model": "V4",
"title": "Plan-B-Music 검증",
"genre": "lofi",
"moods": ["chill"],
"instruments": [],
"duration_sec": 60,
"prompt": "lofi chill background",
"lyrics": "",
"instrumental": true
}'
응답 예: {"task_id": "xxxx-yyyy", "provider": "suno"}
- Step 2: Windows worker 로그에서 처리 확인
박재오 WSL2:
docker logs music-render --tail 50 -f
Expected:
music-render | INFO ... Suno API 연결... task=xxxx
music-render | INFO ... 곡 생성 대기열에 등록...
music-render | INFO ... 폴링... attempt=1 status=PENDING
...
music-render | INFO ... 오디오 다운로드 중...
music-render | INFO ... webhook task=xxxx status=succeeded
- Step 3: NAS DB 상태 확인 (폴링)
박재오:
curl https://gahusb.synology.me/api/music/status/xxxx-yyyy
Expected: {"status": "succeeded", "progress": 100, "audio_url": "/media/music/xxxx-yyyy.mp3", "track": {...}}
- Step 4: 파일 존재 확인
박재오 NAS:
ls -la /volume1/docker/webpage/data/music/xxxx-yyyy.mp3
Expected: 파일 존재, 크기 > 0.
브라우저: https://gahusb.synology.me/media/music/xxxx-yyyy.mp3 직접 재생 시도.
- Step 5: Sync forward 검증 (Suno 크레딧 조회)
박재오:
curl https://gahusb.synology.me/api/music/credits
Expected: {"credits_left": <숫자>} 또는 Suno API 응답 데이터.
- Step 6: 모든 NAS·web-ai 변경 push 최종 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -10
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -10
Expected: 두 저장소 모두 clean + 최신 commits push 완료.
- Step 7: 메모리에 기록 (Plan-B-Music 검증 완료)
C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/project_signal_v2.md는 무관. 새 reference 메모리 추가:
reference_plan_b_music_complete.md:
---
name: plan-b-music-complete
description: 2026-05-19 Plan-B-Music 완료 — NAS music-lab → Windows music-render Redis 큐 + sync forward 구조
metadata:
type: reference
---
Plan-B-Music 2026-05-19 완료. 검증 결과: Suno 1 트랙 end-to-end 성공.
**구조:**
- async 13 endpoint: NAS music-lab → Redis RPUSH `queue:music-render` (job_type discriminator) → Windows music-render worker → SMB direct write → webhook `POST /api/internal/music/update`
- sync 4 endpoint: NAS music-lab → httpx → Windows `POST/GET /api/music-render/sync/{op}` 직접 forward
- SUNO_API_KEY: NAS `.env`에서 완전 제거 → Windows `.env` 단독 보유
**파일 위치:**
- web-ai/services/music-render/providers/{suno,local,sync_ops}.py
- web-ai/services/music-render/nas_client.py (webhook adapter)
- web-backend/music-lab/app/{auth,internal_router,sync_forward}.py
**job_type 13종:** suno_generation, local_generation, suno_extend, vocal_removal, cover_image, wav_convert, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate
**다음 plan:** Plan-B-Video (SP-7+SP-8) — 같은 패턴 복제.
MEMORY.md에 한 줄 추가:
- [Plan-B-Music 완료](reference_plan_b_music_complete.md) — NAS music-lab → Windows music-render Redis 큐 + sync forward, SUNO_API_KEY 완전 이전
- Step 8: Task list 정리
박재오의 TaskCreate/Update에서 #69~#85 Plan-B-Music task들을 모두 completed로 마크.
박재오에게 보고:
Plan-B-Music 완료:
- 17 task 모두 completed
- Suno end-to-end 검증 성공 (task_id=xxxx, 곡 N초)
- SUNO_API_KEY NAS에서 제거 확인 (docker exec music-lab env | grep SUNO 결과 empty)
- nginx 3-layer 차단 활성화 (외부 403, LAN 401)
- 다음: Plan-B-Video (SP-7+SP-8) 또는 FOLLOW-UP B (-lab suffix 제거)
Self-Review
1. Spec coverage
| Spec 요구사항 | 구현 위치 | 상태 |
|---|---|---|
| SP-5 §10: Suno API client (외부 SaaS, 폴링 1~5분) | Task 5 (providers/suno.py) | ✓ 11 async + 4 sync 함수 이식 |
| SP-5 §10: MusicGen local call (Windows localhost:8765) | Task 6 (providers/local.py) | ✓ run_local_generation 이식, host.docker.internal 매핑 |
| SP-5 §10: Redis BLPOP queue:music-render | Task 8 (worker.py) | ✓ job_type 디스패처 13종 |
| SP-5 §10: 결과 mp3 → /mnt/nas/data/music/ | Task 5 (_download_and_register) | ✓ MUSIC_MEDIA_ROOT 직접 저장 |
| SP-5 §10: NAS webhook POST /api/internal/music/update | Task 4 (nas_client.py) | ✓ webhook_update_task + webhook_add_track |
| SP-5 §10: SUNO_API_KEY Windows .env 단독 보관 | Task 3, Task 14 | ✓ Windows .env.example, NAS suno_provider stub |
| SP-6 §10: Suno + MusicGen 호출 코드 삭제 | Task 14 | ✓ suno_provider/local_provider stub |
| SP-6 §10: _bg_generate → Redis push | Task 11 | ✓ 13 endpoint + helper |
| SP-6 §10: POST /api/internal/music/update endpoint | Task 1 | ✓ internal_router.py |
| §5 Windows Render Worker 패턴 (시퀀스) | Task 8 worker_loop | ✓ paused 체크 + BLPOP + webhook |
| §6 Redis 키 컨벤션 (queue:music-render + queue:paused) | Task 8 | ✓ QUEUE_KEY + PAUSED_KEY |
| §6 큐 payload 표준 (task_id/kind/params/submitted_at) | Task 11 _push_render_job | ✓ + job_type 추가 |
| §7 NAS 볼륨 (/mnt/nas/webpage/data/music) | Task 3 .env, Task 10 compose | ✓ |
| §8 Internal webhook 3-layer 차단 | Task 16 | ✓ nginx IP allow + verify_internal_key |
| §9 SUNO_API_KEY NAS .env 제거 | Task 2 docker-compose | ✓ SUNO_API_KEY 라인 제거 |
| 박재오 결정 "모든 Suno + MusicGen 일괄 이전" | Task 5+6+7 (13 async + 4 sync) | ✓ |
전체 spec coverage: 100%. SP-5/SP-6 모든 항목 + §9 키 이전 + §5/§6/§7/§8 패턴 동일.
2. Placeholder scan: 통과 — TBD/TODO/placeholder 없음. 모든 코드 블록 완전.
3. Type consistency:
webhook_update_task(task_id: str, status: str, progress: int, message: str, audio_url: Optional[str], error: Optional[str])— Task 4 정의, Task 5/6 일관 호출webhook_add_track(task_id, status, progress, message, audio_url, track)— Task 4 정의, Task 5/6 일관 호출_push_render_job(task_id, job_type, params)— Task 11 정의, Task 13 호출 일관 (kind=music, submitted_at 동일 포맷)_dispatch(payload)— Task 8 정의, payload[job_type] + payload[task_id] + payload[params] 사용 (Task 11 push 형식과 일치)job_type디스크리미네이터 13개 문자열 (suno_generation, local_generation, suno_extend, vocal_removal, cover_image, wav_convert, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate) — Task 8 _DISPATCH_TABLE, Task 11 endpoint 호출, Task 13 batch_generator 일관
4. Plan-B-Insta와의 일관성:
- auth.py: 동일 (insta-lab/app/auth.py 복제) ✓
- internal_router.py: 같은 패턴 + add_track 페이로드 추가 ✓
- nginx 차단 블록: insta 블록과 동일 패턴 ✓
- worker_loop: 동일 (queue:paused 체크 + BLPOP + webhook) ✓
- .env.example: 동일 구조 ✓
- docker-compose entry: 같은 패턴 ✓
5. 누락 검토:
- compiler.py, video_producer.py, pipeline/orchestrator.py는 FFmpeg/Anthropic/OpenAI/YouTube 호출 — NAS 유지 (spec §10 Suno+MusicGen만 이전). 변경 없음. ✓
- youtube oauth (yt_module): NAS 유지. 변경 없음. ✓
- /api/music/{compile,pipeline,video-project,setup,youtube,market,revenue,library}: 모두 NAS local 처리 (DB·FFmpeg·Anthropic) — 변경 없음. ✓
- update_track_cover_images, update_track_wav_url, update_track_video_url, update_track_stem_urls: NAS local DB 함수. Windows에서는 webhook으로 처리 — cover_image/wav/video 등은 URL을 audio_url 필드에 JSON encode로 위임 (간소화 트레이드오프). 명세 §10에는 track column 업데이트 강제 없음. ✓
플랜이 완성되어 모든 검토를 통과했습니다.
부록: 트러블슈팅 (Plan-B-Insta 경험 반영)
Pre-existing 함정 (Plan-B-Insta에서 발견):
-
NAS DB 함수 시그니처 변경 —
update_task인자 누락- 증상: webhook 호출 시 NAS DB
update_task(task_id, status, progress, message, audio_url, error)시그니처와 webhook payload 불일치 - 방지: Task 4의 webhook_update_task 시그니처를 NAS update_task와 정확히 동일하게 (
task_id, status, progress, message, audio_url, error).
- 증상: webhook 호출 시 NAS DB
-
payload
track필드 dict vs JSON 문자열- 증상: NAS는 add_track이 dict를 받고 내부에서
json.dumps(moods)수행. webhook 페이로드는 dict 그대로 보내야 함. - 방지: Task 1 internal_router의
UpdatePayload.track: Optional[Dict[str, Any]]— JSON 디시리얼라이즈 자동.
- 증상: NAS는 add_track이 dict를 받고 내부에서
-
SMB mount 안의 디렉토리 권한 + 부재
- 증상:
/mnt/nas/webpage/data/music디렉토리 미존재로 첫 write 실패 - 방지: Task 5
_download_and_register에서os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)호출. Task 10 compose에서 volume mount.
- 증상:
-
WSL2 host.docker.internal 미작동
- 증상: MusicGen이 Windows native(localhost:8765)에서 동작하지만 WSL2 컨테이너가 host.docker.internal로 접근 못함
- 방지: Task 10 compose의
extra_hosts: ["host.docker.internal:host-gateway"](Linux Docker에서 host 매핑).
-
Suno polling timeout 초과 — 8초 × 40 = 5분 20초
- 증상: 일부 Suno 모델은 5분 초과 (V5 등)
- 방지: Task 5 _poll_suno_record
max_attempts=POLL_MAX_ATTEMPTS(40)유지 (NAS와 동일). 초과 시 webhook으로 failed 보고하면 사용자가 재시도 가능.
-
NAS .env에서 SUNO_API_KEY 완전 제거 후 dangling reference
- 증상: 어딘가에서
os.getenv("SUNO_API_KEY")검증으로 endpoint 비활성화 - 방지: Task 12에서
if SUNO_API_KEY:체크 라인 제거 + Task 14에서 stub의SUNO_API_KEY = ""sentinel 유지 (다른 모듈 호환).
- 증상: 어딘가에서
-
DDNS hairpin NAT 문제 (Plan-B-Insta 경험)
- 증상: Windows WSL2에서
gahusb.synology.me외부 IP로 접근 시 hairpin 실패 - 방지: Task 3
.env.example의 NAS_BASE_URL은 LAN IP192.168.45.54:18600직접 사용.
- 증상: Windows WSL2에서