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

123 KiB
Raw Blame History

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_trackrun_suno_generation 직접 호출을 Redis push로 변경 batch도 worker 경유
web-backend/music-lab/requirements.txt redis>=5.0 추가 (requests는 mutagen/youtube oauth용 유지) 의존성
web-backend/music-lab/.env (예시) REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL 추가. SUNO_API_KEY 제거 환경
web-backend/docker-compose.yml music-lab service에 REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL env 추가 + depends_on redis + SUNO_API_KEY 라인 제거 compose
web-backend/nginx/default.conf location /api/internal/music/ IP allow + deny all (insta 블록 복제) 3-layer 차단

Phase 2 — Windows web-ai/services/

파일 변경 책임
web-ai/services/music-render/Dockerfile (Create) python:3.12-slim + requests + httpx + redis + mutagen image
web-ai/services/music-render/requirements.txt (Create) fastapi, uvicorn, requests, redis, httpx, mutagen, pytest deps
web-ai/services/music-render/.env.example (Create) REDIS_URL, NAS_BASE_URL, INTERNAL_API_KEY, SUNO_API_KEY, MUSIC_AI_SERVER_URL(MusicGen 호스트), MUSIC_MEDIA_ROOT, MUSIC_MEDIA_URL_PREFIX secrets
web-ai/services/music-render/providers/__init__.py (Create) 빈 패키지 마커 package
web-ai/services/music-render/providers/suno.py (Create) NAS suno_provider.py 이식 — 13 함수 + add_track/update_task 호출을 webhook 호출로 변환 Suno API client
web-ai/services/music-render/providers/local.py (Create) NAS local_provider.py 이식 — MusicGen 호출 (192.168.45.59:8765) + webhook MusicGen client
web-ai/services/music-render/providers/sync_ops.py (Create) sync Suno API 4종 (lyrics, credits, timestamped-lyrics, style-boost) sync helper
web-ai/services/music-render/nas_client.py (Create) webhook_update_task, webhook_add_track — NAS POST 호출 헬퍼 (X-Internal-Key) webhook adapter
web-ai/services/music-render/worker.py (Create) Redis BLPOP 루프 + queue:paused 체크 + job_type 디스패처 dispatcher
web-ai/services/music-render/main.py (Create) FastAPI app + lifespan(worker spawn) + sync endpoints (/api/music-render/sync/*) entry
web-ai/services/music-render/tests/test_worker.py (Create) mocked Redis + job_type 디스패치 검증 TDD
web-ai/services/music-render/tests/test_nas_client.py (Create) httpx mock webhook 호출 검증 TDD
web-ai/services/docker-compose.yml music-render service 추가 compose

Task 1: NAS music-lab — verify_internal_key + /api/internal/music/update endpoint

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/auth.py

  • Create: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/internal_router.py

  • Test: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_auth.py

  • Test: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_internal_router.py

  • Step 1: auth.py 실패하는 테스트 작성

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_auth.py:

"""verify_internal_key dependency — Windows music-render webhook 인증."""
import pytest
from fastapi import HTTPException
from app.auth import verify_internal_key


def test_valid_key_passes(monkeypatch):
    monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
    verify_internal_key(x_internal_key="secret123")


def test_invalid_key_raises_401(monkeypatch):
    monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
    with pytest.raises(HTTPException) as exc:
        verify_internal_key(x_internal_key="wrong")
    assert exc.value.status_code == 401


def test_missing_env_key_raises_401(monkeypatch):
    monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
    with pytest.raises(HTTPException) as exc:
        verify_internal_key(x_internal_key="any")
    assert exc.value.status_code == 401
  • Step 2: auth.py 테스트 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab && python -m pytest tests/test_auth.py -v Expected: FAIL — app.auth 모듈 미존재.

  • Step 3: auth.py 작성

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/auth.py:

"""SP-6 — Windows worker → NAS internal webhook 인증.

X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
서버 측 키 미설정 시 401 (안전한 기본값).
"""
from __future__ import annotations

import os
from fastapi import Header, HTTPException


def verify_internal_key(x_internal_key: str = Header(...)):
    expected = os.getenv("INTERNAL_API_KEY")
    if not expected:
        raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
    if x_internal_key != expected:
        raise HTTPException(401, "Invalid X-Internal-Key")
  • Step 4: auth.py 테스트 통과

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab && python -m pytest tests/test_auth.py -v Expected: 3 PASS.

  • Step 5: internal_router.py 실패하는 테스트 작성

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_internal_router.py:

"""POST /api/internal/music/update — Windows music-render webhook."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal_router import router
from app import db


@pytest.fixture(autouse=True)
def _set_key(monkeypatch):
    monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")


@pytest.fixture
def client(tmp_path, monkeypatch):
    monkeypatch.setenv("MUSIC_DATA_DIR", str(tmp_path))
    db.init_db()
    app = FastAPI()
    app.include_router(router)
    return TestClient(app)


def _make_task():
    tid = "test-task-1"
    db.create_task(tid, {"provider": "suno", "title": "T"}, provider="suno")
    return tid


def test_update_with_valid_key_updates_db(client):
    tid = _make_task()
    r = client.post(
        "/api/internal/music/update",
        headers={"X-Internal-Key": "test-secret"},
        json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"},
    )
    assert r.status_code == 200
    task = db.get_task(tid)
    assert task["status"] == "processing"
    assert task["progress"] == 30


def test_update_with_invalid_key_returns_401(client):
    tid = _make_task()
    r = client.post(
        "/api/internal/music/update",
        headers={"X-Internal-Key": "wrong"},
        json={"task_id": tid, "status": "processing", "progress": 30},
    )
    assert r.status_code == 401


def test_update_succeeded_with_audio_url(client):
    tid = _make_task()
    r = client.post(
        "/api/internal/music/update",
        headers={"X-Internal-Key": "test-secret"},
        json={
            "task_id": tid, "status": "succeeded", "progress": 100,
            "message": "완료", "audio_url": "/media/music/test-task-1.mp3",
        },
    )
    assert r.status_code == 200
    task = db.get_task(tid)
    assert task["status"] == "succeeded"
    assert task["audio_url"] == "/media/music/test-task-1.mp3"


def test_update_failed_records_error(client):
    tid = _make_task()
    r = client.post(
        "/api/internal/music/update",
        headers={"X-Internal-Key": "test-secret"},
        json={"task_id": tid, "status": "failed", "progress": 0, "error": "Suno API rate limit"},
    )
    assert r.status_code == 200
    task = db.get_task(tid)
    assert task["status"] == "failed"
    assert "Suno" in (task.get("error") or "")


def test_add_track_action_inserts_library(client):
    tid = _make_task()
    r = client.post(
        "/api/internal/music/update",
        headers={"X-Internal-Key": "test-secret"},
        json={
            "task_id": tid, "status": "succeeded", "progress": 100,
            "message": "ok",
            "track": {
                "title": "My Song", "genre": "lofi", "moods": ["chill"],
                "instruments": [], "duration_sec": 180, "bpm": 80,
                "key": "C", "scale": "major", "prompt": "",
                "audio_url": "/media/music/test-task-1.mp3",
                "file_path": "/app/data/test-task-1.mp3",
                "task_id": tid, "provider": "suno",
                "lyrics": "", "image_url": "", "suno_id": "suno-abc",
            },
        },
    )
    assert r.status_code == 200
    tracks = db.get_all_tracks()
    assert any(t["title"] == "My Song" for t in tracks)
  • Step 6: internal_router.py 테스트 실패 확인

Run: python -m pytest tests/test_internal_router.py -v Expected: FAIL — app.internal_router 미존재.

  • Step 7: internal_router.py 작성

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/internal_router.py:

"""SP-6 — Windows music-render → NAS internal webhook.

POST /api/internal/music/update
- X-Internal-Key 인증 필수
- music_tasks 테이블 row update (status, progress, message, audio_url, error)
- 옵션 `track` 페이로드가 있으면 music_library에 add_track 호출
"""
from __future__ import annotations

import logging
from typing import Any, Dict, Optional

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

from . import db
from .auth import verify_internal_key

logger = logging.getLogger(__name__)
router = APIRouter()


class UpdatePayload(BaseModel):
    task_id: str
    status: str = Field(..., description="processing|succeeded|failed")
    progress: int = Field(..., ge=0, le=100)
    message: str = ""
    audio_url: Optional[str] = None
    error: Optional[str] = None
    # Optional: 라이브러리 등록을 함께 요청. add_track 페이로드 그대로.
    track: Optional[Dict[str, Any]] = None


@router.post(
    "/api/internal/music/update",
    dependencies=[Depends(verify_internal_key)],
)
def music_update(payload: UpdatePayload):
    task = db.get_task(payload.task_id)
    if task is None:
        raise HTTPException(404, f"task not found: {payload.task_id}")

    db.update_task(
        payload.task_id,
        payload.status,
        payload.progress,
        message=payload.message,
        audio_url=payload.audio_url,
        error=payload.error,
    )

    if payload.track:
        try:
            db.add_track(payload.track)
        except Exception:
            logger.exception("add_track 실패 task=%s (무시)", payload.task_id)

    logger.info(
        "internal/music/update task=%s status=%s progress=%d",
        payload.task_id, payload.status, payload.progress,
    )
    return {"ok": True}
  • Step 8: internal_router.py 테스트 통과

Run: python -m pytest tests/test_internal_router.py -v Expected: 5 PASS.

  • Step 9: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add music-lab/app/auth.py music-lab/app/internal_router.py music-lab/tests/test_auth.py music-lab/tests/test_internal_router.py
git commit -m "$(cat <<'EOF'
feat(music-lab): verify_internal_key + /api/internal/music/update (SP-6)

X-Internal-Key 헤더 검증 dependency (insta-lab 동일 패턴).
Windows music-render webhook 수신 endpoint — update_task + 옵션 add_track.
Plan-B-Music Phase 1 (수신부).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: NAS music-lab — main.py wire (redis client + router include) + docker-compose env

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py:1-50 (imports + redis client + router)

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/requirements.txt

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml:55-90 (music-lab section)

  • Step 1: requirements.txt에 redis 추가

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/requirements.txt에 다음 라인 추가 (mutagen==1.47.0 다음):

redis>=5.0
  • Step 2: main.py — redis client + internal_router include

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py의 import 블록 (현재 line 1-39) 끝에 추가:

import redis.asyncio as aioredis
from .internal_router import router as internal_router

app = FastAPI() (현재 line 40) 바로 뒤에 추가:

REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)

app.include_router(internal_router)
  • Step 3: smoke test — import만 확인

Run: cd music-lab && python -c "from app.main import app, redis_client; print('OK')" Expected: OK 출력.

  • Step 4: docker-compose.yml — music-lab 환경변수 추가

C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml의 music-lab service (line 55-90) 내 environment: 섹션에서:

제거: - SUNO_API_KEY=${SUNO_API_KEY:-} (spec §9 — NAS .env에서 완전 제거)

추가:

      - REDIS_URL=${REDIS_URL:-redis://redis:6379}
      - INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
      - MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}

같은 service 블록 끝(healthcheck 앞)에 depends_on: 추가 (insta-lab과 동일):

    depends_on:
      - redis

(이미 있다면 redis만 항목 추가)

  • Step 5: docker-compose 검증

Run: cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose config | grep -A 30 "music-lab:" Expected: REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL 표시. SUNO_API_KEY 미표시.

  • Step 6: 커밋
git add music-lab/app/main.py music-lab/requirements.txt docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(music-lab): wire redis client + internal_router + compose env (SP-6)

main.py에 redis.asyncio client 추가 + internal_router include.
docker-compose의 music-lab에 REDIS_URL/INTERNAL_API_KEY/MUSIC_RENDER_URL.
SUNO_API_KEY 라인 제거 (spec §9 — Windows로 이전).
Plan-B-Music Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Windows music-render — Dockerfile + requirements + .env.example

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/Dockerfile

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/requirements.txt

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/.env.example

  • Step 1: requirements.txt 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/requirements.txt:

fastapi==0.115.6
uvicorn[standard]==0.34.0
requests==2.32.3
redis>=5.0
httpx>=0.27
mutagen==1.47.0
pytest>=8.0
pytest-asyncio>=0.24
respx>=0.21
  • Step 2: Dockerfile 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/Dockerfile:

FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1

WORKDIR /app

# requests SSL 의존성만 필요 (Chromium 불필요)
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
 && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
  • Step 3: .env.example 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/.env.example:

# Plan-B-Music — Windows music-render worker

# NAS Redis 큐
REDIS_URL=redis://192.168.45.54:6379

# NAS internal webhook
NAS_BASE_URL=http://192.168.45.54:18600
INTERNAL_API_KEY=__copy_from_nas_dotenv__

# Suno API (sunoapi.org 래퍼) — NAS .env에서 옮겨옴
SUNO_API_KEY=__paste_suno_key_here__

# MusicGen 호스트 (Windows native Python — 박재오 PC localhost)
MUSIC_AI_SERVER_URL=http://host.docker.internal:8765

# NAS SMB mount 안의 음악 디렉토리
MUSIC_MEDIA_ROOT=/mnt/nas/webpage/data/music

# nginx 서빙 prefix (NAS webhook payload용)
MUSIC_MEDIA_URL_PREFIX=/media/music
  • Step 4: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/Dockerfile services/music-render/requirements.txt services/music-render/.env.example
git commit -m "$(cat <<'EOF'
feat(music-render): Dockerfile + requirements + env.example (SP-5)

Windows WSL2 Docker 컨테이너 스캐폴드.
Plan-B-Insta보다 가벼움 — Chromium 미포함, requests + httpx + redis + mutagen만.
.env.example에 SUNO_API_KEY 자리 (NAS에서 옮겨올 값).
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: Windows music-render — nas_client.py (webhook 헬퍼)

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/nas_client.py
  • Test: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_nas_client.py

NAS DB 직접 접근이 불가하므로 모든 update_task/add_track 호출을 webhook으로 변환. 이를 위한 어댑터 모듈.

  • Step 1: 실패하는 테스트 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_nas_client.py:

"""nas_client — webhook adapter tests."""
import os
import pytest
import respx
import httpx

from nas_client import webhook_update_task, webhook_add_track


@pytest.fixture(autouse=True)
def _env(monkeypatch):
    monkeypatch.setenv("NAS_BASE_URL", "http://nas-test:18600")
    monkeypatch.setenv("INTERNAL_API_KEY", "test-key")


@respx.mock
def test_webhook_update_task_sends_x_internal_key():
    route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
        return_value=httpx.Response(200, json={"ok": True})
    )
    webhook_update_task("task-1", "processing", 30, message="downloading")
    assert route.called
    req = route.calls[0].request
    assert req.headers["X-Internal-Key"] == "test-key"
    import json
    body = json.loads(req.content)
    assert body["task_id"] == "task-1"
    assert body["status"] == "processing"
    assert body["progress"] == 30
    assert body["message"] == "downloading"


@respx.mock
def test_webhook_update_task_with_audio_url():
    route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
        return_value=httpx.Response(200, json={"ok": True})
    )
    webhook_update_task("task-2", "succeeded", 100, message="완료",
                       audio_url="/media/music/task-2.mp3")
    body = httpx.URL(route.calls[0].request.url)
    import json
    payload = json.loads(route.calls[0].request.content)
    assert payload["audio_url"] == "/media/music/task-2.mp3"
    assert payload["status"] == "succeeded"


@respx.mock
def test_webhook_update_task_with_error():
    route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
        return_value=httpx.Response(200, json={"ok": True})
    )
    webhook_update_task("task-3", "failed", 0, error="API rate limit")
    import json
    payload = json.loads(route.calls[0].request.content)
    assert payload["error"] == "API rate limit"


@respx.mock
def test_webhook_add_track_uses_track_field():
    """add_track은 update와 동시에 (succeeded 시)."""
    route = respx.post("http://nas-test:18600/api/internal/music/update").mock(
        return_value=httpx.Response(200, json={"ok": True})
    )
    track = {"title": "x", "audio_url": "/media/music/t.mp3", "provider": "suno"}
    webhook_add_track("task-4", "succeeded", 100, message="ok",
                     audio_url="/media/music/t.mp3", track=track)
    import json
    payload = json.loads(route.calls[0].request.content)
    assert payload["track"]["title"] == "x"
    assert payload["status"] == "succeeded"


@respx.mock
def test_webhook_swallows_network_error(caplog):
    """webhook 실패해도 raise 안 함 (logger.error)."""
    respx.post("http://nas-test:18600/api/internal/music/update").mock(
        side_effect=httpx.ConnectError("no host")
    )
    # raise 안 하면 통과
    webhook_update_task("task-5", "processing", 10)
  • Step 2: 테스트 실패 확인

Run: cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render && python -m pytest tests/test_nas_client.py -v Expected: FAIL — nas_client 모듈 미존재.

  • Step 3: nas_client.py 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/nas_client.py:

"""NAS webhook 어댑터 — Windows worker가 NAS DB 직접 접근 못하므로 HTTP로 위임.

기존 NAS suno_provider/local_provider의 `update_task`, `add_track` 호출을
이 모듈의 webhook_update_task/webhook_add_track으로 치환.

webhook 실패는 raise하지 않고 logger.error로 기록 (provider 로직 흐름 유지).
"""
from __future__ import annotations

import logging
import os
from typing import Any, Dict, Optional

import httpx

logger = logging.getLogger(__name__)

NAS_BASE_URL = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18600")
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "")
_TIMEOUT = 10.0


def _post(payload: Dict[str, Any]) -> None:
    url = f"{NAS_BASE_URL}/api/internal/music/update"
    try:
        r = httpx.post(
            url,
            headers={"X-Internal-Key": INTERNAL_API_KEY},
            json=payload,
            timeout=_TIMEOUT,
        )
        if r.status_code != 200:
            logger.error("webhook %s returned %d: %s",
                         payload.get("task_id"), r.status_code, r.text[:200])
    except Exception:
        logger.exception("webhook %s 호출 실패", payload.get("task_id"))


def webhook_update_task(
    task_id: str,
    status: str,
    progress: int,
    message: str = "",
    audio_url: Optional[str] = None,
    error: Optional[str] = None,
) -> None:
    """기존 update_task(task_id, status, progress, message, audio_url, error) 대체."""
    payload: Dict[str, Any] = {
        "task_id": task_id,
        "status": status,
        "progress": progress,
        "message": message,
    }
    if audio_url is not None:
        payload["audio_url"] = audio_url
    if error is not None:
        payload["error"] = error
    _post(payload)


def webhook_add_track(
    task_id: str,
    status: str,
    progress: int,
    message: str = "",
    audio_url: Optional[str] = None,
    track: Optional[Dict[str, Any]] = None,
) -> None:
    """update + add_track을 한 webhook 호출로 결합 (NAS internal_router가 둘 다 처리)."""
    payload: Dict[str, Any] = {
        "task_id": task_id,
        "status": status,
        "progress": progress,
        "message": message,
    }
    if audio_url is not None:
        payload["audio_url"] = audio_url
    if track is not None:
        payload["track"] = track
    _post(payload)
  • Step 4: 테스트 통과

Run: python -m pytest tests/test_nas_client.py -v Expected: 5 PASS.

  • Step 5: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/nas_client.py services/music-render/tests/test_nas_client.py
git commit -m "$(cat <<'EOF'
feat(music-render): nas_client webhook adapter (SP-5)

NAS DB 직접 접근 불가 → webhook_update_task/webhook_add_track으로 변환.
X-Internal-Key 헤더 자동 첨부. 실패 시 raise 안 함 (logger.error).
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: Windows music-render — providers/suno.py (13 함수 이식)

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/__init__.py
  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/suno.py
  • Test: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py

NAS music-lab/app/suno_provider.py를 이식. 차이점:

  • from .db import update_task, add_track, ...from nas_client import webhook_update_task, webhook_add_track

  • MUSIC_DATA_DIR = "/app/data"MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music") — NAS SMB 직접 저장

  • MUSIC_MEDIA_BASE 그대로 사용 (/media/music)

  • update_track_cover_images, update_track_wav_url 등은 webhook에 새 action을 만들지 않고 일단 webhook_update_task로 message에 JSON encode (기능 손실 최소 — track 컬럼 업데이트는 batch 미사용)

  • Step 1: 빈 __init__.py 만들기

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/__init__.py:

(빈 파일)

  • Step 2: 실패하는 테스트 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py:

"""providers/suno.py — _build_suno_payload 단위 테스트 + 1개 함수 mock 검증."""
import pytest
from providers.suno import _build_suno_payload


def test_payload_custom_mode_with_lyrics():
    params = {"lyrics": "[Verse]\nhello", "genre": "lofi", "moods": ["chill"], "model": "V4"}
    p = _build_suno_payload(params)
    assert p["customMode"] is True
    assert p["prompt"] == "[Verse]\nhello"
    assert "lofi" in p["style"]
    assert "chill" in p["style"]


def test_payload_simple_mode_no_lyrics_no_genre():
    params = {"prompt": "happy summer", "model": "V4"}
    p = _build_suno_payload(params)
    assert p["customMode"] is False
    assert "happy summer" in p["prompt"]


def test_payload_instrumental_clears_prompt():
    params = {"genre": "ambient", "instrumental": True, "model": "V5"}
    p = _build_suno_payload(params)
    assert p["instrumental"] is True
    assert p["prompt"] == ""


def test_payload_includes_optional_vocal_gender():
    params = {"genre": "pop", "vocal_gender": "f", "model": "V4"}
    p = _build_suno_payload(params)
    assert p["vocalGender"] == "f"
  • Step 3: 테스트 실패 확인

Run: python -m pytest tests/test_suno_provider.py -v Expected: FAIL — providers.suno 미존재.

  • Step 4: providers/suno.py 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/suno.py:

"""Suno API Provider — sunoapi.org 래퍼.

NAS music-lab/app/suno_provider.py에서 이식. 차이점:
- DB 호출(update_task, add_track 등)을 nas_client.webhook_* 으로 변환
- 결과 MP3는 MUSIC_MEDIA_ROOT (/mnt/nas/webpage/data/music/)에 직접 저장
"""
from __future__ import annotations

import json
import logging
import os
import time
from typing import Optional

import requests

from nas_client import webhook_update_task, webhook_add_track

logger = logging.getLogger(__name__)

SUNO_BASE_URL = "https://api.sunoapi.org/api/v1"
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")
MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music")

POLL_INTERVAL = 8
POLL_MAX_ATTEMPTS = 40


def _headers() -> dict:
    return {
        "Authorization": f"Bearer {SUNO_API_KEY}",
        "Content-Type": "application/json",
    }


def _build_suno_payload(params: dict) -> dict:
    """프론트엔드 params → sunoapi.org 요청 형식 (NAS 코드 그대로 이식)."""
    instrumental = params.get("instrumental", False)
    has_lyrics = bool(params.get("lyrics"))
    custom_mode = has_lyrics or bool(params.get("genre")) or bool(params.get("moods"))

    payload = {
        "customMode": custom_mode,
        "instrumental": instrumental,
        "model": params.get("model", "V4"),
        "callBackUrl": "https://example.com/noop",
    }

    if custom_mode:
        if instrumental:
            payload["prompt"] = ""
        elif has_lyrics:
            payload["prompt"] = params["lyrics"][:3000]
        else:
            prompt_text = params.get("prompt", "")
            payload["prompt"] = prompt_text[:3000] if prompt_text else ""

        style_parts = []
        if params.get("genre"):
            style_parts.append(params["genre"])
        if params.get("moods"):
            style_parts.extend(params["moods"])
        if params.get("instruments"):
            style_parts.extend(params["instruments"][:3])
        if style_parts:
            payload["style"] = ", ".join(style_parts)[:200]

        if params.get("title"):
            payload["title"] = params["title"][:80]
    else:
        parts = []
        if params.get("prompt"):
            parts.append(params["prompt"])
        if params.get("genre"):
            parts.append(params["genre"])
        if params.get("moods"):
            parts.append(", ".join(params["moods"]))
        payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music"

    if params.get("vocal_gender"):
        payload["vocalGender"] = params["vocal_gender"]
    if params.get("negative_tags"):
        payload["negativeTags"] = params["negative_tags"]
    if params.get("style_weight") is not None:
        payload["styleWeight"] = params["style_weight"]
    if params.get("audio_weight") is not None:
        payload["audioWeight"] = params["audio_weight"]

    return payload


def _poll_suno_record(
    record_info_path: str,
    suno_task_id: str,
    task_id: str,
    max_attempts: int = POLL_MAX_ATTEMPTS,
    interval: int = POLL_INTERVAL,
    progress_msg_map: dict = None,
) -> Optional[dict]:
    """범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환."""
    error_statuses = {
        "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED",
        "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR",
    }
    default_msgs = {
        "PENDING": "대기열에서 대기 중...",
        "TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...",
        "FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...",
        "GENERATING": "생성 중...",
    }
    msgs = {**default_msgs, **(progress_msg_map or {})}

    for attempt in range(max_attempts):
        time.sleep(interval)
        try:
            resp = requests.get(
                f"{SUNO_BASE_URL}{record_info_path}",
                headers=_headers(),
                params={"taskId": suno_task_id},
                timeout=15,
            )
            if resp.status_code != 200:
                continue
            body = resp.json()
            if body.get("code") != 200:
                continue
            data = body.get("data", {})
            status = data.get("status", "")
            progress = min(15 + int((attempt / max_attempts) * 65), 79)

            if status == "SUCCESS":
                return data.get("response", data)
            elif status in error_statuses:
                error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})"
                webhook_update_task(task_id, "failed", 0, "", error=error_msg)
                return None
            else:
                msg = msgs.get(status, f"처리 중... ({status})")
                if status == "FIRST_SUCCESS":
                    progress = max(progress, 60)
                webhook_update_task(task_id, "processing", progress, msg)
        except Exception as e:
            logger.warning("Suno poll error (attempt %d): %s", attempt, e)
            continue

    webhook_update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃")
    return None


def _download_and_register(
    task_id: str, song: dict, params: dict, filename_suffix: str = "",
) -> Optional[dict]:
    """Suno CDN에서 MP3 다운로드 → /mnt/nas/...에 직접 저장 → webhook으로 add_track."""
    audio_url_remote = song.get("audioUrl") or song.get("audio_url", "")
    if not audio_url_remote:
        webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 audioUrl이 없습니다")
        return None

    filename = f"{task_id}{filename_suffix}.mp3"
    os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)
    file_path = os.path.join(MUSIC_MEDIA_ROOT, filename)

    try:
        dl = requests.get(audio_url_remote, timeout=120, stream=True)
        dl.raise_for_status()
        with open(file_path, "wb") as f:
            for chunk in dl.iter_content(chunk_size=8192):
                f.write(chunk)
    except Exception as e:
        webhook_update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}")
        return None

    local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"

    genre = params.get("genre", song.get("tags", ""))
    moods = params.get("moods", [])
    mood_str = moods[0] if moods else "Original"
    title = (
        song.get("title")
        or params.get("title")
        or (f"{genre}{mood_str} Mix" if genre else f"{mood_str} Mix")
    )

    track_data = {
        "title": title,
        "genre": genre,
        "moods": moods,
        "instruments": params.get("instruments", []),
        "duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"),
        "bpm": params.get("bpm"),
        "key": params.get("key", ""),
        "scale": params.get("scale", ""),
        "prompt": song.get("prompt", params.get("prompt", "")),
        "audio_url": local_audio_url,
        # NAS file_path는 NAS 관점 — /app/data 안의 경로
        "file_path": f"/app/data/{filename}",
        "task_id": task_id,
        "provider": "suno",
        "lyrics": song.get("prompt", params.get("lyrics", "")),
        "image_url": song.get("imageUrl") or song.get("image_url", ""),
        "suno_id": song.get("id", ""),
    }
    return track_data


def run_suno_generation(task_id: str, params: dict) -> None:
    """BackgroundTask: Suno API로 곡 생성 → MP3 → NAS SMB 저장 → webhook add_track."""
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정 (Windows .env)")
            return

        webhook_update_task(task_id, "processing", 5, "Suno API에 연결 중...")
        payload = _build_suno_payload(params)
        resp = requests.post(f"{SUNO_BASE_URL}/generate", headers=_headers(), json=payload, timeout=30)

        if resp.status_code != 200:
            err = resp.text[:300] if resp.text else f"HTTP {resp.status_code}"
            webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {err}")
            return

        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 거부: {body.get('msg', '?')}")
            return

        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 taskId 없음")
            return

        webhook_update_task(task_id, "processing", 15, "곡 생성 대기열에 등록됨...")

        response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="Suno 완료했으나 트랙 데이터 없음")
            return

        webhook_update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
        track = _download_and_register(task_id, completed[0], params)
        if not track:
            return

        webhook_add_track(task_id, "succeeded", 100, "생성 완료",
                          audio_url=track["audio_url"], track=track)

        if len(completed) > 1:
            try:
                second = _download_and_register(f"{task_id}_v2", completed[1], params)
                if second:
                    # 두 번째 변형은 별도 task가 아니라 별도 track으로만 등록
                    webhook_add_track(f"{task_id}_v2", "succeeded", 100, "두 번째 변형",
                                      audio_url=second["audio_url"], track=second)
            except Exception:
                pass

    except requests.Timeout:
        webhook_update_task(task_id, "failed", 0, "", error="Suno API 타임아웃")
    except Exception as e:
        logger.exception("Suno generation error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_suno_extend(task_id: str, params: dict) -> None:
    """기존 곡을 특정 지점부터 연장."""
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정")
            return
        webhook_update_task(task_id, "processing", 5, "곡 연장 요청 중...")
        payload = {
            "audioId": params["suno_id"],
            "defaultParamFlag": not bool(params.get("prompt")),
            "prompt": params.get("prompt", ""),
            "continueAt": params.get("continue_at", 0),
            "model": params.get("model", "V4"),
            "callBackUrl": "https://example.com/noop",
        }
        if params.get("style"):
            payload["style"] = params["style"]
        if params.get("title"):
            payload["title"] = params["title"]

        resp = requests.post(f"{SUNO_BASE_URL}/generate/extend", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Suno Extend 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Extend 거부: {body.get('msg', '?')}")
            return
        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="Extend 응답에 taskId 없음")
            return
        webhook_update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...")

        response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="연장 완료했으나 트랙 없음")
            return
        webhook_update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...")
        track = _download_and_register(task_id, completed[0], params)
        if track:
            webhook_add_track(task_id, "succeeded", 100, "곡 연장 완료",
                              audio_url=track["audio_url"], track=track)
    except Exception as e:
        logger.exception("Suno extend error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_vocal_removal(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정")
            return
        webhook_update_task(task_id, "processing", 5, "보컬 분리 요청 중...")
        payload = {"audioId": params["suno_id"], "callBackUrl": "https://example.com/noop"}
        resp = requests.post(f"{SUNO_BASE_URL}/vocal-removal/generate", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Vocal Removal 오류: {resp.text[:300]}")
            return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Vocal Removal 거부: {body.get('msg', '?')}")
            return
        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음")
            return
        webhook_update_task(task_id, "processing", 15, "보컬 분리 처리 중...")
        response = _poll_suno_record("/vocal-removal/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="분리 완료했으나 트랙 없음")
            return
        webhook_update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...")
        vp = {**params, "title": f"{params.get('title', 'Track')} (Vocals)"}
        track = _download_and_register(task_id, completed[0], vp)
        if len(completed) > 1:
            ip = {**params, "title": f"{params.get('title', 'Track')} (Instrumental)"}
            second = _download_and_register(f"{task_id}_inst", completed[1], ip)
            if second:
                webhook_add_track(f"{task_id}_inst", "succeeded", 100, "Instrumental",
                                  audio_url=second["audio_url"], track=second)
        if track:
            webhook_add_track(task_id, "succeeded", 100, "보컬 분리 완료",
                              audio_url=track["audio_url"], track=track)
    except Exception as e:
        logger.exception("vocal removal error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_cover_image(task_id: str, params: dict) -> None:
    """Suno 곡의 커버 이미지 2장 (URL JSON 반환)."""
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...")
        suno_task_id = params.get("suno_task_id", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="suno_task_id 필요"); return
        payload = {"taskId": suno_task_id, "callBackUrl": "https://example.com/noop"}
        resp = requests.post(f"{SUNO_BASE_URL}/suno/cover/generate", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Cover API 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Cover 거부: {body.get('msg', '?')}"); return
        cover_task_id = body.get("data", {}).get("taskId", suno_task_id)
        webhook_update_task(task_id, "processing", 15, "커버 이미지 생성 중...")
        response = _poll_suno_record(
            "/suno/cover/record-info", cover_task_id, task_id,
            max_attempts=30, interval=5,
            progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."},
        )
        if not response:
            return
        images = response.get("images") or response.get("sunoData") or []
        urls = []
        if isinstance(images, list):
            for img in images:
                if isinstance(img, str):
                    urls.append(img)
                elif isinstance(img, dict):
                    urls.append(img.get("imageUrl") or img.get("image_url", ""))
        webhook_update_task(task_id, "succeeded", 100, "커버 완료",
                            audio_url=json.dumps(urls))
    except Exception as e:
        logger.exception("cover image error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_wav_convert(task_id: str, params: dict) -> None:
    """곡을 WAV 포맷으로 변환 (URL만)."""
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "WAV 변환 요청 중...")
        payload = {
            "taskId": params["suno_task_id"],
            "audioId": params["suno_id"],
            "callBackUrl": "https://example.com/noop",
        }
        resp = requests.post(f"{SUNO_BASE_URL}/wav/generate", headers=_headers(), json=payload, timeout=30)
        if resp.status_code == 409:
            body = resp.json()
            wav_url = body.get("data", {}).get("audioWavUrl", "")
            if wav_url:
                webhook_update_task(task_id, "succeeded", 100, "WAV 캐시", audio_url=wav_url)
                return
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"WAV 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"WAV 거부: {body.get('msg', '?')}"); return
        wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"])
        webhook_update_task(task_id, "processing", 15, "WAV 변환 처리 중...")
        response = _poll_suno_record(
            "/wav/record-info", wav_task_id, task_id,
            max_attempts=30, interval=5,
            progress_msg_map={"PENDING": "WAV 대기 중...", "GENERATING": "WAV 변환 중..."},
        )
        if not response:
            return
        wav_url = ""
        sd = response.get("sunoData") or []
        if sd and isinstance(sd, list) and isinstance(sd[0], dict):
            wav_url = sd[0].get("audioWavUrl", "")
        if not wav_url:
            wav_url = response.get("audioWavUrl", "")
        webhook_update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url)
    except Exception as e:
        logger.exception("wav convert error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_stem_split(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "12스템 분리 요청 중...")
        payload = {
            "taskId": params["suno_task_id"],
            "audioId": params["suno_id"],
            "type": "split_stem",
            "callBackUrl": "https://example.com/noop",
        }
        resp = requests.post(f"{SUNO_BASE_URL}/vocal-removal/generate", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Stem API 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Stem 거부: {body.get('msg', '?')}"); return
        stem_task_id = body.get("data", {}).get("taskId", "")
        if not stem_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
        webhook_update_task(task_id, "processing", 15, "12스템 분리 처리 중 (2~3분)...")
        response = _poll_suno_record(
            "/vocal-removal/record-info", stem_task_id, task_id,
            max_attempts=40, interval=8,
            progress_msg_map={"PENDING": "스템 대기 중...", "GENERATING": "스템 분리 중..."},
        )
        if not response:
            return
        sd = response.get("sunoData") or []
        stems = {}
        names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard",
                 "strings", "brass", "woodwinds", "percussion", "synth", "fx"]
        for i, item in enumerate(sd):
            if isinstance(item, dict):
                nm = names[i] if i < len(names) else f"stem_{i}"
                stems[nm] = item.get("audioUrl") or item.get("audio_url", "")
        webhook_update_task(task_id, "succeeded", 100, "12스템 완료",
                            audio_url=json.dumps(stems))
    except Exception as e:
        logger.exception("stem split error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_upload_cover(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "AI Cover 요청 중...")
        payload = {
            "uploadUrl": params["upload_url"],
            "customMode": params.get("custom_mode", True),
            "instrumental": params.get("instrumental", False),
            "model": params.get("model", "V4"),
            "callBackUrl": "https://example.com/noop",
        }
        for k, ak in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
                      ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
                      ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
            if params.get(k):
                payload[ak] = params[k]
        resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Upload Cover 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', '?')}"); return
        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
        webhook_update_task(task_id, "processing", 15, "AI Cover 생성 중...")
        response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="Cover 완료했으나 트랙 없음"); return
        track = _download_and_register(task_id, completed[0], params)
        if track:
            webhook_add_track(task_id, "succeeded", 100, "AI Cover 완료",
                              audio_url=track["audio_url"], track=track)
    except Exception as e:
        logger.exception("upload cover error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_upload_extend(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "Upload Extend 요청 중...")
        payload = {
            "uploadUrl": params["upload_url"],
            "defaultParamFlag": params.get("default_param_flag", True),
            "model": params.get("model", "V4"),
            "callBackUrl": "https://example.com/noop",
        }
        for k, ak in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
                      ("continue_at", "continueAt"), ("instrumental", "instrumental"),
                      ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
            if params.get(k) is not None:
                payload[ak] = params[k]
        resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Upload Extend 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', '?')}"); return
        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
        webhook_update_task(task_id, "processing", 15, "Upload Extend 생성 중...")
        response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음"); return
        track = _download_and_register(task_id, completed[0], params)
        if track:
            webhook_add_track(task_id, "succeeded", 100, "Upload Extend 완료",
                              audio_url=track["audio_url"], track=track)
    except Exception as e:
        logger.exception("upload extend error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_add_vocals(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "보컬 추가 요청 중...")
        payload = {
            "uploadUrl": params["upload_url"],
            "prompt": params.get("prompt", ""),
            "title": params.get("title", ""),
            "style": params.get("style", ""),
            "negativeTags": params.get("negative_tags", ""),
            "callBackUrl": "https://example.com/noop",
        }
        for k, ak in [("vocal_gender", "vocalGender"), ("model", "model"),
                      ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
            if params.get(k) is not None:
                payload[ak] = params[k]
        resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Add Vocals 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', '?')}"); return
        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
        webhook_update_task(task_id, "processing", 15, "AI 보컬 생성 중...")
        response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음"); return
        track = _download_and_register(task_id, completed[0], params)
        if track:
            webhook_add_track(task_id, "succeeded", 100, "보컬 추가 완료",
                              audio_url=track["audio_url"], track=track)
    except Exception as e:
        logger.exception("add vocals error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_add_instrumental(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")
        payload = {
            "uploadUrl": params["upload_url"],
            "title": params.get("title", ""),
            "tags": params.get("tags", ""),
            "negativeTags": params.get("negative_tags", ""),
            "callBackUrl": "https://example.com/noop",
        }
        for k, ak in [("vocal_gender", "vocalGender"), ("model", "model"),
                      ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
            if params.get(k) is not None:
                payload[ak] = params[k]
        resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Add Inst 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Add Inst 거부: {body.get('msg', '?')}"); return
        suno_task_id = body.get("data", {}).get("taskId", "")
        if not suno_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return
        webhook_update_task(task_id, "processing", 15, "AI 반주 생성 중...")
        response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
        if not response:
            return
        completed = response.get("sunoData") or []
        if not completed:
            webhook_update_task(task_id, "failed", 0, "", error="Add Inst 완료했으나 트랙 없음"); return
        track = _download_and_register(task_id, completed[0], params)
        if track:
            webhook_add_track(task_id, "succeeded", 100, "Add Instrumental 완료",
                              audio_url=track["audio_url"], track=track)
    except Exception as e:
        logger.exception("add instrumental error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))


def run_video_generate(task_id: str, params: dict) -> None:
    try:
        if not SUNO_API_KEY:
            webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return
        webhook_update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")
        payload = {
            "taskId": params["suno_task_id"],
            "audioId": params["suno_id"],
            "callBackUrl": "https://example.com/noop",
        }
        if params.get("author"):
            payload["author"] = params["author"][:50]
        if params.get("domain_name"):
            payload["domainName"] = params["domain_name"][:50]
        resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Video 오류: {resp.text[:300]}"); return
        body = resp.json()
        if body.get("code") != 200:
            webhook_update_task(task_id, "failed", 0, "", error=f"Video 거부: {body.get('msg', '?')}"); return
        video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
        webhook_update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")
        response = _poll_suno_record(
            "/mp4/record-info", video_task_id, task_id,
            max_attempts=60, interval=10,
            progress_msg_map={"PENDING": "비디오 대기 중...", "GENERATING": "비디오 렌더링 중..."},
        )
        if not response:
            return
        video_url = ""
        sd = response.get("sunoData") or []
        if sd and isinstance(sd, list) and isinstance(sd[0], dict):
            video_url = sd[0].get("videoUrl") or sd[0].get("video_url", "")
        if not video_url:
            video_url = response.get("video_url") or response.get("videoUrl", "")
        webhook_update_task(task_id, "succeeded", 100, "뮤직비디오 완료", audio_url=video_url)
    except Exception as e:
        logger.exception("video generate error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))
  • Step 5: 테스트 통과

Run: python -m pytest tests/test_suno_provider.py -v Expected: 4 PASS.

  • Step 6: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/providers/__init__.py services/music-render/providers/suno.py services/music-render/tests/test_suno_provider.py
git commit -m "$(cat <<'EOF'
feat(music-render): providers/suno.py — 13 Suno API 함수 이식 (SP-5)

NAS music-lab/app/suno_provider.py를 Windows worker로 이식.
DB 호출(update_task, add_track 등)을 nas_client.webhook_*으로 변환.
결과 MP3는 MUSIC_MEDIA_ROOT(/mnt/nas/...)에 직접 저장.
13 함수: generation, extend, vocal_removal, cover_image, wav, stem_split,
upload_cover, upload_extend, add_vocals, add_instrumental, video_generate
+ _build_suno_payload + _poll_suno_record + _download_and_register
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: Windows music-render — providers/local.py (MusicGen 이식)

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/local.py

  • Step 1: providers/local.py 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/local.py:

"""Local MusicGen Provider — Windows AI 머신의 native MusicGen 서버(:8765) 호출.

NAS music-lab/app/local_provider.py 이식. DB 호출만 webhook으로 변환.
"""
from __future__ import annotations

import logging
import os
import time

import requests

from nas_client import webhook_update_task, webhook_add_track

logger = logging.getLogger(__name__)

MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music")


def run_local_generation(task_id: str, params: dict) -> None:
    """MusicGen 생성 → /mnt/nas/.../music/{task_id}.mp3 저장 → add_track."""
    try:
        webhook_update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
        if not MUSIC_AI_SERVER_URL:
            webhook_update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL 미설정")
            return

        webhook_update_task(task_id, "processing", 30, "음악 생성 중...")
        resp = requests.post(f"{MUSIC_AI_SERVER_URL}/generate", json=params, timeout=30)
        if resp.status_code != 200:
            webhook_update_task(task_id, "failed", 0, "",
                                error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
            return

        ai_task_id = resp.json().get("task_id")
        if not ai_task_id:
            webhook_update_task(task_id, "failed", 0, "", error="AI 서버 응답에 task_id 없음")
            return

        remote_url = None
        for _ in range(120):
            time.sleep(5)
            sr = requests.get(f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10)
            sd = sr.json()
            st = sd.get("status")
            prog = sd.get("progress", 0)
            msg = sd.get("message", "음악 생성 중...")
            scaled = 30 + int(prog * 0.49)
            webhook_update_task(task_id, "processing", scaled, msg)

            if st == "succeeded":
                remote_url = sd.get("audio_url")
                break
            elif st == "failed":
                webhook_update_task(task_id, "failed", 0, "",
                                    error=sd.get("error", "AI 서버 생성 실패"))
                return

        if not remote_url:
            webhook_update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분)")
            return

        webhook_update_task(task_id, "processing", 80, "파일 저장 중...")
        filename = f"{task_id}.mp3"
        os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)
        file_path = os.path.join(MUSIC_MEDIA_ROOT, filename)

        dl = requests.get(remote_url, timeout=120, stream=True)
        with open(file_path, "wb") as f:
            for chunk in dl.iter_content(chunk_size=8192):
                f.write(chunk)

        audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
        genre = params.get("genre", "")
        moods = params.get("moods", [])
        mood_str = moods[0] if moods else "Original"
        title = params.get("title") or (
            f"{genre}{mood_str} Mix" if genre else f"{mood_str} Mix"
        )

        track = {
            "title": title,
            "genre": genre,
            "moods": moods,
            "instruments": params.get("instruments", []),
            "duration_sec": params.get("duration_sec"),
            "bpm": params.get("bpm"),
            "key": params.get("key", ""),
            "scale": params.get("scale", ""),
            "prompt": params.get("prompt", ""),
            "audio_url": audio_url,
            "file_path": f"/app/data/{filename}",
            "task_id": task_id,
            "provider": "local",
        }
        webhook_add_track(task_id, "succeeded", 100, "생성 완료",
                          audio_url=audio_url, track=track)

    except requests.Timeout:
        webhook_update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃")
    except Exception as e:
        logger.exception("local generation error task=%s", task_id)
        webhook_update_task(task_id, "failed", 0, "", error=str(e))
  • Step 2: 임포트 smoke 테스트

Run: python -c "from providers.local import run_local_generation; print('OK')" Expected: OK.

  • Step 3: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/music-render/providers/local.py
git commit -m "$(cat <<'EOF'
feat(music-render): providers/local.py — MusicGen client (SP-5)

NAS music-lab/app/local_provider.py 이식. DB 호출 webhook 변환.
MusicGen 호스트는 host.docker.internal:8765 (Windows native).
결과 MP3는 /mnt/nas/webpage/data/music/에 직접 저장.
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: Windows music-render — providers/sync_ops.py (sync Suno API 4종)

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/sync_ops.py

NAS music-lab의 generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost 동기 호출을 그대로 이식.

  • Step 1: sync_ops.py 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/sync_ops.py:

"""Sync Suno API helpers — main.py FastAPI sync endpoints에서 호출.

NAS music-lab/app/suno_provider.py의 sync 함수들 이식.
"""
from __future__ import annotations

import logging
import os
import time
from typing import Optional

import requests

logger = logging.getLogger(__name__)

SUNO_BASE_URL = "https://api.sunoapi.org/api/v1"
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")


def _headers() -> dict:
    return {
        "Authorization": f"Bearer {SUNO_API_KEY}",
        "Content-Type": "application/json",
    }


def generate_lyrics(prompt: str) -> Optional[dict]:
    """Suno 가사 생성 API — 폴링 결과 반환."""
    if not SUNO_API_KEY:
        return None
    try:
        resp = requests.post(
            f"{SUNO_BASE_URL}/lyrics",
            headers=_headers(),
            json={"prompt": prompt[:200]},
            timeout=30,
        )
        if resp.status_code != 200:
            return None
        body = resp.json()
        if body.get("code") != 200:
            return body
        task_id = body.get("data", {}).get("taskId", "")
        if not task_id:
            return body
        return _poll_lyrics(task_id)
    except Exception as e:
        logger.warning("Suno lyrics API error: %s", e)
        return None


def _poll_lyrics(lyrics_task_id: str) -> Optional[dict]:
    for _ in range(15):
        time.sleep(3)
        try:
            resp = requests.get(
                f"{SUNO_BASE_URL}/lyrics/record-info",
                headers=_headers(),
                params={"taskId": lyrics_task_id},
                timeout=15,
            )
            if resp.status_code != 200:
                continue
            body = resp.json()
            data = body.get("data", {})
            if data.get("status") == "complete":
                items = data.get("data") or data.get("sunoData") or []
                if items and isinstance(items, list):
                    return {
                        "id": lyrics_task_id,
                        "status": "complete",
                        "text": items[0].get("text", ""),
                        "title": items[0].get("title", ""),
                    }
                return {"id": lyrics_task_id, "status": "complete", "text": ""}
        except Exception:
            continue
    return None


def get_credits() -> Optional[dict]:
    if not SUNO_API_KEY:
        return None
    for path in ["/generate/credit", "/get-credits"]:
        try:
            resp = requests.get(f"{SUNO_BASE_URL}{path}", headers=_headers(), timeout=15)
            if resp.status_code == 200:
                body = resp.json()
                data = body.get("data", body)
                if isinstance(data, (int, float)):
                    return {"credits_left": int(data)}
                return data
        except Exception as e:
            logger.warning("Suno credits API error (%s): %s", path, e)
    return None


def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]:
    if not SUNO_API_KEY:
        return None
    try:
        resp = requests.post(
            f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics",
            headers=_headers(),
            json={"taskId": suno_task_id, "audioId": suno_id},
            timeout=30,
        )
        if resp.status_code == 200:
            body = resp.json()
            return body.get("data", body)
    except Exception as e:
        logger.warning("Timestamped lyrics error: %s", e)
    return None


def generate_style_boost(content: str) -> Optional[dict]:
    if not SUNO_API_KEY:
        return None
    try:
        resp = requests.post(
            f"{SUNO_BASE_URL}/style/generate",
            headers=_headers(),
            json={"content": content},
            timeout=30,
        )
        if resp.status_code == 200:
            body = resp.json()
            return body.get("data", body)
    except Exception as e:
        logger.warning("Style boost error: %s", e)
    return None
  • Step 2: 임포트 smoke 테스트

Run: python -c "from providers.sync_ops import generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost; print('OK')" Expected: OK.

  • Step 3: 커밋
git add services/music-render/providers/sync_ops.py
git commit -m "$(cat <<'EOF'
feat(music-render): providers/sync_ops.py — sync Suno helpers (SP-5)

NAS sync 함수 4종 이식: generate_lyrics, get_credits,
get_timestamped_lyrics, generate_style_boost.
NAS main.py가 httpx로 forward하여 호출.
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: Windows music-render — worker.py (Redis BLPOP + job_type 디스패처)

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/worker.py
  • Test: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_worker.py

큐 단일 (queue:music-render)에 모든 job 종류가 들어옴. payload의 job_type 필드로 디스패치.

  • Step 1: 실패하는 테스트 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_worker.py:

"""worker.py — job_type 디스패처 + paused 체크."""
import json
import pytest
from unittest.mock import MagicMock, patch

import worker


def test_dispatch_suno_generation_calls_run_suno_generation():
    payload = {
        "task_id": "t1",
        "job_type": "suno_generation",
        "params": {"genre": "lofi", "title": "x"},
    }
    with patch("worker.run_suno_generation") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t1", {"genre": "lofi", "title": "x"})


def test_dispatch_local_generation_calls_run_local_generation():
    payload = {
        "task_id": "t2",
        "job_type": "local_generation",
        "params": {"genre": "ambient"},
    }
    with patch("worker.run_local_generation") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t2", {"genre": "ambient"})


def test_dispatch_unknown_job_type_logs_error():
    payload = {"task_id": "t3", "job_type": "weird_type", "params": {}}
    with patch("worker.webhook_update_task") as m:
        worker._dispatch(payload)
        # 알 수 없는 job_type은 failed로 보고
        m.assert_called_once()
        args = m.call_args[0]
        assert args[0] == "t3"
        assert args[1] == "failed"


def test_dispatch_suno_extend_calls_run_suno_extend():
    payload = {"task_id": "t4", "job_type": "suno_extend", "params": {"suno_id": "abc"}}
    with patch("worker.run_suno_extend") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t4", {"suno_id": "abc"})


def test_dispatch_vocal_removal_calls_run_vocal_removal():
    payload = {"task_id": "t5", "job_type": "vocal_removal", "params": {"suno_id": "abc"}}
    with patch("worker.run_vocal_removal") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t5", {"suno_id": "abc"})


def test_dispatch_cover_image_calls_run_cover_image():
    payload = {"task_id": "t6", "job_type": "cover_image", "params": {"suno_task_id": "x"}}
    with patch("worker.run_cover_image") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t6", {"suno_task_id": "x"})


def test_dispatch_wav_convert_calls_run_wav_convert():
    payload = {"task_id": "t7", "job_type": "wav_convert", "params": {"suno_task_id": "x", "suno_id": "y"}}
    with patch("worker.run_wav_convert") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t7", {"suno_task_id": "x", "suno_id": "y"})


def test_dispatch_stem_split_calls_run_stem_split():
    payload = {"task_id": "t8", "job_type": "stem_split", "params": {"suno_task_id": "x", "suno_id": "y"}}
    with patch("worker.run_stem_split") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t8", {"suno_task_id": "x", "suno_id": "y"})


def test_dispatch_video_generate_calls_run_video_generate():
    payload = {"task_id": "t9", "job_type": "video_generate", "params": {"suno_task_id": "x", "suno_id": "y"}}
    with patch("worker.run_video_generate") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t9", {"suno_task_id": "x", "suno_id": "y"})


def test_dispatch_upload_cover_calls_run_upload_cover():
    payload = {"task_id": "t10", "job_type": "upload_cover", "params": {"upload_url": "u"}}
    with patch("worker.run_upload_cover") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t10", {"upload_url": "u"})


def test_dispatch_upload_extend_calls_run_upload_extend():
    payload = {"task_id": "t11", "job_type": "upload_extend", "params": {"upload_url": "u"}}
    with patch("worker.run_upload_extend") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t11", {"upload_url": "u"})


def test_dispatch_add_vocals_calls_run_add_vocals():
    payload = {"task_id": "t12", "job_type": "add_vocals", "params": {"upload_url": "u"}}
    with patch("worker.run_add_vocals") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t12", {"upload_url": "u"})


def test_dispatch_add_instrumental_calls_run_add_instrumental():
    payload = {"task_id": "t13", "job_type": "add_instrumental", "params": {"upload_url": "u"}}
    with patch("worker.run_add_instrumental") as m:
        worker._dispatch(payload)
        m.assert_called_once_with("t13", {"upload_url": "u"})
  • Step 2: 테스트 실패 확인

Run: python -m pytest tests/test_worker.py -v Expected: FAIL — worker 모듈 미존재.

  • Step 3: worker.py 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/worker.py:

"""Redis BLPOP worker — queue:music-render → job_type 디스패치 → NAS webhook.

queue:paused 가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set).
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
from typing import Any

import redis.asyncio as aioredis

from nas_client import webhook_update_task
from providers.suno import (
    run_suno_generation, run_suno_extend, run_vocal_removal,
    run_cover_image, run_wav_convert, run_stem_split,
    run_upload_cover, run_upload_extend, run_add_vocals,
    run_add_instrumental, run_video_generate,
)
from providers.local import run_local_generation

logger = logging.getLogger(__name__)

REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
QUEUE_KEY = "queue:music-render"
PAUSED_KEY = "queue:paused"

_DISPATCH_TABLE = {
    "suno_generation":  run_suno_generation,
    "local_generation": run_local_generation,
    "suno_extend":      run_suno_extend,
    "vocal_removal":    run_vocal_removal,
    "cover_image":      run_cover_image,
    "wav_convert":      run_wav_convert,
    "stem_split":       run_stem_split,
    "upload_cover":     run_upload_cover,
    "upload_extend":    run_upload_extend,
    "add_vocals":       run_add_vocals,
    "add_instrumental": run_add_instrumental,
    "video_generate":   run_video_generate,
}


def _dispatch(payload: dict) -> None:
    """payload[job_type] → provider 함수 호출 (sync, asyncio.to_thread로 래핑)."""
    job_type = payload.get("job_type", "")
    task_id = payload.get("task_id", "")
    params = payload.get("params", {})
    fn = _DISPATCH_TABLE.get(job_type)
    if fn is None:
        logger.error("unknown job_type=%s task=%s", job_type, task_id)
        webhook_update_task(task_id, "failed", 0, "", error=f"unknown job_type: {job_type}")
        return
    fn(task_id, params)


async def worker_loop():
    redis = aioredis.from_url(REDIS_URL, decode_responses=False)
    logger.info("music-render worker started (queue=%s)", QUEUE_KEY)
    while True:
        try:
            paused = await redis.get(PAUSED_KEY)
            if paused == b"1":
                await asyncio.sleep(10)
                continue
            item = await redis.blpop(QUEUE_KEY, timeout=1)
            if item is None:
                continue
            _, raw = item
            try:
                payload = json.loads(raw)
            except json.JSONDecodeError:
                logger.error("invalid queue payload: %r", raw[:200])
                continue
            # sync provider 함수 — thread로 실행해서 이벤트 루프 블로킹 방지
            await asyncio.to_thread(_dispatch, payload)
        except asyncio.CancelledError:
            logger.info("worker_loop cancelled")
            raise
        except Exception:
            logger.exception("worker_loop iteration 실패, 5초 후 재시도")
            await asyncio.sleep(5)
  • Step 4: 테스트 통과

Run: python -m pytest tests/test_worker.py -v Expected: 13 PASS.

  • Step 5: 커밋
git add services/music-render/worker.py services/music-render/tests/test_worker.py
git commit -m "$(cat <<'EOF'
feat(music-render): worker.py — Redis BLPOP + 12 job_type dispatch (SP-5)

queue:music-render BLPOP, queue:paused 체크 후 job_type별 provider 호출.
sync provider는 asyncio.to_thread로 래핑 (이벤트 루프 블로킹 방지).
12 job_types (suno_*, local_*, vocal_removal, cover_image, wav_convert,
stem_split, upload_cover, upload_extend, add_vocals, add_instrumental,
video_generate).
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 9: Windows music-render — main.py (FastAPI + lifespan + sync endpoints)

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/main.py

  • Step 1: main.py 작성

C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/main.py:

"""music-render FastAPI entry — health + lifespan + sync forward endpoints.

NAS music-lab이 sync helpers(lyrics, credits, timestamped, style-boost)를
httpx로 forward해서 이 endpoint들을 호출.
"""
from __future__ import annotations

import asyncio
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import worker
from providers.sync_ops import (
    generate_lyrics, get_credits,
    get_timestamped_lyrics, generate_style_boost,
)

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    worker_task = asyncio.create_task(worker.worker_loop())
    logger.info("music-render lifespan 시작")
    try:
        yield
    finally:
        worker_task.cancel()
        try:
            await worker_task
        except asyncio.CancelledError:
            pass
        logger.info("music-render lifespan 종료")


app = FastAPI(lifespan=lifespan)


@app.get("/health")
def health():
    return {"ok": True, "service": "music-render"}


# ── Sync forward endpoints ──────────────────────────────────────────────
# NAS music-lab의 /api/music/lyrics 등 sync helpers가 이 endpoint들로 forward.

class LyricsRequest(BaseModel):
    prompt: str


@app.post("/api/music-render/sync/lyrics")
def sync_lyrics(req: LyricsRequest):
    result = generate_lyrics(req.prompt)
    if not result:
        raise HTTPException(502, "가사 생성 실패")
    return result


@app.get("/api/music-render/sync/credits")
def sync_credits():
    result = get_credits()
    if result is None:
        raise HTTPException(502, "크레딧 조회 실패")
    return result


@app.get("/api/music-render/sync/timestamped-lyrics")
def sync_timestamped_lyrics(task_id: str, suno_id: str):
    result = get_timestamped_lyrics(task_id, suno_id)
    if not result:
        raise HTTPException(502, "타임스탬프 가사 조회 실패")
    return result


class StyleBoostRequest(BaseModel):
    content: str


@app.post("/api/music-render/sync/style-boost")
def sync_style_boost(req: StyleBoostRequest):
    result = generate_style_boost(req.content)
    if not result:
        raise HTTPException(502, "스타일 부스트 생성 실패")
    return result
  • Step 2: 임포트 smoke 테스트

Run: python -c "from main import app; print(len(app.routes))" Expected: 숫자 출력 (>= 5 — health + sync 4종).

  • Step 3: 커밋
git add services/music-render/main.py
git commit -m "$(cat <<'EOF'
feat(music-render): main.py — FastAPI + lifespan + sync endpoints (SP-5)

lifespan에서 worker_loop 스폰. sync forward 4 endpoint:
/api/music-render/sync/{lyrics, credits, timestamped-lyrics, style-boost}.
NAS music-lab이 이 endpoint들을 httpx forward로 호출.
Plan-B-Music Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 10: Windows services/docker-compose — music-render 추가 + 박재오 빌드

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml

  • Step 1: docker-compose.yml에 music-render 서비스 추가

C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml 끝(insta-render 아래)에 추가:


  music-render:
    build:
      context: ./music-render
    container_name: music-render
    restart: unless-stopped
    ports:
      - "18711:8000"
    environment:
      - TZ=Asia/Seoul
      - REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
      - NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18600}
      - INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
      - SUNO_API_KEY=${SUNO_API_KEY:-}
      - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-http://host.docker.internal:8765}
      - MUSIC_MEDIA_ROOT=${MUSIC_MEDIA_ROOT:-/mnt/nas/webpage/data/music}
      - MUSIC_MEDIA_URL_PREFIX=${MUSIC_MEDIA_URL_PREFIX:-/media/music}
    extra_hosts:
      - "host.docker.internal:host-gateway"
    volumes:
      - /mnt/nas/webpage/data/music:/mnt/nas/webpage/data/music
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 60s
      timeout: 5s
      retries: 3
  • Step 2: 박재오에게 .env 작성 요청 (구두 가이드)

박재오 Windows AI 머신 (WSL2):

cd /workspace/web-ai/services/music-render
cp .env.example .env
# .env 편집:
#   INTERNAL_API_KEY = NAS .env의 동일값 복사
#   SUNO_API_KEY = NAS .env에서 옮겨오는 값
nano .env

플랜 실행자는 박재오에게 .env 작성 완료 확인 후 다음 step 진행.

  • Step 3: 박재오 머신에서 빌드 + 시작

박재오 WSL2:

cd /workspace/web-ai/services
docker compose build music-render
docker compose up -d music-render
docker compose logs -f music-render

Expected logs:

music-render | INFO ... music-render lifespan 시작
music-render | INFO ... music-render worker started (queue=queue:music-render)
  • Step 4: 헬스체크 확인

박재오 WSL2:

curl http://localhost:18711/health

Expected: {"ok": true, "service": "music-render"}

NAS SMB write 확인 (worker가 컨테이너에서 mount 접근 가능한지):

docker exec music-render ls -la /mnt/nas/webpage/data/music | head

Expected: 에러 없이 디렉토리 리스트 출력.

  • Step 5: 커밋 + push
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(music-render): services/docker-compose에 music-render 서비스 (SP-5)

포트 18711, REDIS_URL/NAS_BASE_URL/INTERNAL_API_KEY/SUNO_API_KEY/MUSIC_AI_SERVER_URL env.
host.docker.internal 매핑(MusicGen native 호스트).
SMB /mnt/nas/webpage/data/music 마운트.
Plan-B-Music Phase 2 — 빌드 + 박재오 머신 시작 확인.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push

Task 11: NAS music-lab — main.py background_tasks → Redis push (13 endpoint)

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py — 13개 endpoint의 background_tasks.add_task(run_*, ...) 호출을 Redis push로 변경

각 endpoint별로 동일 패턴 적용: task_id 생성 + create_task + redis_client.rpush("queue:music-render", json.dumps(payload)) + 응답.

helper 함수 1개를 추가하여 DRY 유지.

  • Step 1: main.py import 블록 끝(internal_router import 다음 줄)에 helper 추가

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py에서 app = FastAPI() 위에 추가:

async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
    """Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리."""
    from datetime import datetime, timezone, timedelta
    kst = timezone(timedelta(hours=9))
    payload = {
        "task_id": task_id,
        "kind": "music",
        "job_type": job_type,
        "params": params,
        "submitted_at": datetime.now(kst).isoformat(),
    }
    await redis_client.rpush("queue:music-render", json.dumps(payload))

(json import는 이미 line 1에 있음.)

  • Step 2: /api/music/generate 변경

generate_music 함수(현재 line 132-154) 본문 변경:

@app.post("/api/music/generate")
async def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
    """음악 생성 작업 — Redis 큐로 Windows music-render에 위임."""
    provider = req.provider
    # SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X).
    # 실패 시 worker가 webhook으로 failed 보고.
    if provider not in ("suno", "local"):
        raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
    if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"):
        # 이 env는 NAS에는 더 이상 없지만 사용자 친화 검증으로 유지 — 실제 호출은 Windows
        pass

    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider=provider)
    job_type = "suno_generation" if provider == "suno" else "local_generation"
    await _push_render_job(task_id, job_type, params)
    return {"task_id": task_id, "provider": provider}

(주의: async def로 변경 + await _push_render_job(...).)

  • Step 3: /api/music/extend 변경

extend_music(현재 line 397-406) 본문 변경:

@app.post("/api/music/extend")
async def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "suno_extend", params)
    return {"task_id": task_id, "provider": "suno"}
  • Step 4: /api/music/vocal-removal 변경

vocal_removal 본문 (line 417-426):

@app.post("/api/music/vocal-removal")
async def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "vocal_removal", params)
    return {"task_id": task_id, "provider": "suno"}
  • Step 5: /api/music/cover-image, /api/music/wav, /api/music/stem-split 변경

각각:

@app.post("/api/music/cover-image")
async def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "cover_image", params)
    return {"task_id": task_id, "provider": "suno"}


@app.post("/api/music/wav")
async def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "wav_convert", params)
    return {"task_id": task_id, "provider": "suno"}


@app.post("/api/music/stem-split")
async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "stem_split", params)
    return {"task_id": task_id, "provider": "suno"}
  • Step 6: /api/music/upload-cover, /api/music/upload-extend, /api/music/add-vocals, /api/music/add-instrumental, /api/music/video 변경
@app.post("/api/music/upload-cover")
async def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "upload_cover", params)
    return {"task_id": task_id, "provider": "suno"}


@app.post("/api/music/upload-extend")
async def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "upload_extend", params)
    return {"task_id": task_id, "provider": "suno"}


@app.post("/api/music/add-vocals")
async def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "add_vocals", params)
    return {"task_id": task_id, "provider": "suno"}


@app.post("/api/music/add-instrumental")
async def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "add_instrumental", params)
    return {"task_id": task_id, "provider": "suno"}


@app.post("/api/music/video")
async def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    params = req.model_dump()
    create_task(task_id, params, provider="suno")
    await _push_render_job(task_id, "video_generate", params)
    return {"task_id": task_id, "provider": "suno"}
  • Step 7: smoke test — import + endpoint 라우트 확인

Run: cd music-lab && python -c "from app.main import app; print([r.path for r in app.routes if hasattr(r, 'path')])" Expected: /api/music/generate, /api/music/extend, ... 13 endpoint 모두 포함.

  • Step 8: 커밋
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add music-lab/app/main.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): 13 background_tasks → Redis push (SP-6)

generate, extend, vocal-removal, cover-image, wav, stem-split,
upload-cover, upload-extend, add-vocals, add-instrumental, video
모두 _push_render_job 헬퍼로 queue:music-render에 push.
job_type 디스크리미네이터로 Windows worker가 분기.
Plan-B-Music Phase 3 (cutover 1/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 12: NAS music-lab — sync helpers를 Windows HTTP forward로

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/sync_forward.py

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py — sync endpoint 4종(lyrics, credits, timestamped, style-boost)이 sync_forward 호출

  • Step 1: sync_forward.py 작성

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/sync_forward.py:

"""SP-6 sync helpers forward — NAS → Windows music-render.

NAS music-lab의 /api/music/lyrics, /api/music/credits, 
/api/music/timestamped-lyrics, /api/music/style-boost 호출을
Windows music-render의 /api/music-render/sync/* 로 forward.

SUNO_API_KEY는 NAS에 없으므로 NAS에서 직접 호출 불가.
"""
from __future__ import annotations

import logging
import os
from typing import Optional

import httpx

logger = logging.getLogger(__name__)

MUSIC_RENDER_URL = os.getenv("MUSIC_RENDER_URL", "http://192.168.45.59:18711")
_TIMEOUT = 60.0  # 가사 생성은 폴링 포함 ~45초


def forward_lyrics(prompt: str) -> Optional[dict]:
    try:
        r = httpx.post(
            f"{MUSIC_RENDER_URL}/api/music-render/sync/lyrics",
            json={"prompt": prompt},
            timeout=_TIMEOUT,
        )
        if r.status_code == 200:
            return r.json()
        logger.warning("forward_lyrics returned %d", r.status_code)
    except Exception:
        logger.exception("forward_lyrics 실패")
    return None


def forward_credits() -> Optional[dict]:
    try:
        r = httpx.get(
            f"{MUSIC_RENDER_URL}/api/music-render/sync/credits",
            timeout=30.0,
        )
        if r.status_code == 200:
            return r.json()
    except Exception:
        logger.exception("forward_credits 실패")
    return None


def forward_timestamped_lyrics(task_id: str, suno_id: str) -> Optional[dict]:
    try:
        r = httpx.get(
            f"{MUSIC_RENDER_URL}/api/music-render/sync/timestamped-lyrics",
            params={"task_id": task_id, "suno_id": suno_id},
            timeout=30.0,
        )
        if r.status_code == 200:
            return r.json()
    except Exception:
        logger.exception("forward_timestamped_lyrics 실패")
    return None


def forward_style_boost(content: str) -> Optional[dict]:
    try:
        r = httpx.post(
            f"{MUSIC_RENDER_URL}/api/music-render/sync/style-boost",
            json={"content": content},
            timeout=30.0,
        )
        if r.status_code == 200:
            return r.json()
    except Exception:
        logger.exception("forward_style_boost 실패")
    return None
  • Step 2: main.py — sync endpoint 4종 변경

main.pygen_lyrics (현재 line 190-198), check_credits (line 374-381), timestamped_lyrics (line 491-498), style_boost (line 508-515) 변경:

@app.post("/api/music/lyrics")
def gen_lyrics(req: LyricsRequest):
    """Suno AI 가사 생성 — Windows music-render로 forward."""
    from .sync_forward import forward_lyrics
    result = forward_lyrics(req.prompt)
    if not result:
        raise HTTPException(status_code=502, detail="가사 생성 실패 (Windows worker 응답 없음)")
    return result


@app.get("/api/music/credits")
def check_credits():
    """Suno 잔여 크레딧 조회 — Windows music-render로 forward."""
    from .sync_forward import forward_credits
    result = forward_credits()
    if result is None:
        raise HTTPException(status_code=502, detail="크레딧 조회 실패")
    return result


@app.get("/api/music/timestamped-lyrics")
def timestamped_lyrics(task_id: str, suno_id: str):
    """타임스탬프 가사 — Windows music-render로 forward."""
    from .sync_forward import forward_timestamped_lyrics
    result = forward_timestamped_lyrics(task_id, suno_id)
    if not result:
        raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
    return result


@app.post("/api/music/style-boost")
def style_boost(req: StyleBoostRequest):
    """스타일 부스트 — Windows music-render로 forward."""
    from .sync_forward import forward_style_boost
    result = forward_style_boost(req.content)
    if not result:
        raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
    return result

SUNO_API_KEY 검증 라인은 제거 (모두 Windows로 위임). from .suno_provider import (...) import 라인에서 generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost, SUNO_API_KEY는 더 이상 필요 없음. 다만 SUNO_MODELS/api/music/models endpoint에서 사용하므로 유지. import 정리:

기존 (line 31-37):

from .suno_provider import (
    run_suno_generation, run_suno_extend, run_vocal_removal,
    run_cover_image, run_wav_convert, run_stem_split,
    run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
    generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
    SUNO_API_KEY, SUNO_MODELS,
)

→ 변경:

from .suno_provider import SUNO_MODELS

(run_* 함수들은 Task 11에서 _push_render_job로 대체했으므로 import 불필요.)

또한 /api/music/providers endpoint(line 86-104)의 if SUNO_API_KEY: 체크는 항상 True 가정 (Windows로 이전됨). 변경:

@app.get("/api/music/providers")
def get_providers():
    providers = []
    if os.getenv("MUSIC_AI_SERVER_URL"):
        providers.append({
            "id": "local", "name": "MusicGen",
            "description": "로컬 AI 서버 (인스트루멘탈 전용)",
            "features": ["instrumental"],
        })
    # SUNO는 Windows music-render에서 처리 — 항상 가용 (Suno 키 누락 시 worker가 failed 보고)
    providers.append({
        "id": "suno", "name": "Suno",
        "description": "Suno AI (보컬·가사·인스트루멘탈)",
        "features": ["vocals", "lyrics", "instrumental"],
    })
    return {"providers": providers}

local_provider도 main.py에서 import 안 함 (run_local_generation도 미사용). 기존 line 28 from .local_provider import run_local_generation를 제거.

  • Step 3: import smoke test

Run: cd music-lab && python -c "from app.main import app; print('OK')" Expected: OK.

  • Step 4: 커밋
git add music-lab/app/sync_forward.py music-lab/app/main.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): sync helpers → Windows HTTP forward (SP-6)

/api/music/{lyrics, credits, timestamped-lyrics, style-boost}
모두 sync_forward 모듈로 위임 → Windows :18711/api/music-render/sync/*.
SUNO_API_KEY가 NAS에 없으므로 직접 호출 불가.
run_*, generate_*, get_* import 제거 (Windows로 이전됨).
SUNO_MODELS만 잔존 (정적 데이터).
Plan-B-Music Phase 3 (cutover 2/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 13: NAS music-lab — batch_generator를 Redis push 호환으로

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/batch_generator.py:103-148 (_generate_one_track 함수)

기존 _generate_one_trackrun_suno_generation을 직접 호출하지만, 그 함수는 이제 NAS에서 사라짐. Redis push 후 task 상태 polling 패턴으로 변경.

  • Step 1: _generate_one_track 재작성

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/batch_generator.py_generate_one_track 함수 전체 교체:

async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
                              params: dict) -> int | None:
    """Redis 큐에 push + task 상태 polling. 성공 시 새 track id, 실패 시 None."""
    import json
    from datetime import datetime, timezone, timedelta
    from .main import redis_client  # 같은 컨테이너 — 동일 redis 클라이언트 공유

    task_id = str(uuid.uuid4())
    suno_params = {
        "title": title,
        "genre": genre,
        "moods": params["moods"],
        "instruments": params["instruments"],
        "duration_sec": duration_sec,
        "bpm": params["bpm"],
        "key": params["key"],
        "scale": params["scale"],
        "prompt": params.get("prompt_modifier", ""),
        "provider": "suno",
        "model": "V4",
        "instrumental": False,
        "lyrics": "",
    }
    db.create_task(task_id, suno_params, provider="suno")

    # Redis push (Windows music-render가 BLPOP 처리)
    kst = timezone(timedelta(hours=9))
    payload = {
        "task_id": task_id,
        "kind": "music",
        "job_type": "suno_generation",
        "params": suno_params,
        "submitted_at": datetime.now(kst).isoformat(),
    }
    await redis_client.rpush("queue:music-render", json.dumps(payload))

    waited = 0
    while waited < TRACK_GEN_TIMEOUT_S:
        await asyncio.sleep(POLL_INTERVAL_S)
        waited += POLL_INTERVAL_S
        task = db.get_task(task_id)
        if not task:
            continue
        status = task.get("status")
        if status == "succeeded":
            # Windows webhook이 add_track 했으므로 task_id로 검색
            track = db.get_track_by_task_id(task_id)
            if track:
                return track.get("id")
            return None
        if status == "failed":
            return None
    return None  # timeout

(주의: from .suno_provider import run_suno_generation import 제거, asyncio.create_task(asyncio.to_thread(...)) 제거.)

  • Step 2: import smoke test

Run: cd music-lab && python -c "from app.batch_generator import run_batch; print('OK')" Expected: OK.

  • Step 3: 커밋
git add music-lab/app/batch_generator.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): batch_generator _generate_one_track → Redis push (SP-6)

기존 직접 run_suno_generation 호출 + asyncio.to_thread를
Redis push (queue:music-render, job_type=suno_generation) +
task 상태 polling 패턴으로 변경. 결과는 task_id로 music_library 조회.
Plan-B-Music Phase 3 (cutover 3/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 14: NAS music-lab — suno_provider/local_provider stub + Dockerfile 슬림화

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/suno_provider.py (전체 교체)

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/local_provider.py (전체 교체)

  • Step 1: suno_provider.py 전체 교체 (stub만 남김)

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/suno_provider.py 전체를 다음으로 교체:

"""DEPRECATED 2026-05-19 — Suno API 호출 코드는 모두 Windows music-render로 이전.

기존 13 함수 (run_suno_generation, run_suno_extend, ...)는
web-ai/services/music-render/providers/suno.py로 이식됨.
NAS는 Redis push (queue:music-render)만 담당.

SUNO_MODELS는 frontend 응답용 정적 데이터만 잔존.
"""
from __future__ import annotations

SUNO_API_KEY = ""  # NAS에서 더 이상 보유 X — sentinel 유지 (다른 import 호환)

SUNO_MODELS = [
    {"id": "V4", "name": "V4", "max_duration": "4분", "description": "안정적 품질, 빠른 생성"},
    {"id": "V4_5", "name": "V4.5", "max_duration": "8분", "description": "향상된 장르 블렌딩"},
    {"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"},
    {"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"},
    {"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성"},
    {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델"},
]
  • Step 2: local_provider.py 전체 교체 (stub만 남김)

C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/local_provider.py 전체를 다음으로 교체:

"""DEPRECATED 2026-05-19 — MusicGen 호출은 Windows music-render로 이전.

기존 run_local_generation은 web-ai/services/music-render/providers/local.py로 이식.
NAS는 Redis push (queue:music-render, job_type=local_generation)만 담당.
"""
  • Step 3: 다른 모듈의 dangling import 확인

Run: cd music-lab && grep -rn "from .suno_provider import\|from .local_provider import\|from app.suno_provider\|from app.local_provider" app/ tests/ Expected: app/main.py에서 SUNO_MODELS만 import, app/batch_generator.py에서 어떤 것도 import 안 함.

만약 추가 dangling이 보이면 그 파일도 정리. (compiler.py나 video_producer.py 등 — 일반적으로 NAS local 처리이므로 영향 없음.)

  • Step 4: import smoke test + pytest

Run:

cd music-lab
python -c "from app.main import app; print('OK')"
python -m pytest tests/test_auth.py tests/test_internal_router.py -v

Expected: OK + 8 PASS.

  • Step 5: 커밋
git add music-lab/app/suno_provider.py music-lab/app/local_provider.py
git commit -m "$(cat <<'EOF'
refactor(music-lab): suno_provider/local_provider → stub (SP-6)

기존 13+1 외부 API 호출 함수는 web-ai/services/music-render/providers로 이식.
NAS는 SUNO_MODELS (정적 데이터)만 잔존. SUNO_API_KEY = "" sentinel.
Plan-B-Music Phase 3 (cutover 4/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 15: NAS push + deployer rebuild + 헬스 확인

Files:

  • (변경 없음 — push + 원격 배포만)

  • Step 1: 모든 NAS 변경 push

cd C:/Users/jaeoh/Desktop/workspace/web-backend
git push

Expected: push 성공. 만약 자격증명 실패 시 박재오에게 수동 push 요청.

  • Step 2: 박재오에게 NAS 컨테이너 rebuild 확인 요청

NAS의 Gitea webhook이 자동으로 deployer를 트리거. 박재오 측에서 NAS deployer 로그 확인:

ssh nas
docker logs webpage-deployer --tail 50

Expected: music-lab rebuild + restart 라인 확인.

  • Step 3: 헬스 확인

박재오 NAS:

curl https://gahusb.synology.me/api/music/providers

Expected: {"providers": [{"id": "local", ...}, {"id": "suno", ...}]}

docker logs music-lab --tail 20

Expected: 시작 로그, Application startup complete 표시.

docker exec music-lab python -c "from app.main import redis_client; import asyncio; print(asyncio.run(redis_client.ping()))"

Expected: True (Redis 연결 확인).

  • Step 4: SUNO_API_KEY 잔존 검사

박재오 NAS:

docker exec music-lab env | grep SUNO

Expected: SUNO_API_KEY 없음 (또는 빈 문자열).

  • Step 5: 커밋할 추가 변경 없으면 다음 task로 진행

Task 16: nginx 3-layer 차단 /api/internal/music/

Files:

  • Modify: C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf/api/internal/insta/ 블록 옆에 /api/internal/music/ 추가

  • Step 1: default.conf에 차단 블록 추가

C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf/api/internal/insta/ 블록(line 196-211) 바로 다음에 추가:

  # Plan-B-Music — Windows music-render → NAS music-lab internal webhook
  # Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
  # Layer 3: X-Internal-Key (FastAPI dependency)
  location /api/internal/music/ {
    allow 192.168.45.0/24;     # LAN 화이트리스트
    allow 100.64.0.0/10;       # Tailscale CGNAT
    allow 127.0.0.1;           # NAS 내부
    deny all;

    resolver 127.0.0.11 valid=10s;
    set $music_internal_backend music-lab:8000;

    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Internal-Key $http_x_internal_key;
    proxy_pass http://$music_internal_backend$request_uri;
  }
  • Step 2: nginx 설정 검증

Run: grep -c "location /api/internal/" C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf Expected: 2 (insta + music).

  • Step 3: NAS nginx reload (push 후 webhook 자동)
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add nginx/default.conf
git commit -m "$(cat <<'EOF'
feat(nginx): /api/internal/music/ 3-layer 차단 (SP-6)

LAN(192.168.45.0/24) + Tailscale(100.64.0.0/10) + 127.0.0.1 allow.
deny all. X-Internal-Key forward → music-lab:8000.
insta 블록과 동일 패턴.
Plan-B-Music Phase 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push
  • Step 4: NAS frontend(nginx) 재시작 확인

박재오 NAS:

docker compose -f /volume1/docker/webpage/docker-compose.yml restart frontend
docker logs frontend --tail 20 | grep -i music

Expected: 시작 로그에 nginx 재로드 + /api/internal/music/ 차단 활성화.

  • Step 5: 차단 테스트 (외부에서 호출)

박재오 외부 PC(WAN)에서:

curl -X POST https://gahusb.synology.me/api/internal/music/update \
  -H "X-Internal-Key: anything" \
  -H "Content-Type: application/json" \
  -d '{"task_id":"x","status":"processing","progress":0}'

Expected: HTTP 403 (nginx deny).

박재오 LAN PC에서 (또는 NAS 내부):

curl -X POST http://192.168.45.54:8080/api/internal/music/update \
  -H "X-Internal-Key: WRONG" \
  -H "Content-Type: application/json" \
  -d '{"task_id":"x","status":"processing","progress":0}'

Expected: HTTP 401 (FastAPI verify_internal_key 거부 — nginx 통과 확인).


Task 17: end-to-end Suno 1 트랙 생성 검증 + 최종 정리

Files:

  • (변경 없음 — 검증만)

  • Step 1: 박재오 머신에서 SUNO 1 트랙 생성 트리거

박재오 LAN:

curl -X POST https://gahusb.synology.me/api/music/generate \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "suno",
    "model": "V4",
    "title": "Plan-B-Music 검증",
    "genre": "lofi",
    "moods": ["chill"],
    "instruments": [],
    "duration_sec": 60,
    "prompt": "lofi chill background",
    "lyrics": "",
    "instrumental": true
  }'

응답 예: {"task_id": "xxxx-yyyy", "provider": "suno"}

  • Step 2: Windows worker 로그에서 처리 확인

박재오 WSL2:

docker logs music-render --tail 50 -f

Expected:

music-render | INFO ... Suno API 연결... task=xxxx
music-render | INFO ... 곡 생성 대기열에 등록...
music-render | INFO ... 폴링... attempt=1 status=PENDING
...
music-render | INFO ... 오디오 다운로드 중...
music-render | INFO ... webhook task=xxxx status=succeeded
  • Step 3: NAS DB 상태 확인 (폴링)

박재오:

curl https://gahusb.synology.me/api/music/status/xxxx-yyyy

Expected: {"status": "succeeded", "progress": 100, "audio_url": "/media/music/xxxx-yyyy.mp3", "track": {...}}

  • Step 4: 파일 존재 확인

박재오 NAS:

ls -la /volume1/docker/webpage/data/music/xxxx-yyyy.mp3

Expected: 파일 존재, 크기 > 0.

브라우저: https://gahusb.synology.me/media/music/xxxx-yyyy.mp3 직접 재생 시도.

  • Step 5: Sync forward 검증 (Suno 크레딧 조회)

박재오:

curl https://gahusb.synology.me/api/music/credits

Expected: {"credits_left": <숫자>} 또는 Suno API 응답 데이터.

  • Step 6: 모든 NAS·web-ai 변경 push 최종 확인
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -10
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -10

Expected: 두 저장소 모두 clean + 최신 commits push 완료.

  • Step 7: 메모리에 기록 (Plan-B-Music 검증 완료)

C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/project_signal_v2.md는 무관. 새 reference 메모리 추가:

reference_plan_b_music_complete.md:

---
name: plan-b-music-complete
description: 2026-05-19 Plan-B-Music 완료 — NAS music-lab → Windows music-render Redis 큐 + sync forward 구조
metadata:
  type: reference
---

Plan-B-Music 2026-05-19 완료. 검증 결과: Suno 1 트랙 end-to-end 성공.

**구조:**
- async 13 endpoint: NAS music-lab → Redis RPUSH `queue:music-render` (job_type discriminator) → Windows music-render worker → SMB direct write → webhook `POST /api/internal/music/update`
- sync 4 endpoint: NAS music-lab → httpx → Windows `POST/GET /api/music-render/sync/{op}` 직접 forward
- SUNO_API_KEY: NAS `.env`에서 완전 제거 → Windows `.env` 단독 보유

**파일 위치:**
- web-ai/services/music-render/providers/{suno,local,sync_ops}.py
- web-ai/services/music-render/nas_client.py (webhook adapter)
- web-backend/music-lab/app/{auth,internal_router,sync_forward}.py

**job_type 13종:** suno_generation, local_generation, suno_extend, vocal_removal, cover_image, wav_convert, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate

**다음 plan:** Plan-B-Video (SP-7+SP-8) — 같은 패턴 복제.

MEMORY.md에 한 줄 추가:

- [Plan-B-Music 완료](reference_plan_b_music_complete.md) — NAS music-lab → Windows music-render Redis 큐 + sync forward, SUNO_API_KEY 완전 이전
  • Step 8: Task list 정리

박재오의 TaskCreate/Update에서 #69~#85 Plan-B-Music task들을 모두 completed로 마크.

박재오에게 보고:

Plan-B-Music 완료:
- 17 task 모두 completed
- Suno end-to-end 검증 성공 (task_id=xxxx, 곡 N초)
- SUNO_API_KEY NAS에서 제거 확인 (docker exec music-lab env | grep SUNO 결과 empty)
- nginx 3-layer 차단 활성화 (외부 403, LAN 401)
- 다음: Plan-B-Video (SP-7+SP-8) 또는 FOLLOW-UP B (-lab suffix 제거)

Self-Review

1. Spec coverage

Spec 요구사항 구현 위치 상태
SP-5 §10: Suno API client (외부 SaaS, 폴링 1~5분) Task 5 (providers/suno.py) ✓ 11 async + 4 sync 함수 이식
SP-5 §10: MusicGen local call (Windows localhost:8765) Task 6 (providers/local.py) ✓ run_local_generation 이식, host.docker.internal 매핑
SP-5 §10: Redis BLPOP queue:music-render Task 8 (worker.py) ✓ job_type 디스패처 13종
SP-5 §10: 결과 mp3 → /mnt/nas/data/music/ Task 5 (_download_and_register) ✓ MUSIC_MEDIA_ROOT 직접 저장
SP-5 §10: NAS webhook POST /api/internal/music/update Task 4 (nas_client.py) ✓ webhook_update_task + webhook_add_track
SP-5 §10: SUNO_API_KEY Windows .env 단독 보관 Task 3, Task 14 ✓ Windows .env.example, NAS suno_provider stub
SP-6 §10: Suno + MusicGen 호출 코드 삭제 Task 14 ✓ suno_provider/local_provider stub
SP-6 §10: _bg_generate → Redis push Task 11 ✓ 13 endpoint + helper
SP-6 §10: POST /api/internal/music/update endpoint Task 1 ✓ internal_router.py
§5 Windows Render Worker 패턴 (시퀀스) Task 8 worker_loop ✓ paused 체크 + BLPOP + webhook
§6 Redis 키 컨벤션 (queue:music-render + queue:paused) Task 8 ✓ QUEUE_KEY + PAUSED_KEY
§6 큐 payload 표준 (task_id/kind/params/submitted_at) Task 11 _push_render_job ✓ + job_type 추가
§7 NAS 볼륨 (/mnt/nas/webpage/data/music) Task 3 .env, Task 10 compose
§8 Internal webhook 3-layer 차단 Task 16 ✓ nginx IP allow + verify_internal_key
§9 SUNO_API_KEY NAS .env 제거 Task 2 docker-compose ✓ SUNO_API_KEY 라인 제거
박재오 결정 "모든 Suno + MusicGen 일괄 이전" Task 5+6+7 (13 async + 4 sync)

전체 spec coverage: 100%. SP-5/SP-6 모든 항목 + §9 키 이전 + §5/§6/§7/§8 패턴 동일.

2. Placeholder scan: 통과 — TBD/TODO/placeholder 없음. 모든 코드 블록 완전.

3. Type consistency:

  • webhook_update_task(task_id: str, status: str, progress: int, message: str, audio_url: Optional[str], error: Optional[str]) — Task 4 정의, Task 5/6 일관 호출
  • webhook_add_track(task_id, status, progress, message, audio_url, track) — Task 4 정의, Task 5/6 일관 호출
  • _push_render_job(task_id, job_type, params) — Task 11 정의, Task 13 호출 일관 (kind=music, submitted_at 동일 포맷)
  • _dispatch(payload) — Task 8 정의, payload[job_type] + payload[task_id] + payload[params] 사용 (Task 11 push 형식과 일치)
  • job_type 디스크리미네이터 13개 문자열 (suno_generation, local_generation, suno_extend, vocal_removal, cover_image, wav_convert, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate) — Task 8 _DISPATCH_TABLE, Task 11 endpoint 호출, Task 13 batch_generator 일관

4. Plan-B-Insta와의 일관성:

  • auth.py: 동일 (insta-lab/app/auth.py 복제) ✓
  • internal_router.py: 같은 패턴 + add_track 페이로드 추가 ✓
  • nginx 차단 블록: insta 블록과 동일 패턴 ✓
  • worker_loop: 동일 (queue:paused 체크 + BLPOP + webhook) ✓
  • .env.example: 동일 구조 ✓
  • docker-compose entry: 같은 패턴 ✓

5. 누락 검토:

  • compiler.py, video_producer.py, pipeline/orchestrator.py는 FFmpeg/Anthropic/OpenAI/YouTube 호출 — NAS 유지 (spec §10 Suno+MusicGen만 이전). 변경 없음. ✓
  • youtube oauth (yt_module): NAS 유지. 변경 없음. ✓
  • /api/music/{compile,pipeline,video-project,setup,youtube,market,revenue,library}: 모두 NAS local 처리 (DB·FFmpeg·Anthropic) — 변경 없음. ✓
  • update_track_cover_images, update_track_wav_url, update_track_video_url, update_track_stem_urls: NAS local DB 함수. Windows에서는 webhook으로 처리 — cover_image/wav/video 등은 URL을 audio_url 필드에 JSON encode로 위임 (간소화 트레이드오프). 명세 §10에는 track column 업데이트 강제 없음. ✓

플랜이 완성되어 모든 검토를 통과했습니다.


부록: 트러블슈팅 (Plan-B-Insta 경험 반영)

Pre-existing 함정 (Plan-B-Insta에서 발견):

  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 직접 사용.