From 714224a9b49ee594e2c6bce7f8b80849e10729ae Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 19 May 2026 03:02:48 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Plan-B-Music=20=E2=80=94=20music-?= =?UTF-8?q?render=20Windows=20worker=20+=20NAS=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-05-19-plan-b-music-render.md | 3241 +++++++++++++++++ 1 file changed, 3241 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-plan-b-music-render.md diff --git a/docs/superpowers/plans/2026-05-19-plan-b-music-render.md b/docs/superpowers/plans/2026-05-19-plan-b-music-render.md new file mode 100644 index 0000000..b3095a5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-plan-b-music-render.md @@ -0,0 +1,3241 @@ +# Plan-B-Music — NAS music-lab 분할 + Windows music-render Worker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** NAS music-lab의 모든 외부 음악 생성 API 호출(Suno 13종 + MusicGen 1종)을 Windows AI 머신(WSL2 Docker)으로 완전 이전. NAS는 게이트(메타·DB·Redis push·HTTP forward)만 담당, Windows worker가 Suno/MusicGen 호출 + 결과 파일을 NAS SMB 볼륨에 직접 저장 + webhook으로 NAS DB 업데이트. + +**Architecture:** **Async path** — NAS gateway → Redis RPUSH `queue:music-render` (job_type discriminator) → Windows BLPOP → Suno/MusicGen 호출 + 다운로드 → SMB direct write to `/mnt/nas/webpage/data/music/{task_id}.mp3` → HTTP POST `/api/internal/music/update`. **Sync path** — NAS gateway → httpx forward to Windows `POST/GET /api/music-render/sync/{op}` → 결과 반환. 사용자는 폴링 (`GET /api/music/status/{task_id}`). + +**Tech Stack:** Python 3.12 / FastAPI / Suno API (sunoapi.org) / MusicGen Windows native (192.168.45.59:8765) / `redis>=5.0` (async) / `httpx` / Docker Engine in WSL2 Ubuntu 24.04 / cifs SMB to NAS / mutagen (MP3 duration). + +**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-5·SP-6, §5 Windows Render Worker 패턴, §6 Redis 큐, §8 internal webhook + auth, §9 SUNO_API_KEY 이전, §10 SP-5/SP-6 상세 + +**Prerequisites (✅ 모두 완료):** +- Plan-A: web-ai/NAS 캐시 강화 +- Plan-B-Base: NAS Redis 컨테이너 + Windows WSL2/Docker/SMB +- Plan-B-Insta: 패턴 검증 + verify_internal_key + Redis 큐 + nginx 차단 인프라 + +**Plan-B-Insta와의 차이:** +1. **Endpoint 수**: 인스타 1개(render) vs 음악 14개(13 Suno async + 1 MusicGen async + 4 sync helpers) +2. **Sync 호출 존재**: lyrics·credits·timestamped-lyrics·style-boost는 즉시 응답 필요 → HTTP forward 패턴 추가 +3. **SUNO_API_KEY 이전**: NAS `.env`에서 완전 제거 (spec §9) +4. **DB 호출 변환**: Windows에서 NAS DB 직접 접근 불가 → 모든 `add_track`/`update_task`/`update_track_*` 호출을 webhook 페이로드로 변환 +5. **`job_type` discriminator**: 큐 단일이지만 13가지 작업 분기 필요 (worker dispatch by `params.job_type`) +6. **결과 디렉토리**: `/mnt/nas/webpage/data/music/{task_id}.mp3` (Plan-B-Insta는 `/insta/{slate_id}/01.png`) + +--- + +## Phase 구조 + +| Phase | 내용 | Task | +|-------|------|------| +| **1. NAS gateway 수신부** | auth + webhook endpoint + redis client + docker-compose env | 1~2 | +| **2. Windows worker 신설** | services/music-render — providers + sync handlers + worker + main + tests + compose | 3~10 | +| **3. NAS music-lab cutover** | background_tasks→Redis push, sync→HTTP forward, batch_generator 호환, provider stub | 11~14 | +| **4. nginx 차단 + 검증** | 3-layer 차단 + end-to-end Suno 1트랙 검증 + 최종 정리 | 15~17 | + +**순서 강제 사유:** Phase 1·2 먼저(수신부+worker 준비) → Phase 3(전환) → Phase 4(보안+검증). Phase 3 먼저 하면 큐에 쌓이고 worker 미존재로 처리 안 됨 (Plan-B-Insta T11 cutover 직전까지 Phase 1·2 완성 패턴 그대로). + +--- + +## File Structure + +### Phase 1·3·4 — NAS web-backend + +| 파일 | 변경 | 책임 | +|------|------|------| +| `web-backend/music-lab/app/auth.py` (Create) | `verify_internal_key` dependency | X-Internal-Key 검증 (insta-lab/app/auth.py 동일 복제) | +| `web-backend/music-lab/app/internal_router.py` (Create) | `POST /api/internal/music/update` | Windows webhook 수신 — DB update_task + add_track | +| `web-backend/music-lab/app/sync_forward.py` (Create) | `POST /api/music/lyrics`, `GET /api/music/credits`, `GET /api/music/timestamped-lyrics`, `POST /api/music/style-boost` — 모두 Windows music-render로 httpx forward | sync helper 프록시 | +| `web-backend/music-lab/app/main.py` | redis client 추가 + `background_tasks.add_task(run_*)` 14개를 Redis push로 + sync helpers를 sync_forward로 + router include | Redis push 전환 | +| `web-backend/music-lab/app/suno_provider.py` | 외부 API 호출 함수 13개를 1줄 stub으로 — DEPRECATED 마커 | 코드 제거 | +| `web-backend/music-lab/app/local_provider.py` | `run_local_generation` stub | 코드 제거 | +| `web-backend/music-lab/app/batch_generator.py` | `_generate_one_track`의 `run_suno_generation` 직접 호출을 Redis push로 변경 | batch도 worker 경유 | +| `web-backend/music-lab/requirements.txt` | `redis>=5.0` 추가 (`requests`는 mutagen/youtube oauth용 유지) | 의존성 | +| `web-backend/music-lab/.env` (예시) | `REDIS_URL`, `INTERNAL_API_KEY`, `MUSIC_RENDER_URL` 추가. `SUNO_API_KEY` 제거 | 환경 | +| `web-backend/docker-compose.yml` | music-lab service에 REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL env 추가 + depends_on redis + SUNO_API_KEY 라인 제거 | compose | +| `web-backend/nginx/default.conf` | `location /api/internal/music/` IP allow + deny all (insta 블록 복제) | 3-layer 차단 | + +### Phase 2 — Windows web-ai/services/ + +| 파일 | 변경 | 책임 | +|------|------|------| +| `web-ai/services/music-render/Dockerfile` (Create) | python:3.12-slim + requests + httpx + redis + mutagen | image | +| `web-ai/services/music-render/requirements.txt` (Create) | fastapi, uvicorn, requests, redis, httpx, mutagen, pytest | deps | +| `web-ai/services/music-render/.env.example` (Create) | `REDIS_URL`, `NAS_BASE_URL`, `INTERNAL_API_KEY`, `SUNO_API_KEY`, `MUSIC_AI_SERVER_URL`(MusicGen 호스트), `MUSIC_MEDIA_ROOT`, `MUSIC_MEDIA_URL_PREFIX` | secrets | +| `web-ai/services/music-render/providers/__init__.py` (Create) | 빈 패키지 마커 | package | +| `web-ai/services/music-render/providers/suno.py` (Create) | NAS suno_provider.py 이식 — 13 함수 + `add_track`/`update_task` 호출을 webhook 호출로 변환 | Suno API client | +| `web-ai/services/music-render/providers/local.py` (Create) | NAS local_provider.py 이식 — MusicGen 호출 (192.168.45.59:8765) + webhook | MusicGen client | +| `web-ai/services/music-render/providers/sync_ops.py` (Create) | sync Suno API 4종 (lyrics, credits, timestamped-lyrics, style-boost) | sync helper | +| `web-ai/services/music-render/nas_client.py` (Create) | `webhook_update_task`, `webhook_add_track` — NAS POST 호출 헬퍼 (X-Internal-Key) | webhook adapter | +| `web-ai/services/music-render/worker.py` (Create) | Redis BLPOP 루프 + queue:paused 체크 + `job_type` 디스패처 | dispatcher | +| `web-ai/services/music-render/main.py` (Create) | FastAPI app + lifespan(worker spawn) + sync endpoints (`/api/music-render/sync/*`) | entry | +| `web-ai/services/music-render/tests/test_worker.py` (Create) | mocked Redis + job_type 디스패치 검증 | TDD | +| `web-ai/services/music-render/tests/test_nas_client.py` (Create) | httpx mock webhook 호출 검증 | TDD | +| `web-ai/services/docker-compose.yml` | music-render service 추가 | compose | + +--- + +## Task 1: NAS music-lab — `verify_internal_key` + `/api/internal/music/update` endpoint + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/auth.py` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/internal_router.py` +- Test: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_auth.py` +- Test: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_internal_router.py` + +- [ ] **Step 1: `auth.py` 실패하는 테스트 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_auth.py`: + +```python +"""verify_internal_key dependency — Windows music-render webhook 인증.""" +import pytest +from fastapi import HTTPException +from app.auth import verify_internal_key + + +def test_valid_key_passes(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "secret123") + verify_internal_key(x_internal_key="secret123") + + +def test_invalid_key_raises_401(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "secret123") + with pytest.raises(HTTPException) as exc: + verify_internal_key(x_internal_key="wrong") + assert exc.value.status_code == 401 + + +def test_missing_env_key_raises_401(monkeypatch): + monkeypatch.delenv("INTERNAL_API_KEY", raising=False) + with pytest.raises(HTTPException) as exc: + verify_internal_key(x_internal_key="any") + assert exc.value.status_code == 401 +``` + +- [ ] **Step 2: `auth.py` 테스트 실패 확인** + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab && python -m pytest tests/test_auth.py -v` +Expected: FAIL — `app.auth` 모듈 미존재. + +- [ ] **Step 3: `auth.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/auth.py`: + +```python +"""SP-6 — Windows worker → NAS internal webhook 인증. + +X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교. +서버 측 키 미설정 시 401 (안전한 기본값). +""" +from __future__ import annotations + +import os +from fastapi import Header, HTTPException + + +def verify_internal_key(x_internal_key: str = Header(...)): + expected = os.getenv("INTERNAL_API_KEY") + if not expected: + raise HTTPException(401, "INTERNAL_API_KEY not configured on server") + if x_internal_key != expected: + raise HTTPException(401, "Invalid X-Internal-Key") +``` + +- [ ] **Step 4: `auth.py` 테스트 통과** + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab && python -m pytest tests/test_auth.py -v` +Expected: 3 PASS. + +- [ ] **Step 5: `internal_router.py` 실패하는 테스트 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/tests/test_internal_router.py`: + +```python +"""POST /api/internal/music/update — Windows music-render webhook.""" +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from app.internal_router import router +from app import db + + +@pytest.fixture(autouse=True) +def _set_key(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "test-secret") + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setenv("MUSIC_DATA_DIR", str(tmp_path)) + db.init_db() + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def _make_task(): + tid = "test-task-1" + db.create_task(tid, {"provider": "suno", "title": "T"}, provider="suno") + return tid + + +def test_update_with_valid_key_updates_db(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"}, + ) + assert r.status_code == 200 + task = db.get_task(tid) + assert task["status"] == "processing" + assert task["progress"] == 30 + + +def test_update_with_invalid_key_returns_401(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "wrong"}, + json={"task_id": tid, "status": "processing", "progress": 30}, + ) + assert r.status_code == 401 + + +def test_update_succeeded_with_audio_url(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={ + "task_id": tid, "status": "succeeded", "progress": 100, + "message": "완료", "audio_url": "/media/music/test-task-1.mp3", + }, + ) + assert r.status_code == 200 + task = db.get_task(tid) + assert task["status"] == "succeeded" + assert task["audio_url"] == "/media/music/test-task-1.mp3" + + +def test_update_failed_records_error(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={"task_id": tid, "status": "failed", "progress": 0, "error": "Suno API rate limit"}, + ) + assert r.status_code == 200 + task = db.get_task(tid) + assert task["status"] == "failed" + assert "Suno" in (task.get("error") or "") + + +def test_add_track_action_inserts_library(client): + tid = _make_task() + r = client.post( + "/api/internal/music/update", + headers={"X-Internal-Key": "test-secret"}, + json={ + "task_id": tid, "status": "succeeded", "progress": 100, + "message": "ok", + "track": { + "title": "My Song", "genre": "lofi", "moods": ["chill"], + "instruments": [], "duration_sec": 180, "bpm": 80, + "key": "C", "scale": "major", "prompt": "", + "audio_url": "/media/music/test-task-1.mp3", + "file_path": "/app/data/test-task-1.mp3", + "task_id": tid, "provider": "suno", + "lyrics": "", "image_url": "", "suno_id": "suno-abc", + }, + }, + ) + assert r.status_code == 200 + tracks = db.get_all_tracks() + assert any(t["title"] == "My Song" for t in tracks) +``` + +- [ ] **Step 6: `internal_router.py` 테스트 실패 확인** + +Run: `python -m pytest tests/test_internal_router.py -v` +Expected: FAIL — `app.internal_router` 미존재. + +- [ ] **Step 7: `internal_router.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/internal_router.py`: + +```python +"""SP-6 — Windows music-render → NAS internal webhook. + +POST /api/internal/music/update +- X-Internal-Key 인증 필수 +- music_tasks 테이블 row update (status, progress, message, audio_url, error) +- 옵션 `track` 페이로드가 있으면 music_library에 add_track 호출 +""" +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from . import db +from .auth import verify_internal_key + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class UpdatePayload(BaseModel): + task_id: str + status: str = Field(..., description="processing|succeeded|failed") + progress: int = Field(..., ge=0, le=100) + message: str = "" + audio_url: Optional[str] = None + error: Optional[str] = None + # Optional: 라이브러리 등록을 함께 요청. add_track 페이로드 그대로. + track: Optional[Dict[str, Any]] = None + + +@router.post( + "/api/internal/music/update", + dependencies=[Depends(verify_internal_key)], +) +def music_update(payload: UpdatePayload): + task = db.get_task(payload.task_id) + if task is None: + raise HTTPException(404, f"task not found: {payload.task_id}") + + db.update_task( + payload.task_id, + payload.status, + payload.progress, + message=payload.message, + audio_url=payload.audio_url, + error=payload.error, + ) + + if payload.track: + try: + db.add_track(payload.track) + except Exception: + logger.exception("add_track 실패 task=%s (무시)", payload.task_id) + + logger.info( + "internal/music/update task=%s status=%s progress=%d", + payload.task_id, payload.status, payload.progress, + ) + return {"ok": True} +``` + +- [ ] **Step 8: `internal_router.py` 테스트 통과** + +Run: `python -m pytest tests/test_internal_router.py -v` +Expected: 5 PASS. + +- [ ] **Step 9: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git add music-lab/app/auth.py music-lab/app/internal_router.py music-lab/tests/test_auth.py music-lab/tests/test_internal_router.py +git commit -m "$(cat <<'EOF' +feat(music-lab): verify_internal_key + /api/internal/music/update (SP-6) + +X-Internal-Key 헤더 검증 dependency (insta-lab 동일 패턴). +Windows music-render webhook 수신 endpoint — update_task + 옵션 add_track. +Plan-B-Music Phase 1 (수신부). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: NAS music-lab — main.py wire (redis client + router include) + docker-compose env + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py:1-50` (imports + redis client + router) +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/requirements.txt` +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml:55-90` (music-lab section) + +- [ ] **Step 1: `requirements.txt`에 redis 추가** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/requirements.txt`에 다음 라인 추가 (`mutagen==1.47.0` 다음): + +``` +redis>=5.0 +``` + +- [ ] **Step 2: `main.py` — redis client + internal_router include** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py`의 import 블록 (현재 line 1-39) 끝에 추가: + +```python +import redis.asyncio as aioredis +from .internal_router import router as internal_router +``` + +`app = FastAPI()` (현재 line 40) 바로 뒤에 추가: + +```python +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379") +redis_client = aioredis.from_url(REDIS_URL, decode_responses=False) + +app.include_router(internal_router) +``` + +- [ ] **Step 3: smoke test — import만 확인** + +Run: `cd music-lab && python -c "from app.main import app, redis_client; print('OK')"` +Expected: `OK` 출력. + +- [ ] **Step 4: `docker-compose.yml` — music-lab 환경변수 추가** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`의 music-lab service (line 55-90) 내 `environment:` 섹션에서: + +**제거**: `- SUNO_API_KEY=${SUNO_API_KEY:-}` (spec §9 — NAS .env에서 완전 제거) + +**추가**: +```yaml + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - INTERNAL_API_KEY=${INTERNAL_API_KEY:-} + - MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711} +``` + +같은 service 블록 끝(`healthcheck` 앞)에 `depends_on:` 추가 (insta-lab과 동일): + +```yaml + depends_on: + - redis +``` + +(이미 있다면 redis만 항목 추가) + +- [ ] **Step 5: docker-compose 검증** + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend && docker compose config | grep -A 30 "music-lab:"` +Expected: REDIS_URL, INTERNAL_API_KEY, MUSIC_RENDER_URL 표시. SUNO_API_KEY 미표시. + +- [ ] **Step 6: 커밋** + +```bash +git add music-lab/app/main.py music-lab/requirements.txt docker-compose.yml +git commit -m "$(cat <<'EOF' +feat(music-lab): wire redis client + internal_router + compose env (SP-6) + +main.py에 redis.asyncio client 추가 + internal_router include. +docker-compose의 music-lab에 REDIS_URL/INTERNAL_API_KEY/MUSIC_RENDER_URL. +SUNO_API_KEY 라인 제거 (spec §9 — Windows로 이전). +Plan-B-Music Phase 1. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Windows music-render — Dockerfile + requirements + .env.example + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/Dockerfile` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/requirements.txt` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/.env.example` + +- [ ] **Step 1: `requirements.txt` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/requirements.txt`: + +``` +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +requests==2.32.3 +redis>=5.0 +httpx>=0.27 +mutagen==1.47.0 +pytest>=8.0 +pytest-asyncio>=0.24 +respx>=0.21 +``` + +- [ ] **Step 2: `Dockerfile` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/Dockerfile`: + +```dockerfile +FROM python:3.12-slim-bookworm +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# requests SSL 의존성만 필요 (Chromium 불필요) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +``` + +- [ ] **Step 3: `.env.example` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/.env.example`: + +``` +# Plan-B-Music — Windows music-render worker + +# NAS Redis 큐 +REDIS_URL=redis://192.168.45.54:6379 + +# NAS internal webhook +NAS_BASE_URL=http://192.168.45.54:18600 +INTERNAL_API_KEY=__copy_from_nas_dotenv__ + +# Suno API (sunoapi.org 래퍼) — NAS .env에서 옮겨옴 +SUNO_API_KEY=__paste_suno_key_here__ + +# MusicGen 호스트 (Windows native Python — 박재오 PC localhost) +MUSIC_AI_SERVER_URL=http://host.docker.internal:8765 + +# NAS SMB mount 안의 음악 디렉토리 +MUSIC_MEDIA_ROOT=/mnt/nas/webpage/data/music + +# nginx 서빙 prefix (NAS webhook payload용) +MUSIC_MEDIA_URL_PREFIX=/media/music +``` + +- [ ] **Step 4: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/music-render/Dockerfile services/music-render/requirements.txt services/music-render/.env.example +git commit -m "$(cat <<'EOF' +feat(music-render): Dockerfile + requirements + env.example (SP-5) + +Windows WSL2 Docker 컨테이너 스캐폴드. +Plan-B-Insta보다 가벼움 — Chromium 미포함, requests + httpx + redis + mutagen만. +.env.example에 SUNO_API_KEY 자리 (NAS에서 옮겨올 값). +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Windows music-render — nas_client.py (webhook 헬퍼) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/nas_client.py` +- Test: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_nas_client.py` + +NAS DB 직접 접근이 불가하므로 모든 `update_task`/`add_track` 호출을 webhook으로 변환. 이를 위한 어댑터 모듈. + +- [ ] **Step 1: 실패하는 테스트 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_nas_client.py`: + +```python +"""nas_client — webhook adapter tests.""" +import os +import pytest +import respx +import httpx + +from nas_client import webhook_update_task, webhook_add_track + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("NAS_BASE_URL", "http://nas-test:18600") + monkeypatch.setenv("INTERNAL_API_KEY", "test-key") + + +@respx.mock +def test_webhook_update_task_sends_x_internal_key(): + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-1", "processing", 30, message="downloading") + assert route.called + req = route.calls[0].request + assert req.headers["X-Internal-Key"] == "test-key" + import json + body = json.loads(req.content) + assert body["task_id"] == "task-1" + assert body["status"] == "processing" + assert body["progress"] == 30 + assert body["message"] == "downloading" + + +@respx.mock +def test_webhook_update_task_with_audio_url(): + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-2", "succeeded", 100, message="완료", + audio_url="/media/music/task-2.mp3") + body = httpx.URL(route.calls[0].request.url) + import json + payload = json.loads(route.calls[0].request.content) + assert payload["audio_url"] == "/media/music/task-2.mp3" + assert payload["status"] == "succeeded" + + +@respx.mock +def test_webhook_update_task_with_error(): + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-3", "failed", 0, error="API rate limit") + import json + payload = json.loads(route.calls[0].request.content) + assert payload["error"] == "API rate limit" + + +@respx.mock +def test_webhook_add_track_uses_track_field(): + """add_track은 update와 동시에 (succeeded 시).""" + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + track = {"title": "x", "audio_url": "/media/music/t.mp3", "provider": "suno"} + webhook_add_track("task-4", "succeeded", 100, message="ok", + audio_url="/media/music/t.mp3", track=track) + import json + payload = json.loads(route.calls[0].request.content) + assert payload["track"]["title"] == "x" + assert payload["status"] == "succeeded" + + +@respx.mock +def test_webhook_swallows_network_error(caplog): + """webhook 실패해도 raise 안 함 (logger.error).""" + respx.post("http://nas-test:18600/api/internal/music/update").mock( + side_effect=httpx.ConnectError("no host") + ) + # raise 안 하면 통과 + webhook_update_task("task-5", "processing", 10) +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render && python -m pytest tests/test_nas_client.py -v` +Expected: FAIL — `nas_client` 모듈 미존재. + +- [ ] **Step 3: `nas_client.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/nas_client.py`: + +```python +"""NAS webhook 어댑터 — Windows worker가 NAS DB 직접 접근 못하므로 HTTP로 위임. + +기존 NAS suno_provider/local_provider의 `update_task`, `add_track` 호출을 +이 모듈의 webhook_update_task/webhook_add_track으로 치환. + +webhook 실패는 raise하지 않고 logger.error로 기록 (provider 로직 흐름 유지). +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +logger = logging.getLogger(__name__) + +NAS_BASE_URL = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18600") +INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "") +_TIMEOUT = 10.0 + + +def _post(payload: Dict[str, Any]) -> None: + url = f"{NAS_BASE_URL}/api/internal/music/update" + try: + r = httpx.post( + url, + headers={"X-Internal-Key": INTERNAL_API_KEY}, + json=payload, + timeout=_TIMEOUT, + ) + if r.status_code != 200: + logger.error("webhook %s returned %d: %s", + payload.get("task_id"), r.status_code, r.text[:200]) + except Exception: + logger.exception("webhook %s 호출 실패", payload.get("task_id")) + + +def webhook_update_task( + task_id: str, + status: str, + progress: int, + message: str = "", + audio_url: Optional[str] = None, + error: Optional[str] = None, +) -> None: + """기존 update_task(task_id, status, progress, message, audio_url, error) 대체.""" + payload: Dict[str, Any] = { + "task_id": task_id, + "status": status, + "progress": progress, + "message": message, + } + if audio_url is not None: + payload["audio_url"] = audio_url + if error is not None: + payload["error"] = error + _post(payload) + + +def webhook_add_track( + task_id: str, + status: str, + progress: int, + message: str = "", + audio_url: Optional[str] = None, + track: Optional[Dict[str, Any]] = None, +) -> None: + """update + add_track을 한 webhook 호출로 결합 (NAS internal_router가 둘 다 처리).""" + payload: Dict[str, Any] = { + "task_id": task_id, + "status": status, + "progress": progress, + "message": message, + } + if audio_url is not None: + payload["audio_url"] = audio_url + if track is not None: + payload["track"] = track + _post(payload) +``` + +- [ ] **Step 4: 테스트 통과** + +Run: `python -m pytest tests/test_nas_client.py -v` +Expected: 5 PASS. + +- [ ] **Step 5: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/music-render/nas_client.py services/music-render/tests/test_nas_client.py +git commit -m "$(cat <<'EOF' +feat(music-render): nas_client webhook adapter (SP-5) + +NAS DB 직접 접근 불가 → webhook_update_task/webhook_add_track으로 변환. +X-Internal-Key 헤더 자동 첨부. 실패 시 raise 안 함 (logger.error). +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Windows music-render — providers/suno.py (13 함수 이식) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/__init__.py` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/suno.py` +- Test: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py` + +NAS `music-lab/app/suno_provider.py`를 이식. 차이점: +- `from .db import update_task, add_track, ...` → `from nas_client import webhook_update_task, webhook_add_track` +- `MUSIC_DATA_DIR = "/app/data"` → `MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music")` — NAS SMB 직접 저장 +- `MUSIC_MEDIA_BASE` 그대로 사용 (`/media/music`) +- `update_track_cover_images`, `update_track_wav_url` 등은 webhook에 새 action을 만들지 않고 일단 `webhook_update_task`로 message에 JSON encode (기능 손실 최소 — track 컬럼 업데이트는 batch 미사용) + +- [ ] **Step 1: 빈 `__init__.py` 만들기** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/__init__.py`: + +```python +``` + +(빈 파일) + +- [ ] **Step 2: 실패하는 테스트 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_suno_provider.py`: + +```python +"""providers/suno.py — _build_suno_payload 단위 테스트 + 1개 함수 mock 검증.""" +import pytest +from providers.suno import _build_suno_payload + + +def test_payload_custom_mode_with_lyrics(): + params = {"lyrics": "[Verse]\nhello", "genre": "lofi", "moods": ["chill"], "model": "V4"} + p = _build_suno_payload(params) + assert p["customMode"] is True + assert p["prompt"] == "[Verse]\nhello" + assert "lofi" in p["style"] + assert "chill" in p["style"] + + +def test_payload_simple_mode_no_lyrics_no_genre(): + params = {"prompt": "happy summer", "model": "V4"} + p = _build_suno_payload(params) + assert p["customMode"] is False + assert "happy summer" in p["prompt"] + + +def test_payload_instrumental_clears_prompt(): + params = {"genre": "ambient", "instrumental": True, "model": "V5"} + p = _build_suno_payload(params) + assert p["instrumental"] is True + assert p["prompt"] == "" + + +def test_payload_includes_optional_vocal_gender(): + params = {"genre": "pop", "vocal_gender": "f", "model": "V4"} + p = _build_suno_payload(params) + assert p["vocalGender"] == "f" +``` + +- [ ] **Step 3: 테스트 실패 확인** + +Run: `python -m pytest tests/test_suno_provider.py -v` +Expected: FAIL — `providers.suno` 미존재. + +- [ ] **Step 4: `providers/suno.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/suno.py`: + +```python +"""Suno API Provider — sunoapi.org 래퍼. + +NAS music-lab/app/suno_provider.py에서 이식. 차이점: +- DB 호출(update_task, add_track 등)을 nas_client.webhook_* 으로 변환 +- 결과 MP3는 MUSIC_MEDIA_ROOT (/mnt/nas/webpage/data/music/)에 직접 저장 +""" +from __future__ import annotations + +import json +import logging +import os +import time +from typing import Optional + +import requests + +from nas_client import webhook_update_task, webhook_add_track + +logger = logging.getLogger(__name__) + +SUNO_BASE_URL = "https://api.sunoapi.org/api/v1" +SUNO_API_KEY = os.getenv("SUNO_API_KEY", "") +MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music") +MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music") + +POLL_INTERVAL = 8 +POLL_MAX_ATTEMPTS = 40 + + +def _headers() -> dict: + return { + "Authorization": f"Bearer {SUNO_API_KEY}", + "Content-Type": "application/json", + } + + +def _build_suno_payload(params: dict) -> dict: + """프론트엔드 params → sunoapi.org 요청 형식 (NAS 코드 그대로 이식).""" + instrumental = params.get("instrumental", False) + has_lyrics = bool(params.get("lyrics")) + custom_mode = has_lyrics or bool(params.get("genre")) or bool(params.get("moods")) + + payload = { + "customMode": custom_mode, + "instrumental": instrumental, + "model": params.get("model", "V4"), + "callBackUrl": "https://example.com/noop", + } + + if custom_mode: + if instrumental: + payload["prompt"] = "" + elif has_lyrics: + payload["prompt"] = params["lyrics"][:3000] + else: + prompt_text = params.get("prompt", "") + payload["prompt"] = prompt_text[:3000] if prompt_text else "" + + style_parts = [] + if params.get("genre"): + style_parts.append(params["genre"]) + if params.get("moods"): + style_parts.extend(params["moods"]) + if params.get("instruments"): + style_parts.extend(params["instruments"][:3]) + if style_parts: + payload["style"] = ", ".join(style_parts)[:200] + + if params.get("title"): + payload["title"] = params["title"][:80] + else: + parts = [] + if params.get("prompt"): + parts.append(params["prompt"]) + if params.get("genre"): + parts.append(params["genre"]) + if params.get("moods"): + parts.append(", ".join(params["moods"])) + payload["prompt"] = " ".join(parts)[:500] if parts else "instrumental music" + + if params.get("vocal_gender"): + payload["vocalGender"] = params["vocal_gender"] + if params.get("negative_tags"): + payload["negativeTags"] = params["negative_tags"] + if params.get("style_weight") is not None: + payload["styleWeight"] = params["style_weight"] + if params.get("audio_weight") is not None: + payload["audioWeight"] = params["audio_weight"] + + return payload + + +def _poll_suno_record( + record_info_path: str, + suno_task_id: str, + task_id: str, + max_attempts: int = POLL_MAX_ATTEMPTS, + interval: int = POLL_INTERVAL, + progress_msg_map: dict = None, +) -> Optional[dict]: + """범용 Suno 작업 폴링. SUCCESS 시 response 객체 반환.""" + error_statuses = { + "CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", + "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR", + } + default_msgs = { + "PENDING": "대기열에서 대기 중...", + "TEXT_SUCCESS": "가사 생성 완료, 음악 생성 중...", + "FIRST_SUCCESS": "첫 번째 트랙 완료, 두 번째 생성 중...", + "GENERATING": "생성 중...", + } + msgs = {**default_msgs, **(progress_msg_map or {})} + + for attempt in range(max_attempts): + time.sleep(interval) + try: + resp = requests.get( + f"{SUNO_BASE_URL}{record_info_path}", + headers=_headers(), + params={"taskId": suno_task_id}, + timeout=15, + ) + if resp.status_code != 200: + continue + body = resp.json() + if body.get("code") != 200: + continue + data = body.get("data", {}) + status = data.get("status", "") + progress = min(15 + int((attempt / max_attempts) * 65), 79) + + if status == "SUCCESS": + return data.get("response", data) + elif status in error_statuses: + error_msg = data.get("errorMessage") or data.get("msg") or f"Suno 작업 실패 ({status})" + webhook_update_task(task_id, "failed", 0, "", error=error_msg) + return None + else: + msg = msgs.get(status, f"처리 중... ({status})") + if status == "FIRST_SUCCESS": + progress = max(progress, 60) + webhook_update_task(task_id, "processing", progress, msg) + except Exception as e: + logger.warning("Suno poll error (attempt %d): %s", attempt, e) + continue + + webhook_update_task(task_id, "failed", 0, "", error="Suno 작업 타임아웃") + return None + + +def _download_and_register( + task_id: str, song: dict, params: dict, filename_suffix: str = "", +) -> Optional[dict]: + """Suno CDN에서 MP3 다운로드 → /mnt/nas/...에 직접 저장 → webhook으로 add_track.""" + audio_url_remote = song.get("audioUrl") or song.get("audio_url", "") + if not audio_url_remote: + webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 audioUrl이 없습니다") + return None + + filename = f"{task_id}{filename_suffix}.mp3" + os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True) + file_path = os.path.join(MUSIC_MEDIA_ROOT, filename) + + try: + dl = requests.get(audio_url_remote, timeout=120, stream=True) + dl.raise_for_status() + with open(file_path, "wb") as f: + for chunk in dl.iter_content(chunk_size=8192): + f.write(chunk) + except Exception as e: + webhook_update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}") + return None + + local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}" + + genre = params.get("genre", song.get("tags", "")) + moods = params.get("moods", []) + mood_str = moods[0] if moods else "Original" + title = ( + song.get("title") + or params.get("title") + or (f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix") + ) + + track_data = { + "title": title, + "genre": genre, + "moods": moods, + "instruments": params.get("instruments", []), + "duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"), + "bpm": params.get("bpm"), + "key": params.get("key", ""), + "scale": params.get("scale", ""), + "prompt": song.get("prompt", params.get("prompt", "")), + "audio_url": local_audio_url, + # NAS file_path는 NAS 관점 — /app/data 안의 경로 + "file_path": f"/app/data/{filename}", + "task_id": task_id, + "provider": "suno", + "lyrics": song.get("prompt", params.get("lyrics", "")), + "image_url": song.get("imageUrl") or song.get("image_url", ""), + "suno_id": song.get("id", ""), + } + return track_data + + +def run_suno_generation(task_id: str, params: dict) -> None: + """BackgroundTask: Suno API로 곡 생성 → MP3 → NAS SMB 저장 → webhook add_track.""" + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정 (Windows .env)") + return + + webhook_update_task(task_id, "processing", 5, "Suno API에 연결 중...") + payload = _build_suno_payload(params) + resp = requests.post(f"{SUNO_BASE_URL}/generate", headers=_headers(), json=payload, timeout=30) + + if resp.status_code != 200: + err = resp.text[:300] if resp.text else f"HTTP {resp.status_code}" + webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {err}") + return + + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Suno API 거부: {body.get('msg', '?')}") + return + + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="Suno 응답에 taskId 없음") + return + + webhook_update_task(task_id, "processing", 15, "곡 생성 대기열에 등록됨...") + + response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="Suno 완료했으나 트랙 데이터 없음") + return + + webhook_update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...") + track = _download_and_register(task_id, completed[0], params) + if not track: + return + + webhook_add_track(task_id, "succeeded", 100, "생성 완료", + audio_url=track["audio_url"], track=track) + + if len(completed) > 1: + try: + second = _download_and_register(f"{task_id}_v2", completed[1], params) + if second: + # 두 번째 변형은 별도 task가 아니라 별도 track으로만 등록 + webhook_add_track(f"{task_id}_v2", "succeeded", 100, "두 번째 변형", + audio_url=second["audio_url"], track=second) + except Exception: + pass + + except requests.Timeout: + webhook_update_task(task_id, "failed", 0, "", error="Suno API 타임아웃") + except Exception as e: + logger.exception("Suno generation error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_suno_extend(task_id: str, params: dict) -> None: + """기존 곡을 특정 지점부터 연장.""" + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정") + return + webhook_update_task(task_id, "processing", 5, "곡 연장 요청 중...") + payload = { + "audioId": params["suno_id"], + "defaultParamFlag": not bool(params.get("prompt")), + "prompt": params.get("prompt", ""), + "continueAt": params.get("continue_at", 0), + "model": params.get("model", "V4"), + "callBackUrl": "https://example.com/noop", + } + if params.get("style"): + payload["style"] = params["style"] + if params.get("title"): + payload["title"] = params["title"] + + resp = requests.post(f"{SUNO_BASE_URL}/generate/extend", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Suno Extend 오류: {resp.text[:300]}") + return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Extend 거부: {body.get('msg', '?')}") + return + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="Extend 응답에 taskId 없음") + return + webhook_update_task(task_id, "processing", 15, "곡 연장 대기열에 등록됨...") + + response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="연장 완료했으나 트랙 없음") + return + webhook_update_task(task_id, "processing", 80, "연장된 오디오 다운로드 중...") + track = _download_and_register(task_id, completed[0], params) + if track: + webhook_add_track(task_id, "succeeded", 100, "곡 연장 완료", + audio_url=track["audio_url"], track=track) + except Exception as e: + logger.exception("Suno extend error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_vocal_removal(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정") + return + webhook_update_task(task_id, "processing", 5, "보컬 분리 요청 중...") + payload = {"audioId": params["suno_id"], "callBackUrl": "https://example.com/noop"} + resp = requests.post(f"{SUNO_BASE_URL}/vocal-removal/generate", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Vocal Removal 오류: {resp.text[:300]}") + return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Vocal Removal 거부: {body.get('msg', '?')}") + return + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음") + return + webhook_update_task(task_id, "processing", 15, "보컬 분리 처리 중...") + response = _poll_suno_record("/vocal-removal/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="분리 완료했으나 트랙 없음") + return + webhook_update_task(task_id, "processing", 80, "분리된 오디오 다운로드 중...") + vp = {**params, "title": f"{params.get('title', 'Track')} (Vocals)"} + track = _download_and_register(task_id, completed[0], vp) + if len(completed) > 1: + ip = {**params, "title": f"{params.get('title', 'Track')} (Instrumental)"} + second = _download_and_register(f"{task_id}_inst", completed[1], ip) + if second: + webhook_add_track(f"{task_id}_inst", "succeeded", 100, "Instrumental", + audio_url=second["audio_url"], track=second) + if track: + webhook_add_track(task_id, "succeeded", 100, "보컬 분리 완료", + audio_url=track["audio_url"], track=track) + except Exception as e: + logger.exception("vocal removal error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_cover_image(task_id: str, params: dict) -> None: + """Suno 곡의 커버 이미지 2장 (URL JSON 반환).""" + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "커버 이미지 생성 요청 중...") + suno_task_id = params.get("suno_task_id", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="suno_task_id 필요"); return + payload = {"taskId": suno_task_id, "callBackUrl": "https://example.com/noop"} + resp = requests.post(f"{SUNO_BASE_URL}/suno/cover/generate", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Cover API 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Cover 거부: {body.get('msg', '?')}"); return + cover_task_id = body.get("data", {}).get("taskId", suno_task_id) + webhook_update_task(task_id, "processing", 15, "커버 이미지 생성 중...") + response = _poll_suno_record( + "/suno/cover/record-info", cover_task_id, task_id, + max_attempts=30, interval=5, + progress_msg_map={"PENDING": "이미지 생성 대기 중...", "GENERATING": "이미지 생성 중..."}, + ) + if not response: + return + images = response.get("images") or response.get("sunoData") or [] + urls = [] + if isinstance(images, list): + for img in images: + if isinstance(img, str): + urls.append(img) + elif isinstance(img, dict): + urls.append(img.get("imageUrl") or img.get("image_url", "")) + webhook_update_task(task_id, "succeeded", 100, "커버 완료", + audio_url=json.dumps(urls)) + except Exception as e: + logger.exception("cover image error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_wav_convert(task_id: str, params: dict) -> None: + """곡을 WAV 포맷으로 변환 (URL만).""" + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "WAV 변환 요청 중...") + payload = { + "taskId": params["suno_task_id"], + "audioId": params["suno_id"], + "callBackUrl": "https://example.com/noop", + } + resp = requests.post(f"{SUNO_BASE_URL}/wav/generate", headers=_headers(), json=payload, timeout=30) + if resp.status_code == 409: + body = resp.json() + wav_url = body.get("data", {}).get("audioWavUrl", "") + if wav_url: + webhook_update_task(task_id, "succeeded", 100, "WAV 캐시", audio_url=wav_url) + return + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"WAV 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"WAV 거부: {body.get('msg', '?')}"); return + wav_task_id = body.get("data", {}).get("taskId", params["suno_task_id"]) + webhook_update_task(task_id, "processing", 15, "WAV 변환 처리 중...") + response = _poll_suno_record( + "/wav/record-info", wav_task_id, task_id, + max_attempts=30, interval=5, + progress_msg_map={"PENDING": "WAV 대기 중...", "GENERATING": "WAV 변환 중..."}, + ) + if not response: + return + wav_url = "" + sd = response.get("sunoData") or [] + if sd and isinstance(sd, list) and isinstance(sd[0], dict): + wav_url = sd[0].get("audioWavUrl", "") + if not wav_url: + wav_url = response.get("audioWavUrl", "") + webhook_update_task(task_id, "succeeded", 100, "WAV 변환 완료", audio_url=wav_url) + except Exception as e: + logger.exception("wav convert error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_stem_split(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "12스템 분리 요청 중...") + payload = { + "taskId": params["suno_task_id"], + "audioId": params["suno_id"], + "type": "split_stem", + "callBackUrl": "https://example.com/noop", + } + resp = requests.post(f"{SUNO_BASE_URL}/vocal-removal/generate", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Stem API 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Stem 거부: {body.get('msg', '?')}"); return + stem_task_id = body.get("data", {}).get("taskId", "") + if not stem_task_id: + webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return + webhook_update_task(task_id, "processing", 15, "12스템 분리 처리 중 (2~3분)...") + response = _poll_suno_record( + "/vocal-removal/record-info", stem_task_id, task_id, + max_attempts=40, interval=8, + progress_msg_map={"PENDING": "스템 대기 중...", "GENERATING": "스템 분리 중..."}, + ) + if not response: + return + sd = response.get("sunoData") or [] + stems = {} + names = ["vocal", "backing_vocals", "drums", "bass", "guitar", "keyboard", + "strings", "brass", "woodwinds", "percussion", "synth", "fx"] + for i, item in enumerate(sd): + if isinstance(item, dict): + nm = names[i] if i < len(names) else f"stem_{i}" + stems[nm] = item.get("audioUrl") or item.get("audio_url", "") + webhook_update_task(task_id, "succeeded", 100, "12스템 완료", + audio_url=json.dumps(stems)) + except Exception as e: + logger.exception("stem split error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_upload_cover(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "AI Cover 요청 중...") + payload = { + "uploadUrl": params["upload_url"], + "customMode": params.get("custom_mode", True), + "instrumental": params.get("instrumental", False), + "model": params.get("model", "V4"), + "callBackUrl": "https://example.com/noop", + } + for k, ak in [("prompt", "prompt"), ("style", "style"), ("title", "title"), + ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"), + ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]: + if params.get(k): + payload[ak] = params[k] + resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Upload Cover 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', '?')}"); return + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return + webhook_update_task(task_id, "processing", 15, "AI Cover 생성 중...") + response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="Cover 완료했으나 트랙 없음"); return + track = _download_and_register(task_id, completed[0], params) + if track: + webhook_add_track(task_id, "succeeded", 100, "AI Cover 완료", + audio_url=track["audio_url"], track=track) + except Exception as e: + logger.exception("upload cover error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_upload_extend(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "Upload Extend 요청 중...") + payload = { + "uploadUrl": params["upload_url"], + "defaultParamFlag": params.get("default_param_flag", True), + "model": params.get("model", "V4"), + "callBackUrl": "https://example.com/noop", + } + for k, ak in [("prompt", "prompt"), ("style", "style"), ("title", "title"), + ("continue_at", "continueAt"), ("instrumental", "instrumental"), + ("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]: + if params.get(k) is not None: + payload[ak] = params[k] + resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Upload Extend 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', '?')}"); return + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return + webhook_update_task(task_id, "processing", 15, "Upload Extend 생성 중...") + response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음"); return + track = _download_and_register(task_id, completed[0], params) + if track: + webhook_add_track(task_id, "succeeded", 100, "Upload Extend 완료", + audio_url=track["audio_url"], track=track) + except Exception as e: + logger.exception("upload extend error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_add_vocals(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "보컬 추가 요청 중...") + payload = { + "uploadUrl": params["upload_url"], + "prompt": params.get("prompt", ""), + "title": params.get("title", ""), + "style": params.get("style", ""), + "negativeTags": params.get("negative_tags", ""), + "callBackUrl": "https://example.com/noop", + } + for k, ak in [("vocal_gender", "vocalGender"), ("model", "model"), + ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]: + if params.get(k) is not None: + payload[ak] = params[k] + resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Add Vocals 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', '?')}"); return + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return + webhook_update_task(task_id, "processing", 15, "AI 보컬 생성 중...") + response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음"); return + track = _download_and_register(task_id, completed[0], params) + if track: + webhook_add_track(task_id, "succeeded", 100, "보컬 추가 완료", + audio_url=track["audio_url"], track=track) + except Exception as e: + logger.exception("add vocals error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_add_instrumental(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...") + payload = { + "uploadUrl": params["upload_url"], + "title": params.get("title", ""), + "tags": params.get("tags", ""), + "negativeTags": params.get("negative_tags", ""), + "callBackUrl": "https://example.com/noop", + } + for k, ak in [("vocal_gender", "vocalGender"), ("model", "model"), + ("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]: + if params.get(k) is not None: + payload[ak] = params[k] + resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Add Inst 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Add Inst 거부: {body.get('msg', '?')}"); return + suno_task_id = body.get("data", {}).get("taskId", "") + if not suno_task_id: + webhook_update_task(task_id, "failed", 0, "", error="응답에 taskId 없음"); return + webhook_update_task(task_id, "processing", 15, "AI 반주 생성 중...") + response = _poll_suno_record("/generate/record-info", suno_task_id, task_id) + if not response: + return + completed = response.get("sunoData") or [] + if not completed: + webhook_update_task(task_id, "failed", 0, "", error="Add Inst 완료했으나 트랙 없음"); return + track = _download_and_register(task_id, completed[0], params) + if track: + webhook_add_track(task_id, "succeeded", 100, "Add Instrumental 완료", + audio_url=track["audio_url"], track=track) + except Exception as e: + logger.exception("add instrumental error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) + + +def run_video_generate(task_id: str, params: dict) -> None: + try: + if not SUNO_API_KEY: + webhook_update_task(task_id, "failed", 0, "", error="SUNO_API_KEY 미설정"); return + webhook_update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...") + payload = { + "taskId": params["suno_task_id"], + "audioId": params["suno_id"], + "callBackUrl": "https://example.com/noop", + } + if params.get("author"): + payload["author"] = params["author"][:50] + if params.get("domain_name"): + payload["domainName"] = params["domain_name"][:50] + resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Video 오류: {resp.text[:300]}"); return + body = resp.json() + if body.get("code") != 200: + webhook_update_task(task_id, "failed", 0, "", error=f"Video 거부: {body.get('msg', '?')}"); return + video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", "")) + webhook_update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...") + response = _poll_suno_record( + "/mp4/record-info", video_task_id, task_id, + max_attempts=60, interval=10, + progress_msg_map={"PENDING": "비디오 대기 중...", "GENERATING": "비디오 렌더링 중..."}, + ) + if not response: + return + video_url = "" + sd = response.get("sunoData") or [] + if sd and isinstance(sd, list) and isinstance(sd[0], dict): + video_url = sd[0].get("videoUrl") or sd[0].get("video_url", "") + if not video_url: + video_url = response.get("video_url") or response.get("videoUrl", "") + webhook_update_task(task_id, "succeeded", 100, "뮤직비디오 완료", audio_url=video_url) + except Exception as e: + logger.exception("video generate error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) +``` + +- [ ] **Step 5: 테스트 통과** + +Run: `python -m pytest tests/test_suno_provider.py -v` +Expected: 4 PASS. + +- [ ] **Step 6: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/music-render/providers/__init__.py services/music-render/providers/suno.py services/music-render/tests/test_suno_provider.py +git commit -m "$(cat <<'EOF' +feat(music-render): providers/suno.py — 13 Suno API 함수 이식 (SP-5) + +NAS music-lab/app/suno_provider.py를 Windows worker로 이식. +DB 호출(update_task, add_track 등)을 nas_client.webhook_*으로 변환. +결과 MP3는 MUSIC_MEDIA_ROOT(/mnt/nas/...)에 직접 저장. +13 함수: generation, extend, vocal_removal, cover_image, wav, stem_split, +upload_cover, upload_extend, add_vocals, add_instrumental, video_generate ++ _build_suno_payload + _poll_suno_record + _download_and_register +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Windows music-render — providers/local.py (MusicGen 이식) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/local.py` + +- [ ] **Step 1: `providers/local.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/local.py`: + +```python +"""Local MusicGen Provider — Windows AI 머신의 native MusicGen 서버(:8765) 호출. + +NAS music-lab/app/local_provider.py 이식. DB 호출만 webhook으로 변환. +""" +from __future__ import annotations + +import logging +import os +import time + +import requests + +from nas_client import webhook_update_task, webhook_add_track + +logger = logging.getLogger(__name__) + +MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "") +MUSIC_MEDIA_ROOT = os.getenv("MUSIC_MEDIA_ROOT", "/mnt/nas/webpage/data/music") +MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_URL_PREFIX", "/media/music") + + +def run_local_generation(task_id: str, params: dict) -> None: + """MusicGen 생성 → /mnt/nas/.../music/{task_id}.mp3 저장 → add_track.""" + try: + webhook_update_task(task_id, "processing", 10, "AI 서버에 연결 중...") + if not MUSIC_AI_SERVER_URL: + webhook_update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL 미설정") + return + + webhook_update_task(task_id, "processing", 30, "음악 생성 중...") + resp = requests.post(f"{MUSIC_AI_SERVER_URL}/generate", json=params, timeout=30) + if resp.status_code != 200: + webhook_update_task(task_id, "failed", 0, "", + error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}") + return + + ai_task_id = resp.json().get("task_id") + if not ai_task_id: + webhook_update_task(task_id, "failed", 0, "", error="AI 서버 응답에 task_id 없음") + return + + remote_url = None + for _ in range(120): + time.sleep(5) + sr = requests.get(f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10) + sd = sr.json() + st = sd.get("status") + prog = sd.get("progress", 0) + msg = sd.get("message", "음악 생성 중...") + scaled = 30 + int(prog * 0.49) + webhook_update_task(task_id, "processing", scaled, msg) + + if st == "succeeded": + remote_url = sd.get("audio_url") + break + elif st == "failed": + webhook_update_task(task_id, "failed", 0, "", + error=sd.get("error", "AI 서버 생성 실패")) + return + + if not remote_url: + webhook_update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분)") + return + + webhook_update_task(task_id, "processing", 80, "파일 저장 중...") + filename = f"{task_id}.mp3" + os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True) + file_path = os.path.join(MUSIC_MEDIA_ROOT, filename) + + dl = requests.get(remote_url, timeout=120, stream=True) + with open(file_path, "wb") as f: + for chunk in dl.iter_content(chunk_size=8192): + f.write(chunk) + + audio_url = f"{MUSIC_MEDIA_BASE}/{filename}" + genre = params.get("genre", "") + moods = params.get("moods", []) + mood_str = moods[0] if moods else "Original" + title = params.get("title") or ( + f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix" + ) + + track = { + "title": title, + "genre": genre, + "moods": moods, + "instruments": params.get("instruments", []), + "duration_sec": params.get("duration_sec"), + "bpm": params.get("bpm"), + "key": params.get("key", ""), + "scale": params.get("scale", ""), + "prompt": params.get("prompt", ""), + "audio_url": audio_url, + "file_path": f"/app/data/{filename}", + "task_id": task_id, + "provider": "local", + } + webhook_add_track(task_id, "succeeded", 100, "생성 완료", + audio_url=audio_url, track=track) + + except requests.Timeout: + webhook_update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃") + except Exception as e: + logger.exception("local generation error task=%s", task_id) + webhook_update_task(task_id, "failed", 0, "", error=str(e)) +``` + +- [ ] **Step 2: 임포트 smoke 테스트** + +Run: `python -c "from providers.local import run_local_generation; print('OK')"` +Expected: `OK`. + +- [ ] **Step 3: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/music-render/providers/local.py +git commit -m "$(cat <<'EOF' +feat(music-render): providers/local.py — MusicGen client (SP-5) + +NAS music-lab/app/local_provider.py 이식. DB 호출 webhook 변환. +MusicGen 호스트는 host.docker.internal:8765 (Windows native). +결과 MP3는 /mnt/nas/webpage/data/music/에 직접 저장. +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Windows music-render — providers/sync_ops.py (sync Suno API 4종) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/sync_ops.py` + +NAS music-lab의 `generate_lyrics`, `get_credits`, `get_timestamped_lyrics`, `generate_style_boost` 동기 호출을 그대로 이식. + +- [ ] **Step 1: `sync_ops.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/providers/sync_ops.py`: + +```python +"""Sync Suno API helpers — main.py FastAPI sync endpoints에서 호출. + +NAS music-lab/app/suno_provider.py의 sync 함수들 이식. +""" +from __future__ import annotations + +import logging +import os +import time +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +SUNO_BASE_URL = "https://api.sunoapi.org/api/v1" +SUNO_API_KEY = os.getenv("SUNO_API_KEY", "") + + +def _headers() -> dict: + return { + "Authorization": f"Bearer {SUNO_API_KEY}", + "Content-Type": "application/json", + } + + +def generate_lyrics(prompt: str) -> Optional[dict]: + """Suno 가사 생성 API — 폴링 결과 반환.""" + if not SUNO_API_KEY: + return None + try: + resp = requests.post( + f"{SUNO_BASE_URL}/lyrics", + headers=_headers(), + json={"prompt": prompt[:200]}, + timeout=30, + ) + if resp.status_code != 200: + return None + body = resp.json() + if body.get("code") != 200: + return body + task_id = body.get("data", {}).get("taskId", "") + if not task_id: + return body + return _poll_lyrics(task_id) + except Exception as e: + logger.warning("Suno lyrics API error: %s", e) + return None + + +def _poll_lyrics(lyrics_task_id: str) -> Optional[dict]: + for _ in range(15): + time.sleep(3) + try: + resp = requests.get( + f"{SUNO_BASE_URL}/lyrics/record-info", + headers=_headers(), + params={"taskId": lyrics_task_id}, + timeout=15, + ) + if resp.status_code != 200: + continue + body = resp.json() + data = body.get("data", {}) + if data.get("status") == "complete": + items = data.get("data") or data.get("sunoData") or [] + if items and isinstance(items, list): + return { + "id": lyrics_task_id, + "status": "complete", + "text": items[0].get("text", ""), + "title": items[0].get("title", ""), + } + return {"id": lyrics_task_id, "status": "complete", "text": ""} + except Exception: + continue + return None + + +def get_credits() -> Optional[dict]: + if not SUNO_API_KEY: + return None + for path in ["/generate/credit", "/get-credits"]: + try: + resp = requests.get(f"{SUNO_BASE_URL}{path}", headers=_headers(), timeout=15) + if resp.status_code == 200: + body = resp.json() + data = body.get("data", body) + if isinstance(data, (int, float)): + return {"credits_left": int(data)} + return data + except Exception as e: + logger.warning("Suno credits API error (%s): %s", path, e) + return None + + +def get_timestamped_lyrics(suno_task_id: str, suno_id: str) -> Optional[dict]: + if not SUNO_API_KEY: + return None + try: + resp = requests.post( + f"{SUNO_BASE_URL}/generate/get-timestamped-lyrics", + headers=_headers(), + json={"taskId": suno_task_id, "audioId": suno_id}, + timeout=30, + ) + if resp.status_code == 200: + body = resp.json() + return body.get("data", body) + except Exception as e: + logger.warning("Timestamped lyrics error: %s", e) + return None + + +def generate_style_boost(content: str) -> Optional[dict]: + if not SUNO_API_KEY: + return None + try: + resp = requests.post( + f"{SUNO_BASE_URL}/style/generate", + headers=_headers(), + json={"content": content}, + timeout=30, + ) + if resp.status_code == 200: + body = resp.json() + return body.get("data", body) + except Exception as e: + logger.warning("Style boost error: %s", e) + return None +``` + +- [ ] **Step 2: 임포트 smoke 테스트** + +Run: `python -c "from providers.sync_ops import generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost; print('OK')"` +Expected: `OK`. + +- [ ] **Step 3: 커밋** + +```bash +git add services/music-render/providers/sync_ops.py +git commit -m "$(cat <<'EOF' +feat(music-render): providers/sync_ops.py — sync Suno helpers (SP-5) + +NAS sync 함수 4종 이식: generate_lyrics, get_credits, +get_timestamped_lyrics, generate_style_boost. +NAS main.py가 httpx로 forward하여 호출. +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Windows music-render — worker.py (Redis BLPOP + job_type 디스패처) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/worker.py` +- Test: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_worker.py` + +큐 단일 (`queue:music-render`)에 모든 job 종류가 들어옴. payload의 `job_type` 필드로 디스패치. + +- [ ] **Step 1: 실패하는 테스트 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/tests/test_worker.py`: + +```python +"""worker.py — job_type 디스패처 + paused 체크.""" +import json +import pytest +from unittest.mock import MagicMock, patch + +import worker + + +def test_dispatch_suno_generation_calls_run_suno_generation(): + payload = { + "task_id": "t1", + "job_type": "suno_generation", + "params": {"genre": "lofi", "title": "x"}, + } + with patch("worker.run_suno_generation") as m: + worker._dispatch(payload) + m.assert_called_once_with("t1", {"genre": "lofi", "title": "x"}) + + +def test_dispatch_local_generation_calls_run_local_generation(): + payload = { + "task_id": "t2", + "job_type": "local_generation", + "params": {"genre": "ambient"}, + } + with patch("worker.run_local_generation") as m: + worker._dispatch(payload) + m.assert_called_once_with("t2", {"genre": "ambient"}) + + +def test_dispatch_unknown_job_type_logs_error(): + payload = {"task_id": "t3", "job_type": "weird_type", "params": {}} + with patch("worker.webhook_update_task") as m: + worker._dispatch(payload) + # 알 수 없는 job_type은 failed로 보고 + m.assert_called_once() + args = m.call_args[0] + assert args[0] == "t3" + assert args[1] == "failed" + + +def test_dispatch_suno_extend_calls_run_suno_extend(): + payload = {"task_id": "t4", "job_type": "suno_extend", "params": {"suno_id": "abc"}} + with patch("worker.run_suno_extend") as m: + worker._dispatch(payload) + m.assert_called_once_with("t4", {"suno_id": "abc"}) + + +def test_dispatch_vocal_removal_calls_run_vocal_removal(): + payload = {"task_id": "t5", "job_type": "vocal_removal", "params": {"suno_id": "abc"}} + with patch("worker.run_vocal_removal") as m: + worker._dispatch(payload) + m.assert_called_once_with("t5", {"suno_id": "abc"}) + + +def test_dispatch_cover_image_calls_run_cover_image(): + payload = {"task_id": "t6", "job_type": "cover_image", "params": {"suno_task_id": "x"}} + with patch("worker.run_cover_image") as m: + worker._dispatch(payload) + m.assert_called_once_with("t6", {"suno_task_id": "x"}) + + +def test_dispatch_wav_convert_calls_run_wav_convert(): + payload = {"task_id": "t7", "job_type": "wav_convert", "params": {"suno_task_id": "x", "suno_id": "y"}} + with patch("worker.run_wav_convert") as m: + worker._dispatch(payload) + m.assert_called_once_with("t7", {"suno_task_id": "x", "suno_id": "y"}) + + +def test_dispatch_stem_split_calls_run_stem_split(): + payload = {"task_id": "t8", "job_type": "stem_split", "params": {"suno_task_id": "x", "suno_id": "y"}} + with patch("worker.run_stem_split") as m: + worker._dispatch(payload) + m.assert_called_once_with("t8", {"suno_task_id": "x", "suno_id": "y"}) + + +def test_dispatch_video_generate_calls_run_video_generate(): + payload = {"task_id": "t9", "job_type": "video_generate", "params": {"suno_task_id": "x", "suno_id": "y"}} + with patch("worker.run_video_generate") as m: + worker._dispatch(payload) + m.assert_called_once_with("t9", {"suno_task_id": "x", "suno_id": "y"}) + + +def test_dispatch_upload_cover_calls_run_upload_cover(): + payload = {"task_id": "t10", "job_type": "upload_cover", "params": {"upload_url": "u"}} + with patch("worker.run_upload_cover") as m: + worker._dispatch(payload) + m.assert_called_once_with("t10", {"upload_url": "u"}) + + +def test_dispatch_upload_extend_calls_run_upload_extend(): + payload = {"task_id": "t11", "job_type": "upload_extend", "params": {"upload_url": "u"}} + with patch("worker.run_upload_extend") as m: + worker._dispatch(payload) + m.assert_called_once_with("t11", {"upload_url": "u"}) + + +def test_dispatch_add_vocals_calls_run_add_vocals(): + payload = {"task_id": "t12", "job_type": "add_vocals", "params": {"upload_url": "u"}} + with patch("worker.run_add_vocals") as m: + worker._dispatch(payload) + m.assert_called_once_with("t12", {"upload_url": "u"}) + + +def test_dispatch_add_instrumental_calls_run_add_instrumental(): + payload = {"task_id": "t13", "job_type": "add_instrumental", "params": {"upload_url": "u"}} + with patch("worker.run_add_instrumental") as m: + worker._dispatch(payload) + m.assert_called_once_with("t13", {"upload_url": "u"}) +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `python -m pytest tests/test_worker.py -v` +Expected: FAIL — `worker` 모듈 미존재. + +- [ ] **Step 3: `worker.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/worker.py`: + +```python +"""Redis BLPOP worker — queue:music-render → job_type 디스패치 → NAS webhook. + +queue:paused 가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any + +import redis.asyncio as aioredis + +from nas_client import webhook_update_task +from providers.suno import ( + run_suno_generation, run_suno_extend, run_vocal_removal, + run_cover_image, run_wav_convert, run_stem_split, + run_upload_cover, run_upload_extend, run_add_vocals, + run_add_instrumental, run_video_generate, +) +from providers.local import run_local_generation + +logger = logging.getLogger(__name__) + +REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379") +QUEUE_KEY = "queue:music-render" +PAUSED_KEY = "queue:paused" + +_DISPATCH_TABLE = { + "suno_generation": run_suno_generation, + "local_generation": run_local_generation, + "suno_extend": run_suno_extend, + "vocal_removal": run_vocal_removal, + "cover_image": run_cover_image, + "wav_convert": run_wav_convert, + "stem_split": run_stem_split, + "upload_cover": run_upload_cover, + "upload_extend": run_upload_extend, + "add_vocals": run_add_vocals, + "add_instrumental": run_add_instrumental, + "video_generate": run_video_generate, +} + + +def _dispatch(payload: dict) -> None: + """payload[job_type] → provider 함수 호출 (sync, asyncio.to_thread로 래핑).""" + job_type = payload.get("job_type", "") + task_id = payload.get("task_id", "") + params = payload.get("params", {}) + fn = _DISPATCH_TABLE.get(job_type) + if fn is None: + logger.error("unknown job_type=%s task=%s", job_type, task_id) + webhook_update_task(task_id, "failed", 0, "", error=f"unknown job_type: {job_type}") + return + fn(task_id, params) + + +async def worker_loop(): + redis = aioredis.from_url(REDIS_URL, decode_responses=False) + logger.info("music-render worker started (queue=%s)", QUEUE_KEY) + while True: + try: + paused = await redis.get(PAUSED_KEY) + if paused == b"1": + await asyncio.sleep(10) + continue + item = await redis.blpop(QUEUE_KEY, timeout=1) + if item is None: + continue + _, raw = item + try: + payload = json.loads(raw) + except json.JSONDecodeError: + logger.error("invalid queue payload: %r", raw[:200]) + continue + # sync provider 함수 — thread로 실행해서 이벤트 루프 블로킹 방지 + await asyncio.to_thread(_dispatch, payload) + except asyncio.CancelledError: + logger.info("worker_loop cancelled") + raise + except Exception: + logger.exception("worker_loop iteration 실패, 5초 후 재시도") + await asyncio.sleep(5) +``` + +- [ ] **Step 4: 테스트 통과** + +Run: `python -m pytest tests/test_worker.py -v` +Expected: 13 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add services/music-render/worker.py services/music-render/tests/test_worker.py +git commit -m "$(cat <<'EOF' +feat(music-render): worker.py — Redis BLPOP + 12 job_type dispatch (SP-5) + +queue:music-render BLPOP, queue:paused 체크 후 job_type별 provider 호출. +sync provider는 asyncio.to_thread로 래핑 (이벤트 루프 블로킹 방지). +12 job_types (suno_*, local_*, vocal_removal, cover_image, wav_convert, +stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, +video_generate). +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Windows music-render — main.py (FastAPI + lifespan + sync endpoints) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/main.py` + +- [ ] **Step 1: `main.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/music-render/main.py`: + +```python +"""music-render FastAPI entry — health + lifespan + sync forward endpoints. + +NAS music-lab이 sync helpers(lyrics, credits, timestamped, style-boost)를 +httpx로 forward해서 이 endpoint들을 호출. +""" +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +import worker +from providers.sync_ops import ( + generate_lyrics, get_credits, + get_timestamped_lyrics, generate_style_boost, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + worker_task = asyncio.create_task(worker.worker_loop()) + logger.info("music-render lifespan 시작") + try: + yield + finally: + worker_task.cancel() + try: + await worker_task + except asyncio.CancelledError: + pass + logger.info("music-render lifespan 종료") + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/health") +def health(): + return {"ok": True, "service": "music-render"} + + +# ── Sync forward endpoints ────────────────────────────────────────────── +# NAS music-lab의 /api/music/lyrics 등 sync helpers가 이 endpoint들로 forward. + +class LyricsRequest(BaseModel): + prompt: str + + +@app.post("/api/music-render/sync/lyrics") +def sync_lyrics(req: LyricsRequest): + result = generate_lyrics(req.prompt) + if not result: + raise HTTPException(502, "가사 생성 실패") + return result + + +@app.get("/api/music-render/sync/credits") +def sync_credits(): + result = get_credits() + if result is None: + raise HTTPException(502, "크레딧 조회 실패") + return result + + +@app.get("/api/music-render/sync/timestamped-lyrics") +def sync_timestamped_lyrics(task_id: str, suno_id: str): + result = get_timestamped_lyrics(task_id, suno_id) + if not result: + raise HTTPException(502, "타임스탬프 가사 조회 실패") + return result + + +class StyleBoostRequest(BaseModel): + content: str + + +@app.post("/api/music-render/sync/style-boost") +def sync_style_boost(req: StyleBoostRequest): + result = generate_style_boost(req.content) + if not result: + raise HTTPException(502, "스타일 부스트 생성 실패") + return result +``` + +- [ ] **Step 2: 임포트 smoke 테스트** + +Run: `python -c "from main import app; print(len(app.routes))"` +Expected: 숫자 출력 (>= 5 — health + sync 4종). + +- [ ] **Step 3: 커밋** + +```bash +git add services/music-render/main.py +git commit -m "$(cat <<'EOF' +feat(music-render): main.py — FastAPI + lifespan + sync endpoints (SP-5) + +lifespan에서 worker_loop 스폰. sync forward 4 endpoint: +/api/music-render/sync/{lyrics, credits, timestamped-lyrics, style-boost}. +NAS music-lab이 이 endpoint들을 httpx forward로 호출. +Plan-B-Music Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Windows services/docker-compose — music-render 추가 + 박재오 빌드 + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml` + +- [ ] **Step 1: `docker-compose.yml`에 music-render 서비스 추가** + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml` 끝(insta-render 아래)에 추가: + +```yaml + + music-render: + build: + context: ./music-render + container_name: music-render + restart: unless-stopped + ports: + - "18711:8000" + environment: + - TZ=Asia/Seoul + - REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379} + - NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18600} + - INTERNAL_API_KEY=${INTERNAL_API_KEY:-} + - SUNO_API_KEY=${SUNO_API_KEY:-} + - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-http://host.docker.internal:8765} + - MUSIC_MEDIA_ROOT=${MUSIC_MEDIA_ROOT:-/mnt/nas/webpage/data/music} + - MUSIC_MEDIA_URL_PREFIX=${MUSIC_MEDIA_URL_PREFIX:-/media/music} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - /mnt/nas/webpage/data/music:/mnt/nas/webpage/data/music + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 60s + timeout: 5s + retries: 3 +``` + +- [ ] **Step 2: 박재오에게 `.env` 작성 요청 (구두 가이드)** + +박재오 Windows AI 머신 (WSL2): +```bash +cd /workspace/web-ai/services/music-render +cp .env.example .env +# .env 편집: +# INTERNAL_API_KEY = NAS .env의 동일값 복사 +# SUNO_API_KEY = NAS .env에서 옮겨오는 값 +nano .env +``` + +플랜 실행자는 박재오에게 .env 작성 완료 확인 후 다음 step 진행. + +- [ ] **Step 3: 박재오 머신에서 빌드 + 시작** + +박재오 WSL2: +```bash +cd /workspace/web-ai/services +docker compose build music-render +docker compose up -d music-render +docker compose logs -f music-render +``` + +Expected logs: +``` +music-render | INFO ... music-render lifespan 시작 +music-render | INFO ... music-render worker started (queue=queue:music-render) +``` + +- [ ] **Step 4: 헬스체크 확인** + +박재오 WSL2: +```bash +curl http://localhost:18711/health +``` +Expected: `{"ok": true, "service": "music-render"}` + +NAS SMB write 확인 (worker가 컨테이너에서 mount 접근 가능한지): +```bash +docker exec music-render ls -la /mnt/nas/webpage/data/music | head +``` +Expected: 에러 없이 디렉토리 리스트 출력. + +- [ ] **Step 5: 커밋 + push** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/docker-compose.yml +git commit -m "$(cat <<'EOF' +feat(music-render): services/docker-compose에 music-render 서비스 (SP-5) + +포트 18711, REDIS_URL/NAS_BASE_URL/INTERNAL_API_KEY/SUNO_API_KEY/MUSIC_AI_SERVER_URL env. +host.docker.internal 매핑(MusicGen native 호스트). +SMB /mnt/nas/webpage/data/music 마운트. +Plan-B-Music Phase 2 — 빌드 + 박재오 머신 시작 확인. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +git push +``` + +--- + +## Task 11: NAS music-lab — main.py background_tasks → Redis push (13 endpoint) + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py` — 13개 endpoint의 `background_tasks.add_task(run_*, ...)` 호출을 Redis push로 변경 + +각 endpoint별로 동일 패턴 적용: `task_id` 생성 + `create_task` + `redis_client.rpush("queue:music-render", json.dumps(payload))` + 응답. + +helper 함수 1개를 추가하여 DRY 유지. + +- [ ] **Step 1: `main.py` import 블록 끝(internal_router import 다음 줄)에 helper 추가** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py`에서 `app = FastAPI()` 위에 추가: + +```python +async def _push_render_job(task_id: str, job_type: str, params: dict) -> None: + """Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리.""" + from datetime import datetime, timezone, timedelta + kst = timezone(timedelta(hours=9)) + payload = { + "task_id": task_id, + "kind": "music", + "job_type": job_type, + "params": params, + "submitted_at": datetime.now(kst).isoformat(), + } + await redis_client.rpush("queue:music-render", json.dumps(payload)) +``` + +(`json` import는 이미 line 1에 있음.) + +- [ ] **Step 2: `/api/music/generate` 변경** + +`generate_music` 함수(현재 line 132-154) 본문 변경: + +```python +@app.post("/api/music/generate") +async def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): + """음악 생성 작업 — Redis 큐로 Windows music-render에 위임.""" + provider = req.provider + # SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X). + # 실패 시 worker가 webhook으로 failed 보고. + if provider not in ("suno", "local"): + raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}") + if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"): + # 이 env는 NAS에는 더 이상 없지만 사용자 친화 검증으로 유지 — 실제 호출은 Windows + pass + + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider=provider) + job_type = "suno_generation" if provider == "suno" else "local_generation" + await _push_render_job(task_id, job_type, params) + return {"task_id": task_id, "provider": provider} +``` + +(주의: `async def`로 변경 + `await _push_render_job(...)`.) + +- [ ] **Step 3: `/api/music/extend` 변경** + +`extend_music`(현재 line 397-406) 본문 변경: + +```python +@app.post("/api/music/extend") +async def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "suno_extend", params) + return {"task_id": task_id, "provider": "suno"} +``` + +- [ ] **Step 4: `/api/music/vocal-removal` 변경** + +`vocal_removal` 본문 (line 417-426): + +```python +@app.post("/api/music/vocal-removal") +async def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "vocal_removal", params) + return {"task_id": task_id, "provider": "suno"} +``` + +- [ ] **Step 5: `/api/music/cover-image`, `/api/music/wav`, `/api/music/stem-split` 변경** + +각각: + +```python +@app.post("/api/music/cover-image") +async def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "cover_image", params) + return {"task_id": task_id, "provider": "suno"} + + +@app.post("/api/music/wav") +async def wav_convert(req: WavRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "wav_convert", params) + return {"task_id": task_id, "provider": "suno"} + + +@app.post("/api/music/stem-split") +async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "stem_split", params) + return {"task_id": task_id, "provider": "suno"} +``` + +- [ ] **Step 6: `/api/music/upload-cover`, `/api/music/upload-extend`, `/api/music/add-vocals`, `/api/music/add-instrumental`, `/api/music/video` 변경** + +```python +@app.post("/api/music/upload-cover") +async def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "upload_cover", params) + return {"task_id": task_id, "provider": "suno"} + + +@app.post("/api/music/upload-extend") +async def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "upload_extend", params) + return {"task_id": task_id, "provider": "suno"} + + +@app.post("/api/music/add-vocals") +async def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "add_vocals", params) + return {"task_id": task_id, "provider": "suno"} + + +@app.post("/api/music/add-instrumental") +async def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "add_instrumental", params) + return {"task_id": task_id, "provider": "suno"} + + +@app.post("/api/music/video") +async def video_generate(req: VideoRequest, background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + params = req.model_dump() + create_task(task_id, params, provider="suno") + await _push_render_job(task_id, "video_generate", params) + return {"task_id": task_id, "provider": "suno"} +``` + +- [ ] **Step 7: smoke test — import + endpoint 라우트 확인** + +Run: `cd music-lab && python -c "from app.main import app; print([r.path for r in app.routes if hasattr(r, 'path')])"` +Expected: `/api/music/generate`, `/api/music/extend`, ... 13 endpoint 모두 포함. + +- [ ] **Step 8: 커밋** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git add music-lab/app/main.py +git commit -m "$(cat <<'EOF' +refactor(music-lab): 13 background_tasks → Redis push (SP-6) + +generate, extend, vocal-removal, cover-image, wav, stem-split, +upload-cover, upload-extend, add-vocals, add-instrumental, video +모두 _push_render_job 헬퍼로 queue:music-render에 push. +job_type 디스크리미네이터로 Windows worker가 분기. +Plan-B-Music Phase 3 (cutover 1/4). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: NAS music-lab — sync helpers를 Windows HTTP forward로 + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/sync_forward.py` +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/main.py` — sync endpoint 4종(lyrics, credits, timestamped, style-boost)이 sync_forward 호출 + +- [ ] **Step 1: `sync_forward.py` 작성** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/sync_forward.py`: + +```python +"""SP-6 sync helpers forward — NAS → Windows music-render. + +NAS music-lab의 /api/music/lyrics, /api/music/credits, +/api/music/timestamped-lyrics, /api/music/style-boost 호출을 +Windows music-render의 /api/music-render/sync/* 로 forward. + +SUNO_API_KEY는 NAS에 없으므로 NAS에서 직접 호출 불가. +""" +from __future__ import annotations + +import logging +import os +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +MUSIC_RENDER_URL = os.getenv("MUSIC_RENDER_URL", "http://192.168.45.59:18711") +_TIMEOUT = 60.0 # 가사 생성은 폴링 포함 ~45초 + + +def forward_lyrics(prompt: str) -> Optional[dict]: + try: + r = httpx.post( + f"{MUSIC_RENDER_URL}/api/music-render/sync/lyrics", + json={"prompt": prompt}, + timeout=_TIMEOUT, + ) + if r.status_code == 200: + return r.json() + logger.warning("forward_lyrics returned %d", r.status_code) + except Exception: + logger.exception("forward_lyrics 실패") + return None + + +def forward_credits() -> Optional[dict]: + try: + r = httpx.get( + f"{MUSIC_RENDER_URL}/api/music-render/sync/credits", + timeout=30.0, + ) + if r.status_code == 200: + return r.json() + except Exception: + logger.exception("forward_credits 실패") + return None + + +def forward_timestamped_lyrics(task_id: str, suno_id: str) -> Optional[dict]: + try: + r = httpx.get( + f"{MUSIC_RENDER_URL}/api/music-render/sync/timestamped-lyrics", + params={"task_id": task_id, "suno_id": suno_id}, + timeout=30.0, + ) + if r.status_code == 200: + return r.json() + except Exception: + logger.exception("forward_timestamped_lyrics 실패") + return None + + +def forward_style_boost(content: str) -> Optional[dict]: + try: + r = httpx.post( + f"{MUSIC_RENDER_URL}/api/music-render/sync/style-boost", + json={"content": content}, + timeout=30.0, + ) + if r.status_code == 200: + return r.json() + except Exception: + logger.exception("forward_style_boost 실패") + return None +``` + +- [ ] **Step 2: `main.py` — sync endpoint 4종 변경** + +`main.py`의 `gen_lyrics` (현재 line 190-198), `check_credits` (line 374-381), `timestamped_lyrics` (line 491-498), `style_boost` (line 508-515) 변경: + +```python +@app.post("/api/music/lyrics") +def gen_lyrics(req: LyricsRequest): + """Suno AI 가사 생성 — Windows music-render로 forward.""" + from .sync_forward import forward_lyrics + result = forward_lyrics(req.prompt) + if not result: + raise HTTPException(status_code=502, detail="가사 생성 실패 (Windows worker 응답 없음)") + return result + + +@app.get("/api/music/credits") +def check_credits(): + """Suno 잔여 크레딧 조회 — Windows music-render로 forward.""" + from .sync_forward import forward_credits + result = forward_credits() + if result is None: + raise HTTPException(status_code=502, detail="크레딧 조회 실패") + return result + + +@app.get("/api/music/timestamped-lyrics") +def timestamped_lyrics(task_id: str, suno_id: str): + """타임스탬프 가사 — Windows music-render로 forward.""" + from .sync_forward import forward_timestamped_lyrics + result = forward_timestamped_lyrics(task_id, suno_id) + if not result: + raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패") + return result + + +@app.post("/api/music/style-boost") +def style_boost(req: StyleBoostRequest): + """스타일 부스트 — Windows music-render로 forward.""" + from .sync_forward import forward_style_boost + result = forward_style_boost(req.content) + if not result: + raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패") + return result +``` + +`SUNO_API_KEY` 검증 라인은 제거 (모두 Windows로 위임). `from .suno_provider import (...)` import 라인에서 `generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost, SUNO_API_KEY`는 더 이상 필요 없음. 다만 `SUNO_MODELS`는 `/api/music/models` endpoint에서 사용하므로 유지. import 정리: + +기존 (line 31-37): +```python +from .suno_provider import ( + run_suno_generation, run_suno_extend, run_vocal_removal, + run_cover_image, run_wav_convert, run_stem_split, + run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate, + generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost, + SUNO_API_KEY, SUNO_MODELS, +) +``` +→ 변경: +```python +from .suno_provider import SUNO_MODELS +``` + +(`run_*` 함수들은 Task 11에서 `_push_render_job`로 대체했으므로 import 불필요.) + +또한 `/api/music/providers` endpoint(line 86-104)의 `if SUNO_API_KEY:` 체크는 항상 True 가정 (Windows로 이전됨). 변경: + +```python +@app.get("/api/music/providers") +def get_providers(): + providers = [] + if os.getenv("MUSIC_AI_SERVER_URL"): + providers.append({ + "id": "local", "name": "MusicGen", + "description": "로컬 AI 서버 (인스트루멘탈 전용)", + "features": ["instrumental"], + }) + # SUNO는 Windows music-render에서 처리 — 항상 가용 (Suno 키 누락 시 worker가 failed 보고) + providers.append({ + "id": "suno", "name": "Suno", + "description": "Suno AI (보컬·가사·인스트루멘탈)", + "features": ["vocals", "lyrics", "instrumental"], + }) + return {"providers": providers} +``` + +`local_provider`도 main.py에서 import 안 함 (`run_local_generation`도 미사용). 기존 line 28 `from .local_provider import run_local_generation`를 제거. + +- [ ] **Step 3: import smoke test** + +Run: `cd music-lab && python -c "from app.main import app; print('OK')"` +Expected: `OK`. + +- [ ] **Step 4: 커밋** + +```bash +git add music-lab/app/sync_forward.py music-lab/app/main.py +git commit -m "$(cat <<'EOF' +refactor(music-lab): sync helpers → Windows HTTP forward (SP-6) + +/api/music/{lyrics, credits, timestamped-lyrics, style-boost} +모두 sync_forward 모듈로 위임 → Windows :18711/api/music-render/sync/*. +SUNO_API_KEY가 NAS에 없으므로 직접 호출 불가. +run_*, generate_*, get_* import 제거 (Windows로 이전됨). +SUNO_MODELS만 잔존 (정적 데이터). +Plan-B-Music Phase 3 (cutover 2/4). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: NAS music-lab — batch_generator를 Redis push 호환으로 + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/batch_generator.py:103-148` (`_generate_one_track` 함수) + +기존 `_generate_one_track`은 `run_suno_generation`을 직접 호출하지만, 그 함수는 이제 NAS에서 사라짐. Redis push 후 task 상태 polling 패턴으로 변경. + +- [ ] **Step 1: `_generate_one_track` 재작성** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/batch_generator.py`의 `_generate_one_track` 함수 전체 교체: + +```python +async def _generate_one_track(*, title: str, genre: str, duration_sec: int, + params: dict) -> int | None: + """Redis 큐에 push + task 상태 polling. 성공 시 새 track id, 실패 시 None.""" + import json + from datetime import datetime, timezone, timedelta + from .main import redis_client # 같은 컨테이너 — 동일 redis 클라이언트 공유 + + task_id = str(uuid.uuid4()) + suno_params = { + "title": title, + "genre": genre, + "moods": params["moods"], + "instruments": params["instruments"], + "duration_sec": duration_sec, + "bpm": params["bpm"], + "key": params["key"], + "scale": params["scale"], + "prompt": params.get("prompt_modifier", ""), + "provider": "suno", + "model": "V4", + "instrumental": False, + "lyrics": "", + } + db.create_task(task_id, suno_params, provider="suno") + + # Redis push (Windows music-render가 BLPOP 처리) + kst = timezone(timedelta(hours=9)) + payload = { + "task_id": task_id, + "kind": "music", + "job_type": "suno_generation", + "params": suno_params, + "submitted_at": datetime.now(kst).isoformat(), + } + await redis_client.rpush("queue:music-render", json.dumps(payload)) + + waited = 0 + while waited < TRACK_GEN_TIMEOUT_S: + await asyncio.sleep(POLL_INTERVAL_S) + waited += POLL_INTERVAL_S + task = db.get_task(task_id) + if not task: + continue + status = task.get("status") + if status == "succeeded": + # Windows webhook이 add_track 했으므로 task_id로 검색 + track = db.get_track_by_task_id(task_id) + if track: + return track.get("id") + return None + if status == "failed": + return None + return None # timeout +``` + +(주의: `from .suno_provider import run_suno_generation` import 제거, `asyncio.create_task(asyncio.to_thread(...))` 제거.) + +- [ ] **Step 2: import smoke test** + +Run: `cd music-lab && python -c "from app.batch_generator import run_batch; print('OK')"` +Expected: `OK`. + +- [ ] **Step 3: 커밋** + +```bash +git add music-lab/app/batch_generator.py +git commit -m "$(cat <<'EOF' +refactor(music-lab): batch_generator _generate_one_track → Redis push (SP-6) + +기존 직접 run_suno_generation 호출 + asyncio.to_thread를 +Redis push (queue:music-render, job_type=suno_generation) + +task 상태 polling 패턴으로 변경. 결과는 task_id로 music_library 조회. +Plan-B-Music Phase 3 (cutover 3/4). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: NAS music-lab — suno_provider/local_provider stub + Dockerfile 슬림화 + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/suno_provider.py` (전체 교체) +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/local_provider.py` (전체 교체) + +- [ ] **Step 1: `suno_provider.py` 전체 교체 (stub만 남김)** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/suno_provider.py` 전체를 다음으로 교체: + +```python +"""DEPRECATED 2026-05-19 — Suno API 호출 코드는 모두 Windows music-render로 이전. + +기존 13 함수 (run_suno_generation, run_suno_extend, ...)는 +web-ai/services/music-render/providers/suno.py로 이식됨. +NAS는 Redis push (queue:music-render)만 담당. + +SUNO_MODELS는 frontend 응답용 정적 데이터만 잔존. +""" +from __future__ import annotations + +SUNO_API_KEY = "" # NAS에서 더 이상 보유 X — sentinel 유지 (다른 import 호환) + +SUNO_MODELS = [ + {"id": "V4", "name": "V4", "max_duration": "4분", "description": "안정적 품질, 빠른 생성"}, + {"id": "V4_5", "name": "V4.5", "max_duration": "8분", "description": "향상된 장르 블렌딩"}, + {"id": "V4_5PLUS", "name": "V4.5+", "max_duration": "8분", "description": "강화된 음악성"}, + {"id": "V4_5ALL", "name": "V4.5 All", "max_duration": "8분", "description": "더 나은 곡 구조"}, + {"id": "V5", "name": "V5", "max_duration": "8분", "description": "최신, 빠른 생성"}, + {"id": "V5_5", "name": "V5.5", "max_duration": "8분", "description": "커스텀 모델"}, +] +``` + +- [ ] **Step 2: `local_provider.py` 전체 교체 (stub만 남김)** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/music-lab/app/local_provider.py` 전체를 다음으로 교체: + +```python +"""DEPRECATED 2026-05-19 — MusicGen 호출은 Windows music-render로 이전. + +기존 run_local_generation은 web-ai/services/music-render/providers/local.py로 이식. +NAS는 Redis push (queue:music-render, job_type=local_generation)만 담당. +""" +``` + +- [ ] **Step 3: 다른 모듈의 dangling import 확인** + +Run: `cd music-lab && grep -rn "from .suno_provider import\|from .local_provider import\|from app.suno_provider\|from app.local_provider" app/ tests/` +Expected: `app/main.py`에서 `SUNO_MODELS`만 import, `app/batch_generator.py`에서 어떤 것도 import 안 함. + +만약 추가 dangling이 보이면 그 파일도 정리. (compiler.py나 video_producer.py 등 — 일반적으로 NAS local 처리이므로 영향 없음.) + +- [ ] **Step 4: import smoke test + pytest** + +Run: +```bash +cd music-lab +python -c "from app.main import app; print('OK')" +python -m pytest tests/test_auth.py tests/test_internal_router.py -v +``` +Expected: `OK` + 8 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add music-lab/app/suno_provider.py music-lab/app/local_provider.py +git commit -m "$(cat <<'EOF' +refactor(music-lab): suno_provider/local_provider → stub (SP-6) + +기존 13+1 외부 API 호출 함수는 web-ai/services/music-render/providers로 이식. +NAS는 SUNO_MODELS (정적 데이터)만 잔존. SUNO_API_KEY = "" sentinel. +Plan-B-Music Phase 3 (cutover 4/4). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 15: NAS push + deployer rebuild + 헬스 확인 + +**Files:** +- (변경 없음 — push + 원격 배포만) + +- [ ] **Step 1: 모든 NAS 변경 push** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git push +``` + +Expected: push 성공. 만약 자격증명 실패 시 박재오에게 수동 push 요청. + +- [ ] **Step 2: 박재오에게 NAS 컨테이너 rebuild 확인 요청** + +NAS의 Gitea webhook이 자동으로 deployer를 트리거. 박재오 측에서 NAS deployer 로그 확인: +```bash +ssh nas +docker logs webpage-deployer --tail 50 +``` +Expected: `music-lab` rebuild + restart 라인 확인. + +- [ ] **Step 3: 헬스 확인** + +박재오 NAS: +```bash +curl https://gahusb.synology.me/api/music/providers +``` +Expected: `{"providers": [{"id": "local", ...}, {"id": "suno", ...}]}` + +```bash +docker logs music-lab --tail 20 +``` +Expected: 시작 로그, `Application startup complete` 표시. + +```bash +docker exec music-lab python -c "from app.main import redis_client; import asyncio; print(asyncio.run(redis_client.ping()))" +``` +Expected: `True` (Redis 연결 확인). + +- [ ] **Step 4: SUNO_API_KEY 잔존 검사** + +박재오 NAS: +```bash +docker exec music-lab env | grep SUNO +``` +Expected: SUNO_API_KEY 없음 (또는 빈 문자열). + +- [ ] **Step 5: 커밋할 추가 변경 없으면 다음 task로 진행** + +--- + +## Task 16: nginx 3-layer 차단 /api/internal/music/ + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf` — `/api/internal/insta/` 블록 옆에 `/api/internal/music/` 추가 + +- [ ] **Step 1: `default.conf`에 차단 블록 추가** + +`C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf`의 `/api/internal/insta/` 블록(line 196-211) 바로 다음에 추가: + +```nginx + # Plan-B-Music — Windows music-render → NAS music-lab internal webhook + # Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale) + # Layer 3: X-Internal-Key (FastAPI dependency) + location /api/internal/music/ { + allow 192.168.45.0/24; # LAN 화이트리스트 + allow 100.64.0.0/10; # Tailscale CGNAT + allow 127.0.0.1; # NAS 내부 + deny all; + + resolver 127.0.0.11 valid=10s; + set $music_internal_backend music-lab:8000; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Internal-Key $http_x_internal_key; + proxy_pass http://$music_internal_backend$request_uri; + } +``` + +- [ ] **Step 2: nginx 설정 검증** + +Run: `grep -c "location /api/internal/" C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf` +Expected: `2` (insta + music). + +- [ ] **Step 3: NAS nginx reload (push 후 webhook 자동)** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git add nginx/default.conf +git commit -m "$(cat <<'EOF' +feat(nginx): /api/internal/music/ 3-layer 차단 (SP-6) + +LAN(192.168.45.0/24) + Tailscale(100.64.0.0/10) + 127.0.0.1 allow. +deny all. X-Internal-Key forward → music-lab:8000. +insta 블록과 동일 패턴. +Plan-B-Music Phase 4. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +git push +``` + +- [ ] **Step 4: NAS frontend(nginx) 재시작 확인** + +박재오 NAS: +```bash +docker compose -f /volume1/docker/webpage/docker-compose.yml restart frontend +docker logs frontend --tail 20 | grep -i music +``` +Expected: 시작 로그에 nginx 재로드 + `/api/internal/music/` 차단 활성화. + +- [ ] **Step 5: 차단 테스트 (외부에서 호출)** + +박재오 외부 PC(WAN)에서: +```bash +curl -X POST https://gahusb.synology.me/api/internal/music/update \ + -H "X-Internal-Key: anything" \ + -H "Content-Type: application/json" \ + -d '{"task_id":"x","status":"processing","progress":0}' +``` +Expected: HTTP 403 (nginx deny). + +박재오 LAN PC에서 (또는 NAS 내부): +```bash +curl -X POST http://192.168.45.54:8080/api/internal/music/update \ + -H "X-Internal-Key: WRONG" \ + -H "Content-Type: application/json" \ + -d '{"task_id":"x","status":"processing","progress":0}' +``` +Expected: HTTP 401 (FastAPI verify_internal_key 거부 — nginx 통과 확인). + +--- + +## Task 17: end-to-end Suno 1 트랙 생성 검증 + 최종 정리 + +**Files:** +- (변경 없음 — 검증만) + +- [ ] **Step 1: 박재오 머신에서 SUNO 1 트랙 생성 트리거** + +박재오 LAN: +```bash +curl -X POST https://gahusb.synology.me/api/music/generate \ + -H "Content-Type: application/json" \ + -d '{ + "provider": "suno", + "model": "V4", + "title": "Plan-B-Music 검증", + "genre": "lofi", + "moods": ["chill"], + "instruments": [], + "duration_sec": 60, + "prompt": "lofi chill background", + "lyrics": "", + "instrumental": true + }' +``` + +응답 예: `{"task_id": "xxxx-yyyy", "provider": "suno"}` + +- [ ] **Step 2: Windows worker 로그에서 처리 확인** + +박재오 WSL2: +```bash +docker logs music-render --tail 50 -f +``` +Expected: +``` +music-render | INFO ... Suno API 연결... task=xxxx +music-render | INFO ... 곡 생성 대기열에 등록... +music-render | INFO ... 폴링... attempt=1 status=PENDING +... +music-render | INFO ... 오디오 다운로드 중... +music-render | INFO ... webhook task=xxxx status=succeeded +``` + +- [ ] **Step 3: NAS DB 상태 확인 (폴링)** + +박재오: +```bash +curl https://gahusb.synology.me/api/music/status/xxxx-yyyy +``` +Expected: `{"status": "succeeded", "progress": 100, "audio_url": "/media/music/xxxx-yyyy.mp3", "track": {...}}` + +- [ ] **Step 4: 파일 존재 확인** + +박재오 NAS: +```bash +ls -la /volume1/docker/webpage/data/music/xxxx-yyyy.mp3 +``` +Expected: 파일 존재, 크기 > 0. + +브라우저: `https://gahusb.synology.me/media/music/xxxx-yyyy.mp3` 직접 재생 시도. + +- [ ] **Step 5: Sync forward 검증 (Suno 크레딧 조회)** + +박재오: +```bash +curl https://gahusb.synology.me/api/music/credits +``` +Expected: `{"credits_left": <숫자>}` 또는 Suno API 응답 데이터. + +- [ ] **Step 6: 모든 NAS·web-ai 변경 push 최종 확인** + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -10 +cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -10 +``` +Expected: 두 저장소 모두 clean + 최신 commits push 완료. + +- [ ] **Step 7: 메모리에 기록 (Plan-B-Music 검증 완료)** + +`C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/project_signal_v2.md`는 무관. 새 reference 메모리 추가: + +`reference_plan_b_music_complete.md`: +```markdown +--- +name: plan-b-music-complete +description: 2026-05-19 Plan-B-Music 완료 — NAS music-lab → Windows music-render Redis 큐 + sync forward 구조 +metadata: + type: reference +--- + +Plan-B-Music 2026-05-19 완료. 검증 결과: Suno 1 트랙 end-to-end 성공. + +**구조:** +- async 13 endpoint: NAS music-lab → Redis RPUSH `queue:music-render` (job_type discriminator) → Windows music-render worker → SMB direct write → webhook `POST /api/internal/music/update` +- sync 4 endpoint: NAS music-lab → httpx → Windows `POST/GET /api/music-render/sync/{op}` 직접 forward +- SUNO_API_KEY: NAS `.env`에서 완전 제거 → Windows `.env` 단독 보유 + +**파일 위치:** +- web-ai/services/music-render/providers/{suno,local,sync_ops}.py +- web-ai/services/music-render/nas_client.py (webhook adapter) +- web-backend/music-lab/app/{auth,internal_router,sync_forward}.py + +**job_type 13종:** suno_generation, local_generation, suno_extend, vocal_removal, cover_image, wav_convert, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate + +**다음 plan:** Plan-B-Video (SP-7+SP-8) — 같은 패턴 복제. +``` + +MEMORY.md에 한 줄 추가: +```markdown +- [Plan-B-Music 완료](reference_plan_b_music_complete.md) — NAS music-lab → Windows music-render Redis 큐 + sync forward, SUNO_API_KEY 완전 이전 +``` + +- [ ] **Step 8: Task list 정리** + +박재오의 TaskCreate/Update에서 #69~#85 Plan-B-Music task들을 모두 completed로 마크. + +박재오에게 보고: +``` +Plan-B-Music 완료: +- 17 task 모두 completed +- Suno end-to-end 검증 성공 (task_id=xxxx, 곡 N초) +- SUNO_API_KEY NAS에서 제거 확인 (docker exec music-lab env | grep SUNO 결과 empty) +- nginx 3-layer 차단 활성화 (외부 403, LAN 401) +- 다음: Plan-B-Video (SP-7+SP-8) 또는 FOLLOW-UP B (-lab suffix 제거) +``` + +--- + +## Self-Review + +**1. Spec coverage** + +| Spec 요구사항 | 구현 위치 | 상태 | +|--------------|-----------|------| +| SP-5 §10: Suno API client (외부 SaaS, 폴링 1~5분) | Task 5 (providers/suno.py) | ✓ 11 async + 4 sync 함수 이식 | +| SP-5 §10: MusicGen local call (Windows localhost:8765) | Task 6 (providers/local.py) | ✓ run_local_generation 이식, host.docker.internal 매핑 | +| SP-5 §10: Redis BLPOP queue:music-render | Task 8 (worker.py) | ✓ job_type 디스패처 13종 | +| SP-5 §10: 결과 mp3 → /mnt/nas/data/music/ | Task 5 (_download_and_register) | ✓ MUSIC_MEDIA_ROOT 직접 저장 | +| SP-5 §10: NAS webhook POST /api/internal/music/update | Task 4 (nas_client.py) | ✓ webhook_update_task + webhook_add_track | +| SP-5 §10: SUNO_API_KEY Windows .env 단독 보관 | Task 3, Task 14 | ✓ Windows .env.example, NAS suno_provider stub | +| SP-6 §10: Suno + MusicGen 호출 코드 삭제 | Task 14 | ✓ suno_provider/local_provider stub | +| SP-6 §10: _bg_generate → Redis push | Task 11 | ✓ 13 endpoint + helper | +| SP-6 §10: POST /api/internal/music/update endpoint | Task 1 | ✓ internal_router.py | +| §5 Windows Render Worker 패턴 (시퀀스) | Task 8 worker_loop | ✓ paused 체크 + BLPOP + webhook | +| §6 Redis 키 컨벤션 (queue:music-render + queue:paused) | Task 8 | ✓ QUEUE_KEY + PAUSED_KEY | +| §6 큐 payload 표준 (task_id/kind/params/submitted_at) | Task 11 _push_render_job | ✓ + job_type 추가 | +| §7 NAS 볼륨 (/mnt/nas/webpage/data/music) | Task 3 .env, Task 10 compose | ✓ | +| §8 Internal webhook 3-layer 차단 | Task 16 | ✓ nginx IP allow + verify_internal_key | +| §9 SUNO_API_KEY NAS .env 제거 | Task 2 docker-compose | ✓ SUNO_API_KEY 라인 제거 | +| 박재오 결정 "모든 Suno + MusicGen 일괄 이전" | Task 5+6+7 (13 async + 4 sync) | ✓ | + +**전체 spec coverage**: 100%. SP-5/SP-6 모든 항목 + §9 키 이전 + §5/§6/§7/§8 패턴 동일. + +**2. Placeholder scan**: 통과 — TBD/TODO/placeholder 없음. 모든 코드 블록 완전. + +**3. Type consistency**: +- `webhook_update_task(task_id: str, status: str, progress: int, message: str, audio_url: Optional[str], error: Optional[str])` — Task 4 정의, Task 5/6 일관 호출 +- `webhook_add_track(task_id, status, progress, message, audio_url, track)` — Task 4 정의, Task 5/6 일관 호출 +- `_push_render_job(task_id, job_type, params)` — Task 11 정의, Task 13 호출 일관 (kind=music, submitted_at 동일 포맷) +- `_dispatch(payload)` — Task 8 정의, payload[job_type] + payload[task_id] + payload[params] 사용 (Task 11 push 형식과 일치) +- `job_type` 디스크리미네이터 13개 문자열 (suno_generation, local_generation, suno_extend, vocal_removal, cover_image, wav_convert, stem_split, upload_cover, upload_extend, add_vocals, add_instrumental, video_generate) — Task 8 _DISPATCH_TABLE, Task 11 endpoint 호출, Task 13 batch_generator 일관 + +**4. Plan-B-Insta와의 일관성**: +- auth.py: 동일 (insta-lab/app/auth.py 복제) ✓ +- internal_router.py: 같은 패턴 + add_track 페이로드 추가 ✓ +- nginx 차단 블록: insta 블록과 동일 패턴 ✓ +- worker_loop: 동일 (queue:paused 체크 + BLPOP + webhook) ✓ +- .env.example: 동일 구조 ✓ +- docker-compose entry: 같은 패턴 ✓ + +**5. 누락 검토**: +- compiler.py, video_producer.py, pipeline/orchestrator.py는 FFmpeg/Anthropic/OpenAI/YouTube 호출 — NAS 유지 (spec §10 Suno+MusicGen만 이전). 변경 없음. ✓ +- youtube oauth (yt_module): NAS 유지. 변경 없음. ✓ +- /api/music/{compile,pipeline,video-project,setup,youtube,market,revenue,library}: 모두 NAS local 처리 (DB·FFmpeg·Anthropic) — 변경 없음. ✓ +- update_track_cover_images, update_track_wav_url, update_track_video_url, update_track_stem_urls: NAS local DB 함수. Windows에서는 webhook으로 처리 — cover_image/wav/video 등은 URL을 audio_url 필드에 JSON encode로 위임 (간소화 트레이드오프). 명세 §10에는 track column 업데이트 강제 없음. ✓ + +플랜이 완성되어 모든 검토를 통과했습니다. + +--- + +## 부록: 트러블슈팅 (Plan-B-Insta 경험 반영) + +**Pre-existing 함정 (Plan-B-Insta에서 발견):** + +1. **NAS DB 함수 시그니처 변경 — `update_task` 인자 누락** + - 증상: webhook 호출 시 NAS DB `update_task(task_id, status, progress, message, audio_url, error)` 시그니처와 webhook payload 불일치 + - 방지: Task 4의 webhook_update_task 시그니처를 NAS update_task와 정확히 동일하게 (`task_id, status, progress, message, audio_url, error`). + +2. **payload `track` 필드 dict vs JSON 문자열** + - 증상: NAS는 add_track이 dict를 받고 내부에서 `json.dumps(moods)` 수행. webhook 페이로드는 dict 그대로 보내야 함. + - 방지: Task 1 internal_router의 `UpdatePayload.track: Optional[Dict[str, Any]]` — JSON 디시리얼라이즈 자동. + +3. **SMB mount 안의 디렉토리 권한 + 부재** + - 증상: `/mnt/nas/webpage/data/music` 디렉토리 미존재로 첫 write 실패 + - 방지: Task 5 `_download_and_register`에서 `os.makedirs(MUSIC_MEDIA_ROOT, exist_ok=True)` 호출. Task 10 compose에서 volume mount. + +4. **WSL2 host.docker.internal 미작동** + - 증상: MusicGen이 Windows native(localhost:8765)에서 동작하지만 WSL2 컨테이너가 host.docker.internal로 접근 못함 + - 방지: Task 10 compose의 `extra_hosts: ["host.docker.internal:host-gateway"]` (Linux Docker에서 host 매핑). + +5. **Suno polling timeout 초과 — 8초 × 40 = 5분 20초** + - 증상: 일부 Suno 모델은 5분 초과 (V5 등) + - 방지: Task 5 _poll_suno_record `max_attempts=POLL_MAX_ATTEMPTS(40)` 유지 (NAS와 동일). 초과 시 webhook으로 failed 보고하면 사용자가 재시도 가능. + +6. **NAS .env에서 SUNO_API_KEY 완전 제거 후 dangling reference** + - 증상: 어딘가에서 `os.getenv("SUNO_API_KEY")` 검증으로 endpoint 비활성화 + - 방지: Task 12에서 `if SUNO_API_KEY:` 체크 라인 제거 + Task 14에서 stub의 `SUNO_API_KEY = ""` sentinel 유지 (다른 모듈 호환). + +7. **DDNS hairpin NAT 문제 (Plan-B-Insta 경험)** + - 증상: Windows WSL2에서 `gahusb.synology.me` 외부 IP로 접근 시 hairpin 실패 + - 방지: Task 3 `.env.example`의 NAS_BASE_URL은 LAN IP `192.168.45.54:18600` 직접 사용.