8 Commits

Author SHA1 Message Date
e1722e3963 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>
2026-05-19 05:22:05 +09:00
b1e28aa725 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>
2026-05-19 05:19:41 +09:00
532b794c11 refactor(music-lab): sync helpers → Windows HTTP forward + cleanup (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만 잔존 (정적 데이터).

추가 cleanup (T11 reviewer 지적):
- _push_render_job의 datetime import를 모듈 상위로
- 11 endpoint의 unused BackgroundTasks 매개변수 제거

generate_batch: SUNO_API_KEY 체크를 os.getenv()로 전환 + 테스트 monkeypatch 갱신.

Plan-B-Music Phase 3 (cutover 2/4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 05:16:15 +09:00
e7f6edf7c5 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>
2026-05-19 05:10:20 +09:00
42cf39d0da 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>
2026-05-19 03:16:54 +09:00
74196396c5 fix(music-lab): track guard if payload.track is not None: (T1 follow-up)
Code review found: empty dict `{}` was falsy and would silently skip
add_track. Use explicit None check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:14:41 +09:00
4393ba706b 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>
2026-05-19 03:10:05 +09:00
714224a9b4 docs(plan): Plan-B-Music — music-render Windows worker + NAS 분할
SP-5 + SP-6 — 모든 Suno(13) + MusicGen(1) 외부 호출 + sync helpers(4)를
Windows music-render로 이전. NAS music-lab은 Redis push(async) +
httpx forward(sync)만. SUNO_API_KEY는 Windows .env 단독 보유 (spec §9).

17 task: NAS 수신부(1-2) → Windows worker(3-10) → NAS cutover(11-14) →
nginx 차단 + end-to-end 검증(15-17).

박재오 결정: 모든 Suno + MusicGen 일괄 이전 (Plan-B-Insta 패턴 + sync forward 추가).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 03:02:48 +09:00
13 changed files with 3635 additions and 1267 deletions

View File

@@ -62,7 +62,6 @@ services:
environment: environment:
- TZ=${TZ:-Asia/Seoul} - TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-} - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- SUNO_API_KEY=${SUNO_API_KEY:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music} - MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- PEXELS_API_KEY=${PEXELS_API_KEY:-} - PEXELS_API_KEY=${PEXELS_API_KEY:-}
@@ -77,9 +76,14 @@ services:
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-} - WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos} - NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music} - NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
- 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}
volumes: volumes:
- ${RUNTIME_PATH}/data/music:/app/data - ${RUNTIME_PATH}/data/music:/app/data
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos - ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
depends_on:
- redis
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s interval: 60s

File diff suppressed because it is too large Load Diff

17
music-lab/app/auth.py Normal file
View File

@@ -0,0 +1,17 @@
"""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")

View File

@@ -102,8 +102,10 @@ async def run_batch(batch_id: int) -> None:
async def _generate_one_track(*, title: str, genre: str, duration_sec: int, async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
params: dict) -> int | None: params: dict) -> int | None:
"""기존 Suno generate 호출 + 완료까지 polling. 성공 시 새 track id, 실패 시 None.""" """Redis 큐에 push + task 상태 polling. 성공 시 새 track id, 실패 시 None."""
from .suno_provider import run_suno_generation import json
from datetime import datetime, timezone, timedelta
from .main import redis_client # 같은 컨테이너 — 동일 redis 클라이언트 공유
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
suno_params = { suno_params = {
@@ -116,11 +118,23 @@ async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
"key": params["key"], "key": params["key"],
"scale": params["scale"], "scale": params["scale"],
"prompt": params.get("prompt_modifier", ""), "prompt": params.get("prompt_modifier", ""),
"provider": "suno",
"model": "V4",
"instrumental": False,
"lyrics": "",
} }
db.create_task(task_id, suno_params, provider="suno") db.create_task(task_id, suno_params, provider="suno")
# Suno background task — 우리가 await로 기다림 (BackgroundTasks 미사용) # Redis push (Windows music-render가 BLPOP 처리)
asyncio.create_task(asyncio.to_thread(run_suno_generation, task_id, suno_params)) 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 waited = 0
while waited < TRACK_GEN_TIMEOUT_S: while waited < TRACK_GEN_TIMEOUT_S:
@@ -131,14 +145,7 @@ async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
continue continue
status = task.get("status") status = task.get("status")
if status == "succeeded": if status == "succeeded":
# task["track"] 또는 task["result"]["track"] 형태 시도, 없으면 task_id로 조회 # Windows webhook이 add_track 했으므로 task_id로 검색
tr = task.get("track")
if tr and isinstance(tr, dict):
return tr.get("id")
result = task.get("result", {}) or {}
if isinstance(result, dict) and isinstance(result.get("track"), dict):
return result["track"].get("id")
# Fallback: music_library에서 task_id로 검색
track = db.get_track_by_task_id(task_id) track = db.get_track_by_task_id(task_id)
if track: if track:
return track.get("id") return track.get("id")

View File

@@ -0,0 +1,61 @@
"""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
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 is not None:
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}

