Compare commits
8 Commits
ea93dc522b
...
e1722e3963
| Author | SHA1 | Date | |
|---|---|---|---|
| e1722e3963 | |||
| b1e28aa725 | |||
| 532b794c11 | |||
| e7f6edf7c5 | |||
| 42cf39d0da | |||
| 74196396c5 | |||
| 4393ba706b | |||
| 714224a9b4 |
@@ -62,7 +62,6 @@ services:
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||
- SUNO_API_KEY=${SUNO_API_KEY:-}
|
||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
|
||||
@@ -77,9 +76,14 @@ services:
|
||||
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
|
||||
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||
- 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:
|
||||
- ${RUNTIME_PATH}/data/music:/app/data
|
||||
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||
depends_on:
|
||||
- redis
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
|
||||
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
File diff suppressed because it is too large
Load Diff
17
music-lab/app/auth.py
Normal file
17
music-lab/app/auth.py
Normal 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")
|
||||
@@ -102,8 +102,10 @@ async def run_batch(batch_id: int) -> None:
|
||||
|
||||
async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
|
||||
params: dict) -> int | None:
|
||||
"""기존 Suno generate 호출 + 완료까지 polling. 성공 시 새 track id, 실패 시 None."""
|
||||
from .suno_provider import run_suno_generation
|
||||
"""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 = {
|
||||
@@ -116,11 +118,23 @@ async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
|
||||
"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")
|
||||
|
||||
# Suno background task — 우리가 await로 기다림 (BackgroundTasks 미사용)
|
||||
asyncio.create_task(asyncio.to_thread(run_suno_generation, task_id, suno_params))
|
||||
# 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:
|
||||
@@ -131,14 +145,7 @@ async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
|
||||
continue
|
||||
status = task.get("status")
|
||||
if status == "succeeded":
|
||||
# task["track"] 또는 task["result"]["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로 검색
|
||||
# Windows webhook이 add_track 했으므로 task_id로 검색
|
||||
track = db.get_track_by_task_id(task_id)
|
||||
if track:
|
||||
return track.get("id")
|
||||
|
||||
61
music-lab/app/internal_router.py
Normal file
61
music-lab/app/internal_router.py
Normal 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}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,20 +26,34 @@ 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
|
||||
|
||||
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(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -94,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",
|
||||
@@ -129,28 +144,22 @@ class GenerateRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/generate")
|
||||
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
|
||||
provider: "suno" (Suno API) 또는 "local" (MusicGen)
|
||||
"""
|
||||
async def generate_music(req: GenerateRequest):
|
||||
"""음악 생성 작업 — Redis 큐로 Windows music-render에 위임."""
|
||||
provider = req.provider
|
||||
if provider == "suno" and not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
if provider == "local" and not os.getenv("MUSIC_AI_SERVER_URL"):
|
||||
raise HTTPException(status_code=400, detail="로컬 AI 서버 URL이 설정되지 않았습니다")
|
||||
# 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)
|
||||
|
||||
if provider == "suno":
|
||||
background_tasks.add_task(run_suno_generation, task_id, params)
|
||||
else:
|
||||
background_tasks.add_task(run_local_generation, task_id, params)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -189,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
|
||||
|
||||
|
||||
@@ -373,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
|
||||
@@ -394,15 +401,12 @@ class ExtendRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/extend")
|
||||
def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks):
|
||||
async def extend_music(req: ExtendRequest):
|
||||
"""기존 곡을 특정 지점부터 연장 (Suno Extend API)."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -414,15 +418,12 @@ class VocalRemovalRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/vocal-removal")
|
||||
def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
|
||||
async def vocal_removal(req: VocalRemovalRequest):
|
||||
"""트랙에서 보컬과 인스트루멘탈을 분리 (Suno Vocal Removal API)."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -434,15 +435,12 @@ class CoverImageRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/cover-image")
|
||||
def cover_image(req: CoverImageRequest, background_tasks: BackgroundTasks):
|
||||
async def cover_image(req: CoverImageRequest):
|
||||
"""Suno 곡의 커버 이미지 2장 생성."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -455,14 +453,12 @@ class WavRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/wav")
|
||||
def wav_convert(req: WavRequest, background_tasks: BackgroundTasks):
|
||||
async def wav_convert(req: WavRequest):
|
||||
"""곡을 WAV 포맷으로 변환."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -475,14 +471,12 @@ class StemSplitRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/stem-split")
|
||||
def stem_split(req: StemSplitRequest, background_tasks: BackgroundTasks):
|
||||
async def stem_split(req: StemSplitRequest):
|
||||
"""곡을 12개 스템으로 분리 (50 크레딧). 보컬, 드럼, 베이스, 기타 등."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -490,10 +484,9 @@ 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
|
||||
@@ -507,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
|
||||
@@ -533,14 +525,12 @@ class UploadCoverRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/upload-cover")
|
||||
def upload_cover(req: UploadCoverRequest, background_tasks: BackgroundTasks):
|
||||
async def upload_cover(req: UploadCoverRequest):
|
||||
"""외부 오디오를 Suno 스타일로 리메이크."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -560,14 +550,12 @@ class UploadExtendRequest(BaseModel):
|
||||
|
||||
|
||||
@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())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -586,14 +574,12 @@ class AddVocalsRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/add-vocals")
|
||||
def add_vocals(req: AddVocalsRequest, background_tasks: BackgroundTasks):
|
||||
async def add_vocals(req: AddVocalsRequest):
|
||||
"""인스트루멘탈에 AI 보컬 추가."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -611,14 +597,12 @@ class AddInstrumentalRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/add-instrumental")
|
||||
def add_instrumental(req: AddInstrumentalRequest, background_tasks: BackgroundTasks):
|
||||
async def add_instrumental(req: AddInstrumentalRequest):
|
||||
"""보컬에 AI 반주 추가."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -633,14 +617,12 @@ class VideoRequest(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/music/video")
|
||||
def video_generate(req: VideoRequest, background_tasks: BackgroundTasks):
|
||||
async def video_generate(req: VideoRequest):
|
||||
"""뮤직비디오(MP4) 생성."""
|
||||
if not SUNO_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="Suno API 키가 설정되지 않았습니다")
|
||||
task_id = str(uuid.uuid4())
|
||||
params = req.model_dump()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -867,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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
|
||||
@@ -14,3 +14,4 @@ freezegun>=1.4
|
||||
google-api-python-client>=2.100
|
||||
google-auth-oauthlib>=1.2
|
||||
google-auth-httplib2>=0.2
|
||||
redis>=5.0
|
||||
|
||||
23
music-lab/tests/test_auth.py
Normal file
23
music-lab/tests/test_auth.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
103
music-lab/tests/test_internal_router.py
Normal file
103
music-lab/tests/test_internal_router.py
Normal 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)
|
||||
Reference in New Issue
Block a user