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:
@@ -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(
|
||||||
|
|||||||
76
music-lab/app/sync_forward.py
Normal file
76
music-lab/app/sync_forward.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user