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>
This commit is contained in:
2026-05-19 05:16:15 +09:00
parent e7f6edf7c5
commit 532b794c11
3 changed files with 111 additions and 46 deletions

View File

@@ -2,6 +2,7 @@ import json
import os
import shutil
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware
@@ -25,16 +26,9 @@ from .db import (
from . import db as _db_module
from .compiler import run_compile
from .market import ingest_trends, get_suggestions
from .local_provider import run_local_generation
from .pipeline import orchestrator
from .pipeline import youtube as yt_module
from .suno_provider import (
run_suno_generation, run_suno_extend, run_vocal_removal,
run_cover_image, run_wav_convert, run_stem_split,
run_upload_cover, run_upload_extend, run_add_vocals, run_add_instrumental, run_video_generate,
generate_lyrics, get_credits, get_timestamped_lyrics, generate_style_boost,
SUNO_API_KEY, SUNO_MODELS,
)
from .suno_provider import SUNO_MODELS
from .batch_generator import run_batch as _run_batch
import redis.asyncio as aioredis
from .internal_router import router as internal_router
@@ -49,7 +43,6 @@ 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 후 처리."""
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
@@ -116,7 +109,7 @@ def get_providers():
"description": "로컬 AI 서버 (인스트루멘탈 전용)",
"features": ["instrumental"],
})
if SUNO_API_KEY:
# SUNO는 Windows music-render에서 처리 — 항상 가용 (Suno 키 누락 시 worker가 failed 보고)
providers.append({
"id": "suno",
"name": "Suno",
@@ -151,7 +144,7 @@ class GenerateRequest(BaseModel):
@app.post("/api/music/generate")
async def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
async def generate_music(req: GenerateRequest):
"""음악 생성 작업 — Redis 큐로 Windows music-render에 위임."""
provider = req.provider
# SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X).
@@ -205,12 +198,11 @@ class LyricsRequest(BaseModel):
@app.post("/api/music/lyrics")
def gen_lyrics(req: LyricsRequest):
"""Suno AI 가사 생성합니다. 곡 생성 전 가사 미리보기용."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
result = generate_lyrics(req.prompt)
"""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="가사 생성 실패했습니다")
raise HTTPException(status_code=502, detail="가사 생성 실패 (Windows worker 응답 없음)")
return result
@@ -389,10 +381,9 @@ def get_models():
@app.get("/api/music/credits")
def check_credits():
"""Suno 잔여 크레딧 조회."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
result = get_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
@@ -410,7 +401,7 @@ class ExtendRequest(BaseModel):
@app.post("/api/music/extend")
async def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks):
async def extend_music(req: ExtendRequest):
"""기존 곡을 특정 지점부터 연장 (Suno Extend API)."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -427,7 +418,7 @@ class VocalRemovalRequest(BaseModel):
@app.post("/api/music/vocal-removal")
async def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
async def vocal_removal(req: VocalRemovalRequest):
"""트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API)."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -444,7 +435,7 @@ class CoverImageRequest(BaseModel):
@app.post("/api/music/cover-image")
async def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
async def cover_image(req: CoverImageRequest):
"""Suno 곡의 커버 이미지 2장 생성."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -462,7 +453,7 @@ class WavRequest(BaseModel):
@app.post("/api/music/wav")
async def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
async def wav_convert(req: WavRequest):
"""곡을 WAV 포맷으로 변환."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -480,7 +471,7 @@ class StemSplitRequest(BaseModel):
@app.post("/api/music/stem-split")
async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
async def stem_split(req: StemSplitRequest):
"""곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -493,10 +484,9 @@ async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
@app.get("/api/music/timestamped-lyrics")
def timestamped_lyrics(task_id: str, suno_id: str):
"""타임스탬프 가사 조회 (가라오케 스타일 싱크용)."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
result = get_timestamped_lyrics(task_id, suno_id)
"""타임스탬프 가사 — 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
@@ -510,10 +500,9 @@ class StyleBoostRequest(BaseModel):
@app.post("/api/music/style-boost")
def style_boost(req: StyleBoostRequest):
"""AI로 최적 스타일 프롬프트 생성."""
if not SUNO_API_KEY:
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
result = generate_style_boost(req.content)
"""스타일 부스트 — 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
@@ -536,7 +525,7 @@ class UploadCoverRequest(BaseModel):
@app.post("/api/music/upload-cover")
async def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
async def upload_cover(req: UploadCoverRequest):
"""외부 오디오를 Suno 스타일로 리메이크."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -561,7 +550,7 @@ class UploadExtendRequest(BaseModel):
@app.post("/api/music/upload-extend")
async def upload_extend(req: UploadExtendRequest, background_tasks: BackgroundTasks):
async def upload_extend(req: UploadExtendRequest):
"""외부 오디오를 이어서 확장."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -585,7 +574,7 @@ class AddVocalsRequest(BaseModel):
@app.post("/api/music/add-vocals")
async def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
async def add_vocals(req: AddVocalsRequest):
"""인스트루멘탈에 AI 보컬 추가."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -608,7 +597,7 @@ class AddInstrumentalRequest(BaseModel):
@app.post("/api/music/add-instrumental")
async def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
async def add_instrumental(req: AddInstrumentalRequest):
"""보컬에 AI 반주 추가."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -628,7 +617,7 @@ class VideoRequest(BaseModel):
@app.post("/api/music/video")
async def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
async def video_generate(req: VideoRequest):
"""뮤직비디오(MP4) 생성."""
task_id = str(uuid.uuid4())
params = req.model_dump()
@@ -860,7 +849,7 @@ async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks):
raise HTTPException(status_code=400, detail="target_duration_sec는 60-300 사이")
if not req.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 미설정")
batch_id = _db_module.create_batch_job(

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

@@ -50,7 +50,7 @@ def test_create_batch_rejects_invalid_duration(client):
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",
json={"genre": "lo-fi", "count": 3})
assert r.status_code == 400