Compare commits
8 Commits
ea93dc522b
...
e1722e3963
| Author | SHA1 | Date | |
|---|---|---|---|
| e1722e3963 | |||
| b1e28aa725 | |||
| 532b794c11 | |||
| e7f6edf7c5 | |||
| 42cf39d0da | |||
| 74196396c5 | |||
| 4393ba706b | |||
| 714224a9b4 |
@@ -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
|
||||||
|
|||||||
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,
|
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")
|
||||||
|
|||||||
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 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,7 +109,7 @@ 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",
|
||||||
@@ -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
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-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
|
||||||
|
|||||||
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):
|
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
|
||||||
|
|||||||
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