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 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,16 +26,9 @@ 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 import redis.asyncio as aioredis
from .internal_router import router as internal_router 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: async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
"""Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리.""" """Redis queue:music-render에 push. Windows worker가 BLPOP 후 처리."""
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9)) kst = timezone(timedelta(hours=9))
payload = { payload = {
"task_id": task_id, "task_id": task_id,
@@ -116,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}
@@ -151,7 +144,7 @@ class GenerateRequest(BaseModel):
@app.post("/api/music/generate") @app.post("/api/music/generate")
async def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks): async def generate_music(req: GenerateRequest):
"""음악 생성 작업 — Redis 큐로 Windows music-render에 위임.""" """음악 생성 작업 — Redis 큐로 Windows music-render에 위임."""
provider = req.provider provider = req.provider
# SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X). # SUNO_API_KEY 검증은 Windows로 위임 (NAS에서 키 보유 X).
@@ -205,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
@@ -389,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
@@ -410,7 +401,7 @@ class ExtendRequest(BaseModel):
@app.post("/api/music/extend") @app.post("/api/music/extend")
async def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks): async def extend_music(req: ExtendRequest):
"""기존 곡을 특정 지점부터 연장 (Suno Extend API).""" """기존 곡을 특정 지점부터 연장 (Suno Extend API)."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -427,7 +418,7 @@ class VocalRemovalRequest(BaseModel):
@app.post("/api/music/vocal-removal") @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).""" """트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API)."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -444,7 +435,7 @@ class CoverImageRequest(BaseModel):
@app.post("/api/music/cover-image") @app.post("/api/music/cover-image")
async def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks): async def cover_image(req: CoverImageRequest):
"""Suno 곡의 커버 이미지 2장 생성.""" """Suno 곡의 커버 이미지 2장 생성."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -462,7 +453,7 @@ class WavRequest(BaseModel):
@app.post("/api/music/wav") @app.post("/api/music/wav")
async def wav_convert(req: WavRequest, background_tasks: BackgroundTasks): async def wav_convert(req: WavRequest):
"""곡을 WAV 포맷으로 변환.""" """곡을 WAV 포맷으로 변환."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -480,7 +471,7 @@ class StemSplitRequest(BaseModel):
@app.post("/api/music/stem-split") @app.post("/api/music/stem-split")
async def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks): async def stem_split(req: StemSplitRequest):
"""곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등.""" """곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -493,10 +484,9 @@ async 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
@@ -510,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
@@ -536,7 +525,7 @@ class UploadCoverRequest(BaseModel):
@app.post("/api/music/upload-cover") @app.post("/api/music/upload-cover")
async def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks): async def upload_cover(req: UploadCoverRequest):
"""외부 오디오를 Suno 스타일로 리메이크.""" """외부 오디오를 Suno 스타일로 리메이크."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -561,7 +550,7 @@ class UploadExtendRequest(BaseModel):
@app.post("/api/music/upload-extend") @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()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -585,7 +574,7 @@ class AddVocalsRequest(BaseModel):
@app.post("/api/music/add-vocals") @app.post("/api/music/add-vocals")
async def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks): async def add_vocals(req: AddVocalsRequest):
"""인스트루멘탈에 AI 보컬 추가.""" """인스트루멘탈에 AI 보컬 추가."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -608,7 +597,7 @@ class AddInstrumentalRequest(BaseModel):
@app.post("/api/music/add-instrumental") @app.post("/api/music/add-instrumental")
async def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks): async def add_instrumental(req: AddInstrumentalRequest):
"""보컬에 AI 반주 추가.""" """보컬에 AI 반주 추가."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() params = req.model_dump()
@@ -628,7 +617,7 @@ class VideoRequest(BaseModel):
@app.post("/api/music/video") @app.post("/api/music/video")
async def video_generate(req: VideoRequest, background_tasks: BackgroundTasks): async def video_generate(req: VideoRequest):
"""뮤직비디오(MP4) 생성.""" """뮤직비디오(MP4) 생성."""
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
params = req.model_dump() 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 사이") 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(

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): 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