Files
web-page-backend/docs/superpowers/plans/2026-05-19-plan-b-music-render.md
gahusb 714224a9b4 docs(plan): Plan-B-Music — music-render Windows worker + NAS 분할
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>
2026-05-19 03:02:48 +09:00

3242 lines
123 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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와의 차이:**
1. **Endpoint 수**: 인스타 1개(render) vs 음악 14개(13 Suno async + 1 MusicGen async + 4 sync helpers)
2. **Sync 호출 존재**: lyrics·credits·timestamped-lyrics·style-boost는 즉시 응답 필요 → HTTP forward 패턴 추가
3. **SUNO_API_KEY 이전**: NAS `.env`에서 완전 제거 (spec §9)
4. **DB 호출 변환**: Windows에서 NAS DB 직접 접근 불가 → 모든 `add_track`/`update_task`/`update_track_*` 호출을 webhook 페이로드로 변환
5. **`job_type` discriminator**: 큐 단일이지만 13가지 작업 분기 필요 (worker dispatch by `params.job_type`)
6. **결과 디렉토리**: `/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`:
```python
"""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`:
```python
"""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`:
```python
"""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`:
```python
"""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: 커밋**
```bash
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) 끝에 추가:
```python
import redis.asyncio as aioredis
from .internal_router import router as internal_router
```
`app = FastAPI()` (현재 line 40) 바로 뒤에 추가:
```python
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에서 완전 제거)
**추가**:
```yaml
- 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과 동일):
```yaml
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: 커밋**
```bash
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`:
```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: 커밋**
```bash
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`:
```python
"""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`:
```python
"""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: 커밋**
```bash
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`:
```python
```
(빈 파일)
- [ ] **Step 2: 실패하는 테스트 작성**
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py`:
```python
"""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`:
```python
"""Suno API Provider — sunoapi.org 래퍼.
NAS music-lab/app/suno_provider.py에서 이식. 차이점:
- DB 호출(update_task, add_track 등)을 nas_client.webhook_* 으로 변환
- 결과 MP3는 MUSIC_MEDIA_ROOT (/mnt/nas/webpage/data/music/)에 직접 저장
"""
from __future__ import annotations
import json
import logging
import os
import time
from typing import Optional
import requests
from nas_client import webhook_update_task, webhook_add_track
logger = logging.getLogger(__name__)
SUNO_BASE_URL = "https://api.sunoapi.org/api/v1"
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")
MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music")
POLL_INTERVAL = 8
POLL_MAX_ATTEMPTS = 40
def _headers() -> dict:
return {
"Authorization": f"Bearer {SUNO_API_KEY}",
"Content-Type": "application/json",
}
def _build_suno_payload(params: dict) -> dict:
"""프론트엔드 params → sunoapi.org 요청 형식 (NAS 코드 그대로 이식)."""
instrumental = params.get("instrumental", False)
has_lyrics = bool(params.get("lyrics"))
custom_mode = has_lyrics or bool(params.get("genre")) or bool(params.get("moods"))
payload = {
"customMode": custom_mode,
"instrumental": instrumental,
"model": params.get("model", "V4"),
"callBackUrl": "https://example.com/noop",
}
if custom_mode:
if instrumental:
payload["prompt"] = ""
elif has_lyrics:
payload["prompt"] = params["lyrics"][:3000]
else:
prompt_text = params.get("prompt", "")
payload["prompt"] = prompt_text[:3000] if prompt_text else ""
style_parts = []
if params.get("genre"):
style_parts.append(params["genre"])
if params.get("moods"):
style_parts.extend(params["moods"])
if params.get("instruments"):
style_parts.extend(params["instruments"][:3])
if style_parts:
payload["style"] = ", ".join(style_parts)[:200]
if params.get("title"):
payload["title"] = params["title"][:80]
else:
parts = []
if params.get("prompt"):
parts.append(params["prompt"])
if params.get("genre"):
parts.append(params["genre"])
if params.get("moods"):
parts.append(", ".join(params["moods"]))
payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music"
if params.get("vocal_gender"):
payload["vocalGender"] = params["vocal_gender"]
if params.get("negative_tags"):
payload["negativeTags"] = params["negative_tags"]
if params.get("style_weight") is not None:
payload["styleWeight"] = params["style_weight"]
if params.get("audio_weight") is not None:
payload["audioWeight"] = params["audio_weight"]
return payload
def _poll_suno_record(
record_info_path: str,
suno_task_id: str,
task_id: str,
max_attempts: int = POLL_MAX_ATTEMPTS,
interval: int = POLL_INTERVAL,
progress_msg_map: dict = None,
) -> Optional[dict]:
"""범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환."""
error_statuses = {
"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
"CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
}
default_msgs = {
"PENDING": "대기열에서 대기 중...",
"TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
"FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
"GENERATING": "생성 중...",
}
msgs = {**default_msgs, **(progress_msg_map or {})}
for attempt in range(max_attempts):
time.sleep(interval)
try:
resp = requests.get(
f"{SUNO_BASE_URL}{record_info_path}",
headers=_headers(),
params={"taskId": suno_task_id},
timeout=15,
)
if resp.status_code != 200:
continue
body = resp.json()
if body.get("code") != 200:
continue
data = body.get("data", {})
status = data.get("status", "")
progress = min(15 + int((attempt / max_attempts) * 65), 79)
if status == "SUCCESS":
return data.get("response", data)
elif status in error_statuses:
error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
webhook_update_task(task_id, "failed", 0, "", error=error_msg)
return None
else:
msg = msgs.get(status, f"처리 중... ({status})")
if status == "FIRST_SUCCESS":
progress = max(progress, 60)
webhook_update_task(task_id, "processing", progress, msg)
except Exception as e:
logger.warning("Suno poll error (attempt %d): %s", attempt, e)
continue
webhook_update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
return None
def _download_and_register(
task_id: str, song: dict, params: dict, filename_suffix: str = "",
) -> Optional[dict]:
"""Suno CDN에서 MP3 다운로드 → /mnt/nas/...에 직접 저장 → webhook으로 add_track."""
audio_url_remote = song.get("audioUrl") or song.get("audio_url", "")
if not audio_url_remote:
webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 audioUrl이 없습니다")
return None
filename = f"{task_id}{filename_suffix}.mp3"
os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)
file_path = os.path.join(MUSIC_MEDIA_ROOT, filename)
try:
dl = requests.get(audio_url_remote, timeout=120, stream=True)
dl.raise_for_status()
with open(file_path, "wb") as f:
for chunk in dl.iter_content(chunk_size=8192):
f.write(chunk)
except Exception as e:
webhook_update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}")
return None
local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
genre = params.get("genre", song.get("tags", ""))
moods = params.get("moods", [])
mood_str = moods[0] if moods else "Original"
title = (
song.get("title")
or params.get("title")
or (f"{genre}{mood_str} Mix" if genre else f"{mood_str} Mix")
)
track_data = {
"title": title,
"genre": genre,
"moods": moods,
"instruments": params.get("instruments", []),
"duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"),
"bpm": params.get("bpm"),
"key": params.get("key", ""),
"scale": params.get("scale", ""),
"prompt": song.get("prompt", params.get("prompt", "")),
"audio_url": local_audio_url,
# NAS file_path는 NAS 관점 — /app/data 안의 경로
"file_path": f"/app/data/{filename}",
"task_id": task_id,
"provider": "suno",
"lyrics": song.get("prompt", params.get("lyrics", "")),
"image_url": song.get("imageUrl") or song.get("image_url", ""),
"suno_id": song.get("id", ""),
}
return track_data
def run_suno_generation(task_id: str, params: dict) -> None:
"""BackgroundTask: Suno API로 곡 생성 → MP3 → NAS SMB 저장 → webhook add_track."""
try:
if not SUNO_API_KEY:
webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정 (Windows .env)")
return
webhook_update_task(task_id, "processing", 5, "Suno API에 연결 중...")
payload = _build_suno_payload(params)
resp = requests.post(f"{SUNO_BASE_URL}/generate", headers=_headers(), json=payload, timeout=30)
if resp.status_code != 200:
err = resp.text[:300] if resp.text else f"HTTP {resp.status_code}"
webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {err}")
return
body = resp.json()
if body.get("code") != 200:
webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 거부: {body.get('msg', '?')}")
return
suno_task_id = body.get("data", {}).get("taskId", "")
if not suno_task_id:
webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 taskId 없음")
return
webhook_update_task(task_id, "processing", 15, "곡 생성 대기열에 등록됨...")
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
if not response:
return
completed = response.get("sunoData") or []
if not completed:
webhook_update_task(task_id, "failed", 0, "", error="Suno 완료했으나 트랙 데이터 없음")
return
webhook_update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
track = _download_and_register(task_id, completed[0], params)
if not track:
return
webhook_add_track(task_id, "succeeded", 100, "생성 완료",
audio_url=track["audio_url"], track=track)
if len(completed) > 1:
try:
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: 커밋**
```bash
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`:
```python
"""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: 커밋**
```bash
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`:
```python
"""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: 커밋**
```bash
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`:
```python
"""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`:
```python
"""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: 커밋**
```bash
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`:
```python
"""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: 커밋**
```bash
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 아래)에 추가:
```yaml
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):
```bash
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:
```bash
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:
```bash
curl http://localhost:18711/health
```
Expected: `{"ok": true, "service": "music-render"}`
NAS SMB write 확인 (worker가 컨테이너에서 mount 접근 가능한지):
```bash
docker exec music-render ls -la /mnt/nas/webpage/data/music | head
```
Expected: 에러 없이 디렉토리 리스트 출력.
- [ ] **Step 5: 커밋 + push**
```bash
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.py` import 블록 끝(internal_router import 다음 줄)에 helper 추가**
`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py`에서 `app = FastAPI()` 위에 추가:
```python
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) 본문 변경:
```python
@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) 본문 변경:
```python
@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):
```python
@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` 변경**
각각:
```python
@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` 변경**
```python
@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: 커밋**
```bash
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`:
```python
"""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) 변경:
```python
@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):
```python
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,
)
```
→ 변경:
```python
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로 이전됨). 변경:
```python
@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: 커밋**
```bash
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` 함수 전체 교체:
```python
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: 커밋**
```bash
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` 전체를 다음으로 교체:
```python
"""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` 전체를 다음으로 교체:
```python
"""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:
```bash
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: 커밋**
```bash
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**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push
```
Expected: push 성공. 만약 자격증명 실패 시 박재오에게 수동 push 요청.
- [ ] **Step 2: 박재오에게 NAS 컨테이너 rebuild 확인 요청**
NAS의 Gitea webhook이 자동으로 deployer를 트리거. 박재오 측에서 NAS deployer 로그 확인:
```bash
ssh nas
docker logs webpage-deployer --tail 50
```
Expected: `music-lab` rebuild + restart 라인 확인.
- [ ] **Step 3: 헬스 확인**
박재오 NAS:
```bash
curl https://gahusb.synology.me/api/music/providers
```
Expected: `{"providers": [{"id": "local", ...}, {"id": "suno", ...}]}`
```bash
docker logs music-lab --tail 20
```
Expected: 시작 로그, `Application startup complete` 표시.
```bash
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:
```bash
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) 바로 다음에 추가:
```nginx
# 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 자동)**
```bash
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:
```bash
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)에서:
```bash
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 내부):
```bash
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:
```bash
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:
```bash
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 상태 확인 (폴링)**
박재오:
```bash
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:
```bash
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 크레딧 조회)**
박재오:
```bash
curl https://gahusb.synology.me/api/music/credits
```
Expected: `{"credits_left": <숫자>}` 또는 Suno API 응답 데이터.
- [ ] **Step 6: 모든 NAS·web-ai 변경 push 최종 확인**
```bash
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`:
```markdown
---
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에 한 줄 추가:
```markdown
- [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에서 발견):**
1. **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`).
2. **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 디시리얼라이즈 자동.
3. **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.
4. **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 매핑).
5. **Suno polling timeout 초과 — 8초 × 40 = 5분 20초**
- 증상: 일부 Suno 모델은 5분 초과 (V5 등)
- 방지: Task 5 _poll_suno_record `max_attempts=POLL_MAX_ATTEMPTS(40)` 유지 (NAS와 동일). 초과 시 webhook으로 failed 보고하면 사용자가 재시도 가능.
6. **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 유지 (다른 모듈 호환).
7. **DDNS hairpin NAT 문제 (Plan-B-Insta 경험)**
- 증상: Windows WSL2에서 `gahusb.synology.me` 외부 IP로 접근 시 hairpin 실패
- 방지: Task 3 `.env.example`의 NAS_BASE_URL은 LAN IP `192.168.45.54:18600` 직접 사용.