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