View File

@@ -1,122 +1,5 @@
"""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)만 담당.
""" """
Local MusicGen Provider — Windows AI 서버(MusicGen)를 통한 음악 생성
기존 _run_generation 로직을 그대로 분리.
"""
import os
import time
import logging
import requests
from .db import update_task, add_track
logger = logging.getLogger(__name__)
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
MUSIC_DATA_DIR = "/app/data"
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
def run_local_generation(task_id: str, params: dict) -> None:
"""BackgroundTask: Windows AI 서버(MusicGen)에 생성 요청 → 파일 저장 → 라이브러리 등록"""
try:
update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
if not MUSIC_AI_SERVER_URL:
update_task(task_id, "failed", 0, "",
error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다")
return
update_task(task_id, "processing", 30, "음악 생성 중... (수 분 소요될 수 있습니다)")
# 1단계: 생성 요청 → ai_task_id 반환
resp = requests.post(
f"{MUSIC_AI_SERVER_URL}/generate",
json=params,
timeout=30,
)
if resp.status_code != 200:
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:
update_task(task_id, "failed", 0, "",
error="AI 서버 응답에 task_id가 없습니다")
return
# 2단계: 상태 폴링 (최대 10분, 5초 간격)
remote_url = None
for _ in range(120):
time.sleep(5)
status_resp = requests.get(
f"{MUSIC_AI_SERVER_URL}/status/{ai_task_id}", timeout=10,
)
status_data = status_resp.json()
ai_status = status_data.get("status")
ai_progress = status_data.get("progress", 0)
ai_message = status_data.get("message", "음악 생성 중...")
scaled = 30 + int(ai_progress * 0.49) # 30% ~ 79%
update_task(task_id, "processing", scaled, ai_message)
if ai_status == "succeeded":
remote_url = status_data.get("audio_url")
break
elif ai_status == "failed":
update_task(task_id, "failed", 0, "",
error=status_data.get("error", "AI 서버 생성 실패"))
return
if not remote_url:
update_task(task_id, "failed", 0, "",
error="AI 서버 타임아웃 (10분 초과)")
return
update_task(task_id, "processing", 80, "파일 저장 중...")
filename = f"{task_id}.mp3"
file_path = os.path.join(MUSIC_DATA_DIR, filename)
# 3단계: 오디오 파일 다운로드
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"
)
add_track({
"title": title,
"genre": genre,
"moods": params.get("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": file_path,
"task_id": task_id,
"provider": "local",
})
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
except requests.Timeout:
update_task(task_id, "failed", 0, "",
error="AI 서버 타임아웃 (10분 초과)")
except Exception as e:
logger.exception("Local generation error for task %s", task_id)
update_task(task_id, "failed", 0, "", error=str(e))

View File

@@ -2,6 +2,7 @@ import json
import os import os
import shutil import shutil
import uuid import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -25,20 +26,34 @@ from .db import (
from . import db as _db_module from . import db as _db_module
from .compiler import run_compile from .compiler import run_compile
from .market import ingest_trends, get_suggestions from .market import ingest_trends, get_suggestions
from .local_provider import run_local_generation
from .pipeline import orchestrator from .pipeline import orchestrator
from .pipeline import youtube as yt_module from .pipeline import youtube as yt_module
from .suno_provider import ( from .suno_provider import SUNO_MODELS
run_suno_generation, run_suno_extend, run_vocal_removal,
run_cover_image, run_wav_convert, run_stem_split,
run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
SUNO_API_KEY, SUNO_MODELS,
)
from .batch_generator import run_batch as _run_batch from .batch_generator import run_batch as _run_batch
import redis.asyncio as aioredis
from .internal_router import router as internal_router
app = FastAPI() app = FastAPI()
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
app.include_router(internal_router)
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
"""Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리."""
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))
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",") _cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -94,13 +109,13 @@ def get_providers():
"description": "로컬 AI 서버 (인스트루멘탈 전용)", "description": "로컬 AI 서버 (인스트루멘탈 전용)",
"features": ["instrumental"], "features": ["instrumental"],
}) })
if SUNO_API_KEY: # SUNO는 Windows music-render에서 처리 — 항상 가용 (Suno 키 누락 시 worker가 failed 보고)
providers.append({ providers.append({
"id": "suno", "id": "suno",
"name": "Suno", "name": "Suno",
"description": "Suno AI (보컬·가사·인스트루멘탈)", "description": "Suno AI (보컬·가사·인스트루멘탈)",
"features": ["vocals", "lyrics", "instrumental"], "features": ["vocals", "lyrics", "instrumental"],
}) })
return {"providers": providers} return {"providers": providers}
@@ -129,28 +144,22 @@ class GenerateRequest(BaseModel):
@app.post("/api/music/generate") @app.post("/api/music/generate")
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): async def generate_music(req: GenerateRequest):
""" """음악 생성 작업 — Redis 큐로 Windows music-render에 위임."""
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
provider: "suno" (Suno API) 또는 "local" (MusicGen)
"""
provider = req.provider provider = req.provider
if provider == "suno" and not SUNO_API_KEY: # SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X).
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") # 실패 시 worker가 webhook으로 failed 보고.
if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"):
raise HTTPException(status_code=400, detail="로컬 AI 서버 URL이 설정되지 않았습니다")
if provider not in ("suno", "local"): if provider not in ("suno", "local"):
raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}") 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()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider=provider) create_task(task_id, params, provider=provider)
job_type = "suno_generation" if provider == "suno" else "local_generation"
if provider == "suno": await _push_render_job(task_id, job_type, params)
background_tasks.add_task(run_suno_generation, task_id, params)
else:
background_tasks.add_task(run_local_generation, task_id, params)
return {"task_id": task_id, "provider": provider} return {"task_id": task_id, "provider": provider}
@@ -189,12 +198,11 @@ class LyricsRequest(BaseModel):
@app.post("/api/music/lyrics") @app.post("/api/music/lyrics")
def gen_lyrics(req: LyricsRequest): def gen_lyrics(req: LyricsRequest):
"""Suno AI 가사 생성합니다. 곡 생성 전 가사 미리보기용.""" """Suno AI 가사 생성 — Windows music-render로 forward."""
if not SUNO_API_KEY: from .sync_forward import forward_lyrics
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") result = forward_lyrics(req.prompt)
result = generate_lyrics(req.prompt)
if not result: if not result:
raise HTTPException(status_code=502, detail="가사 생성 실패했습니다") raise HTTPException(status_code=502, detail="가사 생성 실패 (Windows worker 응답 없음)")
return result return result
@@ -373,10 +381,9 @@ def get_models():
@app.get("/api/music/credits") @app.get("/api/music/credits")
def check_credits(): def check_credits():
"""Suno 잔여 크레딧 조회.""" """Suno 잔여 크레딧 조회 — Windows music-render로 forward."""
if not SUNO_API_KEY: from .sync_forward import forward_credits
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") result = forward_credits()
result = get_credits()
if result is None: if result is None:
raise HTTPException(status_code=502, detail="크레딧 조회 실패") raise HTTPException(status_code=502, detail="크레딧 조회 실패")
return result return result
@@ -394,15 +401,12 @@ class ExtendRequest(BaseModel):
@app.post("/api/music/extend") @app.post("/api/music/extend")
def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks): async def extend_music(req: ExtendRequest):
"""기존 곡을 특정 지점부터 연장 (Suno Extend API).""" """기존 곡을 특정 지점부터 연장 (Suno Extend API)."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_suno_extend, task_id, params) await _push_render_job(task_id, "suno_extend", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -414,15 +418,12 @@ class VocalRemovalRequest(BaseModel):
@app.post("/api/music/vocal-removal") @app.post("/api/music/vocal-removal")
def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks): async def vocal_removal(req: VocalRemovalRequest):
"""트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API).""" """트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API)."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_vocal_removal, task_id, params) await _push_render_job(task_id, "vocal_removal", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -434,15 +435,12 @@ class CoverImageRequest(BaseModel):
@app.post("/api/music/cover-image") @app.post("/api/music/cover-image")
def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks): async def cover_image(req: CoverImageRequest):
"""Suno 곡의 커버 이미지 2장 생성.""" """Suno 곡의 커버 이미지 2장 생성."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_cover_image, task_id, params) await _push_render_job(task_id, "cover_image", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -455,14 +453,12 @@ class WavRequest(BaseModel):
@app.post("/api/music/wav") @app.post("/api/music/wav")
def wav_convert(req: WavRequest, background_tasks: BackgroundTasks): async def wav_convert(req: WavRequest):
"""곡을 WAV 포맷으로 변환.""" """곡을 WAV 포맷으로 변환."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_wav_convert, task_id, params) await _push_render_job(task_id, "wav_convert", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -475,14 +471,12 @@ class StemSplitRequest(BaseModel):
@app.post("/api/music/stem-split") @app.post("/api/music/stem-split")
def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks): async def stem_split(req: StemSplitRequest):
"""곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등.""" """곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_stem_split, task_id, params) await _push_render_job(task_id, "stem_split", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -490,10 +484,9 @@ def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
@app.get("/api/music/timestamped-lyrics") @app.get("/api/music/timestamped-lyrics")
def timestamped_lyrics(task_id: str, suno_id: str): def timestamped_lyrics(task_id: str, suno_id: str):
"""타임스탬프 가사 조회 (가라오케 스타일 싱크용).""" """타임스탬프 가사 — Windows music-render로 forward."""
if not SUNO_API_KEY: from .sync_forward import forward_timestamped_lyrics
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") result = forward_timestamped_lyrics(task_id, suno_id)
result = get_timestamped_lyrics(task_id, suno_id)
if not result: if not result:
raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패") raise HTTPException(status_code=502, detail="타임스탬프 가사 조회 실패")
return result return result
@@ -507,10 +500,9 @@ class StyleBoostRequest(BaseModel):
@app.post("/api/music/style-boost") @app.post("/api/music/style-boost")
def style_boost(req: StyleBoostRequest): def style_boost(req: StyleBoostRequest):
"""AI로 최적 스타일 프롬프트 생성.""" """스타일 부스트 — Windows music-render로 forward."""
if not SUNO_API_KEY: from .sync_forward import forward_style_boost
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다") result = forward_style_boost(req.content)
result = generate_style_boost(req.content)
if not result: if not result:
raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패") raise HTTPException(status_code=502, detail="스타일 부스트 생성 실패")
return result return result
@@ -533,14 +525,12 @@ class UploadCoverRequest(BaseModel):
@app.post("/api/music/upload-cover") @app.post("/api/music/upload-cover")
def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks): async def upload_cover(req: UploadCoverRequest):
"""외부 오디오를 Suno 스타일로 리메이크.""" """외부 오디오를 Suno 스타일로 리메이크."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_upload_cover, task_id, params) await _push_render_job(task_id, "upload_cover", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -560,14 +550,12 @@ class UploadExtendRequest(BaseModel):
@app.post("/api/music/upload-extend") @app.post("/api/music/upload-extend")
def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks): async def upload_extend(req: UploadExtendRequest):
"""외부 오디오를 이어서 확장.""" """외부 오디오를 이어서 확장."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_upload_extend, task_id, params) await _push_render_job(task_id, "upload_extend", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -586,14 +574,12 @@ class AddVocalsRequest(BaseModel):
@app.post("/api/music/add-vocals") @app.post("/api/music/add-vocals")
def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks): async def add_vocals(req: AddVocalsRequest):
"""인스트루멘탈에 AI 보컬 추가.""" """인스트루멘탈에 AI 보컬 추가."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_add_vocals, task_id, params) await _push_render_job(task_id, "add_vocals", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -611,14 +597,12 @@ class AddInstrumentalRequest(BaseModel):
@app.post("/api/music/add-instrumental") @app.post("/api/music/add-instrumental")
def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks): async def add_instrumental(req: AddInstrumentalRequest):
"""보컬에 AI 반주 추가.""" """보컬에 AI 반주 추가."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_add_instrumental, task_id, params) await _push_render_job(task_id, "add_instrumental", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -633,14 +617,12 @@ class VideoRequest(BaseModel):
@app.post("/api/music/video") @app.post("/api/music/video")
def video_generate(req: VideoRequest, background_tasks: BackgroundTasks): async def video_generate(req: VideoRequest):
"""뮤직비디오(MP4) 생성.""" """뮤직비디오(MP4) 생성."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
create_task(task_id, params, provider="suno") create_task(task_id, params, provider="suno")
background_tasks.add_task(run_video_generate, task_id, params) await _push_render_job(task_id, "video_generate", params)
return {"task_id": task_id, "provider": "suno"} return {"task_id": task_id, "provider": "suno"}
@@ -867,7 +849,7 @@ async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks):
raise HTTPException(status_code=400, detail="target_duration_sec는 60-300 사이") raise HTTPException(status_code=400, detail="target_duration_sec는 60-300 사이")
if not req.genre: if not req.genre:
raise HTTPException(status_code=400, detail="genre 필수") raise HTTPException(status_code=400, detail="genre 필수")
if not SUNO_API_KEY: if not os.getenv("SUNO_API_KEY"):
raise HTTPException(status_code=400, detail="SUNO_API_KEY 미설정") raise HTTPException(status_code=400, detail="SUNO_API_KEY 미설정")
batch_id = _db_module.create_batch_job( batch_id = _db_module.create_batch_job(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
"""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

View File

@@ -14,3 +14,4 @@ freezegun>=1.4
google-api-python-client>=2.100 google-api-python-client>=2.100
google-auth-oauthlib>=1.2 google-auth-oauthlib>=1.2
google-auth-httplib2>=0.2 google-auth-httplib2>=0.2
redis>=5.0

View File

@@ -0,0 +1,23 @@
"""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

View File

@@ -50,7 +50,7 @@ def test_create_batch_rejects_invalid_duration(client):
def test_create_batch_rejects_no_suno_key(client, monkeypatch): def test_create_batch_rejects_no_suno_key(client, monkeypatch):
monkeypatch.setattr(main_module, "SUNO_API_KEY", "", raising=False) monkeypatch.delenv("SUNO_API_KEY", raising=False)
r = client.post("/api/music/generate-batch", r = client.post("/api/music/generate-batch",
json={"genre": "lo-fi", "count": 3}) json={"genre": "lo-fi", "count": 3})
assert r.status_code == 400 assert r.status_code == 400

View File

@@ -0,0 +1,103 @@
"""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))
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_music.db"))
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)