Phase 1A: - mutagen으로 MP3 실제 재생시간 추출 (sync + startup backfill) - update_track_duration() DB 헬퍼 추가 Phase 2: - GET /api/music/models — Suno 모델 목록 (V4~V5) - GET /api/music/credits — 잔여 크레딧 조회 - POST /api/music/extend — 곡 연장 (continueAt 지점부터) - POST /api/music/vocal-removal — 보컬/인스트루멘탈 분리 - GenerateRequest에 model 필드 추가 (하드코딩 V4 제거) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
335 lines
12 KiB
Python
335 lines
12 KiB
Python
import os
|
|
import uuid
|
|
from typing import List, Optional
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel
|
|
|
|
from .db import (
|
|
init_db,
|
|
create_task, get_task,
|
|
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
|
update_track_duration,
|
|
)
|
|
from .local_provider import run_local_generation
|
|
from .suno_provider import (
|
|
run_suno_generation, run_suno_extend, run_vocal_removal,
|
|
generate_lyrics, get_credits,
|
|
SUNO_API_KEY, SUNO_MODELS,
|
|
)
|
|
|
|
app = FastAPI()
|
|
|
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in _cors_origins],
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
MUSIC_DATA_DIR = "/app/data"
|
|
|
|
|
|
def _get_mp3_duration(file_path: str) -> Optional[int]:
|
|
"""MP3 파일에서 실제 재생 시간(초) 추출."""
|
|
try:
|
|
from mutagen.mp3 import MP3
|
|
audio = MP3(file_path)
|
|
return int(audio.info.length)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _backfill_durations():
|
|
"""duration_sec이 없는 기존 트랙에 MP3 메타데이터에서 길이 채우기."""
|
|
for t in get_all_tracks():
|
|
if t["duration_sec"] is None and t.get("file_path"):
|
|
dur = _get_mp3_duration(t["file_path"])
|
|
if dur:
|
|
update_track_duration(t["id"], dur)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def on_startup():
|
|
init_db()
|
|
os.makedirs(MUSIC_DATA_DIR, exist_ok=True)
|
|
_backfill_durations()
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/music/providers")
|
|
def get_providers():
|
|
"""사용 가능한 음악 생성 프로바이더 목록 반환."""
|
|
providers = []
|
|
if os.getenv("MUSIC_AI_SERVER_URL"):
|
|
providers.append({
|
|
"id": "local",
|
|
"name": "MusicGen",
|
|
"description": "로컬 AI 서버 (인스트루멘탈 전용)",
|
|
"features": ["instrumental"],
|
|
})
|
|
if SUNO_API_KEY:
|
|
providers.append({
|
|
"id": "suno",
|
|
"name": "Suno",
|
|
"description": "Suno AI (보컬·가사·인스트루멘탈)",
|
|
"features": ["vocals", "lyrics", "instrumental"],
|
|
})
|
|
return {"providers": providers}
|
|
|
|
|
|
# ── 음악 생성 API ─────────────────────────────────────────────────────────────
|
|
|
|
class GenerateRequest(BaseModel):
|
|
provider: str = "suno" # "suno" | "local"
|
|
model: str = "V4" # Suno 모델 (V4, V4_5, V5 등)
|
|
title: str = ""
|
|
genre: str = ""
|
|
moods: List[str] = []
|
|
instruments: List[str] = []
|
|
duration_sec: Optional[int] = None
|
|
bpm: Optional[int] = None
|
|
key: str = ""
|
|
scale: str = ""
|
|
prompt: str = ""
|
|
# Suno 전용
|
|
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
|
|
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
|
|
|
|
|
|
@app.post("/api/music/generate")
|
|
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
|
|
"""
|
|
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
|
|
provider: "suno" (Suno API) 또는 "local" (MusicGen)
|
|
"""
|
|
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이 설정되지 않았습니다")
|
|
if provider not in ("suno", "local"):
|
|
raise HTTPException(status_code=400, detail=f"지원하지 않는 provider: {provider}")
|
|
|
|
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)
|
|
|
|
return {"task_id": task_id, "provider": provider}
|
|
|
|
|
|
@app.get("/api/music/status/{task_id}")
|
|
def get_status(task_id: str):
|
|
"""
|
|
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
|
|
status: queued | processing | succeeded | failed
|
|
succeeded 시 track 메타데이터 포함.
|
|
"""
|
|
task = get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
resp = {
|
|
"status": task["status"],
|
|
"progress": task["progress"],
|
|
"message": task["message"],
|
|
"audio_url": task["audio_url"],
|
|
"error": task["error"],
|
|
"provider": task["provider"],
|
|
}
|
|
|
|
if task["status"] == "succeeded":
|
|
track = get_track_by_task_id(task_id)
|
|
resp["track"] = track
|
|
|
|
return resp
|
|
|
|
|
|
# ── 가사 생성 API (Suno 전용) ────────────────────────────────────────────────
|
|
|
|
class LyricsRequest(BaseModel):
|
|
prompt: str
|
|
|
|
|
|
@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)
|
|
if not result:
|
|
raise HTTPException(status_code=502, detail="가사 생성에 실패했습니다")
|
|
return result
|
|
|
|
|
|
# ── 라이브러리 API ────────────────────────────────────────────────────────────
|
|
|
|
class TrackCreate(BaseModel):
|
|
title: str = ""
|
|
genre: str = ""
|
|
moods: List[str] = []
|
|
instruments: List[str] = []
|
|
duration_sec: Optional[int] = None
|
|
bpm: Optional[int] = None
|
|
key: str = ""
|
|
scale: str = ""
|
|
prompt: str = ""
|
|
audio_url: str = ""
|
|
file_path: str = ""
|
|
task_id: Optional[str] = None
|
|
tags: List[str] = []
|
|
provider: str = "local"
|
|
lyrics: str = ""
|
|
image_url: str = ""
|
|
suno_id: str = ""
|
|
|
|
|
|
@app.get("/api/music/library")
|
|
def list_library():
|
|
"""저장된 트랙 목록 전체 조회 (생성일 내림차순). 파일시스템과 자동 동기화."""
|
|
_sync_library_with_disk()
|
|
return {"tracks": get_all_tracks()}
|
|
|
|
|
|
def _sync_library_with_disk():
|
|
"""파일시스템의 .mp3 파일과 DB를 동기화.
|
|
- 디스크에 없는 트랙 → DB에서 삭제
|
|
- DB에 없는 .mp3 파일 → 새 트랙으로 추가
|
|
"""
|
|
tracks = get_all_tracks()
|
|
media_base = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
|
|
|
# 디스크의 .mp3 파일 목록
|
|
disk_files = set()
|
|
try:
|
|
for f in os.listdir(MUSIC_DATA_DIR):
|
|
if f.lower().endswith(".mp3"):
|
|
disk_files.add(f)
|
|
except OSError:
|
|
return # 디렉토리 접근 불가 시 동기화 스킵
|
|
|
|
# DB 트랙의 파일명 매핑
|
|
db_filenames = {} # filename → track
|
|
for t in tracks:
|
|
if t.get("audio_url"):
|
|
fname = t["audio_url"].split("/")[-1]
|
|
db_filenames[fname] = t
|
|
|
|
# DB에는 있지만 디스크에 없는 → 삭제
|
|
for fname, t in db_filenames.items():
|
|
if fname not in disk_files:
|
|
delete_track(t["id"])
|
|
|
|
# 디스크에는 있지만 DB에 없는 → 추가 (duration 자동 추출)
|
|
for f in disk_files:
|
|
if f not in db_filenames:
|
|
file_path = os.path.join(MUSIC_DATA_DIR, f)
|
|
title = os.path.splitext(f)[0].replace("-", " ").replace("_", " ")
|
|
add_track({
|
|
"title": title,
|
|
"audio_url": f"{media_base}/{f}",
|
|
"file_path": file_path,
|
|
"provider": "suno",
|
|
"duration_sec": _get_mp3_duration(file_path),
|
|
})
|
|
|
|
|
|
@app.post("/api/music/library", status_code=201)
|
|
def save_to_library(req: TrackCreate):
|
|
"""트랙 수동 추가 (외부 파일 등록 또는 프론트 직접 저장용)"""
|
|
track = add_track(req.model_dump())
|
|
return track
|
|
|
|
|
|
@app.delete("/api/music/library/{track_id}")
|
|
def remove_from_library(track_id: int):
|
|
"""라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제."""
|
|
file_path = get_track_file_path(track_id)
|
|
ok = delete_track(track_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Track not found")
|
|
|
|
if file_path and os.path.isfile(file_path):
|
|
try:
|
|
os.remove(file_path)
|
|
except OSError:
|
|
pass
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 모델 목록 API ────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/music/models")
|
|
def get_models():
|
|
"""사용 가능한 Suno AI 모델 목록."""
|
|
return {"models": SUNO_MODELS}
|
|
|
|
|
|
# ── 크레딧 조회 API ──────────────────────────────────────────────────────────
|
|
|
|
@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()
|
|
if result is None:
|
|
raise HTTPException(status_code=502, detail="크레딧 조회 실패")
|
|
return result
|
|
|
|
|
|
# ── 곡 연장 API ──────────────────────────────────────────────────────────────
|
|
|
|
class ExtendRequest(BaseModel):
|
|
suno_id: str # 원본 Suno 곡 ID
|
|
continue_at: int = 0 # 연장 시작 지점 (초)
|
|
prompt: str = "" # 추가 가사/프롬프트
|
|
style: str = "" # 스타일 오버라이드
|
|
title: str = ""
|
|
model: str = "V4"
|
|
|
|
|
|
@app.post("/api/music/extend")
|
|
def extend_music(req: ExtendRequest, background_tasks: BackgroundTasks):
|
|
"""기존 곡을 특정 지점부터 연장 (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)
|
|
return {"task_id": task_id, "provider": "suno"}
|
|
|
|
|
|
# ── 보컬 분리 API ────────────────────────────────────────────────────────────
|
|
|
|
class VocalRemovalRequest(BaseModel):
|
|
suno_id: str # Suno 곡 ID
|
|
title: str = "" # 원본 트랙 제목
|
|
|
|
|
|
@app.post("/api/music/vocal-removal")
|
|
def vocal_removal(req: VocalRemovalRequest, background_tasks: BackgroundTasks):
|
|
"""트랙에서 보컬과 인스트루멘탈을 분리 (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)
|
|
return {"task_id": task_id, "provider": "suno"}
|