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