music-lab: Suno API + MusicGen 듀얼 프로바이더 구조 구현
- suno_provider.py: Suno REST API 클라이언트 (곡 생성, 가사, 2변형 저장) - local_provider.py: 기존 MusicGen 로직 분리 - main.py: provider 라우팅, /providers·/lyrics 엔드포인트 추가 - db.py: provider, lyrics, image_url, suno_id 컬럼 마이그레이션 - docker-compose.yml: SUNO_API_KEY 환경변수 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,5 +57,12 @@ ADMIN_API_KEY=
|
|||||||
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# [MUSIC LAB]
|
||||||
|
# Suno API Key (https://suno.com 에서 발급, 미설정 시 Suno provider 비활성화)
|
||||||
|
SUNO_API_KEY=
|
||||||
|
|
||||||
|
# 로컬 MusicGen AI Server URL (미설정 시 Local provider 비활성화)
|
||||||
|
# MUSIC_AI_SERVER_URL=http://192.168.45.59:8765
|
||||||
|
|
||||||
# CORS 허용 도메인 (콤마 구분)
|
# CORS 허용 도메인 (콤마 구분)
|
||||||
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
|
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
|
||||||
|
|||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -225,30 +225,44 @@ docker compose up -d
|
|||||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
||||||
|
|
||||||
### music-lab (music-lab/)
|
### music-lab (music-lab/)
|
||||||
- AI 음악 생성 서비스. Windows AI 서버(`MUSIC_AI_SERVER_URL`)에 생성 요청 프록시
|
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen)
|
||||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||||
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
|
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
|
||||||
- 파일 구조: `main.py`, `db.py`
|
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`
|
||||||
- 생성 흐름: POST generate → task_id 반환 → BackgroundTask가 AI 서버 호출 → 파일 저장 → 라이브러리 자동 등록
|
- 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록
|
||||||
|
|
||||||
|
**Provider 구조**
|
||||||
|
- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원
|
||||||
|
- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용
|
||||||
|
|
||||||
**music-lab API 목록**
|
**music-lab API 목록**
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| POST | `/api/music/generate` | 음악 생성 시작 (task_id 반환, 비동기) |
|
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
||||||
|
| POST | `/api/music/generate` | 음악 생성 시작 (provider, lyrics, instrumental 지원) |
|
||||||
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) |
|
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) |
|
||||||
|
| POST | `/api/music/lyrics` | Suno AI 가사 생성 (곡 생성 전 미리보기용) |
|
||||||
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
||||||
| POST | `/api/music/library` | 트랙 수동 추가 (201) |
|
| POST | `/api/music/library` | 트랙 수동 추가 (201) |
|
||||||
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) |
|
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) |
|
||||||
|
|
||||||
**환경변수**
|
**환경변수**
|
||||||
- `MUSIC_AI_SERVER_URL`: AI 음악 생성 서버 URL (미설정 시 생성 요청 실패)
|
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
||||||
|
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
|
||||||
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
||||||
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
||||||
|
|
||||||
**AI 서버 응답 형식 (2가지 모두 지원)**
|
**music_library 테이블 (확장 컬럼)**
|
||||||
- binary audio (Content-Type: audio/*) → 직접 저장
|
- `provider`: `suno` | `local` — 생성에 사용된 프로바이더
|
||||||
- JSON `{"audio_url": "..."}` → 해당 URL에서 다운로드 후 저장
|
- `lyrics`: Suno 생성 가사 텍스트
|
||||||
|
- `image_url`: Suno 생성 커버 이미지 URL
|
||||||
|
- `suno_id`: Suno 곡 ID (CDN 참조용)
|
||||||
|
|
||||||
|
**Suno 생성 특이사항**
|
||||||
|
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
|
||||||
|
- CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요
|
||||||
|
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]` 등
|
||||||
|
|
||||||
### travel-proxy (travel-proxy/)
|
### travel-proxy (travel-proxy/)
|
||||||
- 원본 사진: `/data/travel/` (RO)
|
- 원본 사진: `/data/travel/` (RO)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ 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}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ def init_db() -> None:
|
|||||||
audio_url TEXT,
|
audio_url TEXT,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
params TEXT NOT NULL DEFAULT '{}',
|
params TEXT NOT NULL DEFAULT '{}',
|
||||||
|
provider TEXT NOT NULL DEFAULT 'local',
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
)
|
)
|
||||||
@@ -46,11 +47,29 @@ def init_db() -> None:
|
|||||||
file_path TEXT NOT NULL DEFAULT '',
|
file_path TEXT NOT NULL DEFAULT '',
|
||||||
task_id TEXT,
|
task_id TEXT,
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
provider TEXT NOT NULL DEFAULT 'local',
|
||||||
|
lyrics TEXT NOT NULL DEFAULT '',
|
||||||
|
image_url TEXT NOT NULL DEFAULT '',
|
||||||
|
suno_id TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_library_created ON music_library(created_at DESC)")
|
||||||
|
|
||||||
|
# 기존 테이블 마이그레이션 (컬럼 없으면 추가)
|
||||||
|
for col, default in [
|
||||||
|
("provider", "'local'"), ("lyrics", "''"),
|
||||||
|
("image_url", "''"), ("suno_id", "''"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # 이미 존재
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE music_tasks ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -63,16 +82,17 @@ def _task_row_to_dict(r) -> Dict[str, Any]:
|
|||||||
"audio_url": r["audio_url"],
|
"audio_url": r["audio_url"],
|
||||||
"error": r["error"],
|
"error": r["error"],
|
||||||
"params": json.loads(r["params"]),
|
"params": json.loads(r["params"]),
|
||||||
|
"provider": r["provider"] if "provider" in r.keys() else "local",
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"updated_at": r["updated_at"],
|
"updated_at": r["updated_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_task(task_id: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
def create_task(task_id: str, params: Dict[str, Any], provider: str = "local") -> Dict[str, Any]:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO music_tasks (id, params) VALUES (?, ?)",
|
"INSERT INTO music_tasks (id, params, provider) VALUES (?, ?, ?)",
|
||||||
(task_id, json.dumps(params)),
|
(task_id, json.dumps(params), provider),
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
|
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||||
return _task_row_to_dict(row)
|
return _task_row_to_dict(row)
|
||||||
@@ -107,6 +127,7 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
|||||||
# ── music_library CRUD ────────────────────────────────────────────────────────
|
# ── music_library CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _track_row_to_dict(r) -> Dict[str, Any]:
|
def _track_row_to_dict(r) -> Dict[str, Any]:
|
||||||
|
keys = r.keys()
|
||||||
return {
|
return {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"title": r["title"],
|
"title": r["title"],
|
||||||
@@ -122,6 +143,10 @@ def _track_row_to_dict(r) -> Dict[str, Any]:
|
|||||||
"file_path": r["file_path"],
|
"file_path": r["file_path"],
|
||||||
"task_id": r["task_id"],
|
"task_id": r["task_id"],
|
||||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||||
|
"provider": r["provider"] if "provider" in keys else "local",
|
||||||
|
"lyrics": r["lyrics"] if "lyrics" in keys else "",
|
||||||
|
"image_url": r["image_url"] if "image_url" in keys else "",
|
||||||
|
"suno_id": r["suno_id"] if "suno_id" in keys else "",
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +163,9 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
INSERT INTO music_library
|
INSERT INTO music_library
|
||||||
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
|
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
|
||||||
prompt, audio_url, file_path, task_id, tags)
|
prompt, audio_url, file_path, task_id, tags,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
provider, lyrics, image_url, suno_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
data.get("title", ""),
|
data.get("title", ""),
|
||||||
@@ -155,6 +181,10 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
data.get("file_path", ""),
|
data.get("file_path", ""),
|
||||||
data.get("task_id"),
|
data.get("task_id"),
|
||||||
json.dumps(data.get("tags", [])),
|
json.dumps(data.get("tags", [])),
|
||||||
|
data.get("provider", "local"),
|
||||||
|
data.get("lyrics", ""),
|
||||||
|
data.get("image_url", ""),
|
||||||
|
data.get("suno_id", ""),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
|
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
|
||||||
|
|||||||
122
music-lab/app/local_provider.py
Normal file
122
music-lab/app/local_provider.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
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"
|
||||||
|
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))
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
import requests
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .db import (
|
from .db import (
|
||||||
init_db,
|
init_db,
|
||||||
create_task, update_task, get_task,
|
create_task, get_task,
|
||||||
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
||||||
)
|
)
|
||||||
|
from .local_provider import run_local_generation
|
||||||
|
from .suno_provider import run_suno_generation, generate_lyrics, SUNO_API_KEY
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -25,9 +24,7 @@ app.add_middleware(
|
|||||||
allow_headers=["Content-Type"],
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")
|
|
||||||
MUSIC_DATA_DIR = "/app/data/music"
|
MUSIC_DATA_DIR = "/app/data/music"
|
||||||
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@@ -41,106 +38,31 @@ def health():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# ── 음악 생성 워커 ────────────────────────────────────────────────────────────
|
@app.get("/api/music/providers")
|
||||||
|
def get_providers():
|
||||||
def _run_generation(task_id: str, params: dict) -> None:
|
"""사용 가능한 음악 생성 프로바이더 목록 반환."""
|
||||||
"""BackgroundTask: AI 서버에 생성 요청 → 파일 저장 → 라이브러리 등록"""
|
providers = []
|
||||||
try:
|
if os.getenv("MUSIC_AI_SERVER_URL"):
|
||||||
update_task(task_id, "processing", 10, "AI 서버에 연결 중...")
|
providers.append({
|
||||||
|
"id": "local",
|
||||||
if not MUSIC_AI_SERVER_URL:
|
"name": "MusicGen",
|
||||||
update_task(task_id, "failed", 0, "", error="MUSIC_AI_SERVER_URL이 설정되지 않았습니다")
|
"description": "로컬 AI 서버 (인스트루멘탈 전용)",
|
||||||
return
|
"features": ["instrumental"],
|
||||||
|
|
||||||
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초 간격) — AI 서버 progress/message 그대로 반영
|
|
||||||
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/message를 로컬 task에 전달 (30~79 범위로 스케일)
|
|
||||||
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은 항상 Nginx 상대경로 (Mixed Content 방지)
|
|
||||||
audio_url = f"/media/music/{filename}"
|
|
||||||
|
|
||||||
# 라이브러리 자동 등록 — payload title 우선, 없으면 자동 생성
|
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
if SUNO_API_KEY:
|
||||||
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
|
providers.append({
|
||||||
|
"id": "suno",
|
||||||
except requests.Timeout:
|
"name": "Suno",
|
||||||
update_task(task_id, "failed", 0, "", error="AI 서버 타임아웃 (10분 초과)")
|
"description": "Suno AI (보컬·가사·인스트루멘탈)",
|
||||||
except Exception as e:
|
"features": ["vocals", "lyrics", "instrumental"],
|
||||||
update_task(task_id, "failed", 0, "", error=str(e))
|
})
|
||||||
|
return {"providers": providers}
|
||||||
|
|
||||||
|
|
||||||
# ── 음악 생성 API ─────────────────────────────────────────────────────────────
|
# ── 음악 생성 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
|
provider: str = "suno" # "suno" | "local"
|
||||||
title: str = ""
|
title: str = ""
|
||||||
genre: str = ""
|
genre: str = ""
|
||||||
moods: List[str] = []
|
moods: List[str] = []
|
||||||
@@ -150,19 +72,35 @@ class GenerateRequest(BaseModel):
|
|||||||
key: str = ""
|
key: str = ""
|
||||||
scale: str = ""
|
scale: str = ""
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
|
# Suno 전용
|
||||||
|
lyrics: str = "" # 커스텀 가사 ([Verse], [Chorus] 등)
|
||||||
|
instrumental: bool = False # True면 보컬 없이 인스트루멘탈만
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/music/generate")
|
@app.post("/api/music/generate")
|
||||||
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
|
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
|
||||||
"""
|
"""
|
||||||
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
|
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
|
||||||
생성 완료 시 music_library에 자동 등록됨.
|
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())
|
task_id = str(uuid.uuid4())
|
||||||
params = req.model_dump()
|
params = req.model_dump()
|
||||||
create_task(task_id, params)
|
create_task(task_id, params, provider=provider)
|
||||||
background_tasks.add_task(_run_generation, task_id, params)
|
|
||||||
return {"task_id": task_id}
|
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}")
|
@app.get("/api/music/status/{task_id}")
|
||||||
@@ -170,7 +108,7 @@ def get_status(task_id: str):
|
|||||||
"""
|
"""
|
||||||
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
|
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
|
||||||
status: queued | processing | succeeded | failed
|
status: queued | processing | succeeded | failed
|
||||||
succeeded 시 track 메타데이터 포함 (라이브러리 별도 저장 불필요).
|
succeeded 시 track 메타데이터 포함.
|
||||||
"""
|
"""
|
||||||
task = get_task(task_id)
|
task = get_task(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
@@ -182,10 +120,9 @@ def get_status(task_id: str):
|
|||||||
"message": task["message"],
|
"message": task["message"],
|
||||||
"audio_url": task["audio_url"],
|
"audio_url": task["audio_url"],
|
||||||
"error": task["error"],
|
"error": task["error"],
|
||||||
|
"provider": task["provider"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# succeeded 시 라이브러리에 저장된 트랙 메타데이터 포함
|
|
||||||
# 프론트는 이 track 객체로 UI를 바로 업데이트하면 됨 (Save 버튼 불필요)
|
|
||||||
if task["status"] == "succeeded":
|
if task["status"] == "succeeded":
|
||||||
track = get_track_by_task_id(task_id)
|
track = get_track_by_task_id(task_id)
|
||||||
resp["track"] = track
|
resp["track"] = track
|
||||||
@@ -193,6 +130,23 @@ def get_status(task_id: str):
|
|||||||
return resp
|
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 ────────────────────────────────────────────────────────────
|
# ── 라이브러리 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TrackCreate(BaseModel):
|
class TrackCreate(BaseModel):
|
||||||
@@ -209,6 +163,10 @@ class TrackCreate(BaseModel):
|
|||||||
file_path: str = ""
|
file_path: str = ""
|
||||||
task_id: Optional[str] = None
|
task_id: Optional[str] = None
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
|
provider: str = "local"
|
||||||
|
lyrics: str = ""
|
||||||
|
image_url: str = ""
|
||||||
|
suno_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/music/library")
|
@app.get("/api/music/library")
|
||||||
@@ -226,19 +184,16 @@ def save_to_library(req: TrackCreate):
|
|||||||
|
|
||||||
@app.delete("/api/music/library/{track_id}")
|
@app.delete("/api/music/library/{track_id}")
|
||||||
def remove_from_library(track_id: int):
|
def remove_from_library(track_id: int):
|
||||||
"""
|
"""라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제."""
|
||||||
라이브러리에서 트랙 삭제. 로컬 파일도 함께 삭제.
|
|
||||||
"""
|
|
||||||
file_path = get_track_file_path(track_id)
|
file_path = get_track_file_path(track_id)
|
||||||
ok = delete_track(track_id)
|
ok = delete_track(track_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=404, detail="Track not found")
|
raise HTTPException(status_code=404, detail="Track not found")
|
||||||
|
|
||||||
# 생성된 파일이 있으면 함께 삭제
|
|
||||||
if file_path and os.path.isfile(file_path):
|
if file_path and os.path.isfile(file_path):
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # 파일 삭제 실패해도 DB에서는 이미 삭제됨
|
pass
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
268
music-lab/app/suno_provider.py
Normal file
268
music-lab/app/suno_provider.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Suno API Provider — Suno REST API를 통한 음악 생성
|
||||||
|
https://apicast.suno.ai/v1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .db import update_task, add_track
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUNO_BASE_URL = "https://apicast.suno.ai/v1"
|
||||||
|
SUNO_API_KEY = os.getenv("SUNO_API_KEY", "")
|
||||||
|
MUSIC_DATA_DIR = "/app/data/music"
|
||||||
|
MUSIC_MEDIA_BASE = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
||||||
|
|
||||||
|
# 폴링 설정
|
||||||
|
POLL_INTERVAL = 6 # 초
|
||||||
|
POLL_MAX_ATTEMPTS = 50 # 최대 5분 (6초 × 50)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {SUNO_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_lyrics(prompt: str) -> Optional[dict]:
|
||||||
|
"""Suno 가사 생성 API 호출. 곡 생성 전 가사 미리보기용."""
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SUNO_BASE_URL}/lyrics",
|
||||||
|
headers=_headers(),
|
||||||
|
json={"prompt": prompt},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Suno lyrics API error: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_suno_generation(task_id: str, params: dict) -> None:
|
||||||
|
"""
|
||||||
|
BackgroundTask: Suno API로 곡 생성 → MP3 다운로드 → 라이브러리 등록.
|
||||||
|
Suno는 1회 생성 시 2개 변형을 반환하므로, 첫 번째를 메인으로 저장하고
|
||||||
|
두 번째는 별도 트랙으로 추가 등록한다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# ── 사전 검증 ──
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
update_task(task_id, "failed", 0, "",
|
||||||
|
error="SUNO_API_KEY가 설정되지 않았습니다. .env 파일을 확인하세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
update_task(task_id, "processing", 5, "Suno API에 연결 중...")
|
||||||
|
|
||||||
|
# ── 1단계: 곡 생성 요청 ──
|
||||||
|
payload = _build_suno_payload(params)
|
||||||
|
resp = requests.post(
|
||||||
|
f"{SUNO_BASE_URL}/songs",
|
||||||
|
headers=_headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
error_detail = resp.text[:300] if resp.text else f"HTTP {resp.status_code}"
|
||||||
|
update_task(task_id, "failed", 0, "", error=f"Suno API 오류: {error_detail}")
|
||||||
|
return
|
||||||
|
|
||||||
|
song_data = resp.json()
|
||||||
|
# Suno 응답 형태: 단일 객체 또는 리스트
|
||||||
|
songs = song_data if isinstance(song_data, list) else [song_data]
|
||||||
|
if not songs:
|
||||||
|
update_task(task_id, "failed", 0, "", error="Suno API 응답이 비어있습니다")
|
||||||
|
return
|
||||||
|
|
||||||
|
primary_song = songs[0]
|
||||||
|
suno_song_id = primary_song.get("id", "")
|
||||||
|
|
||||||
|
update_task(task_id, "processing", 15, "곡 생성 대기열에 등록됨...")
|
||||||
|
|
||||||
|
# ── 2단계: 상태 폴링 ──
|
||||||
|
completed_song = _poll_until_complete(task_id, suno_song_id)
|
||||||
|
if not completed_song:
|
||||||
|
return # 에러는 _poll_until_complete 내부에서 처리
|
||||||
|
|
||||||
|
update_task(task_id, "processing", 80, "오디오 파일 다운로드 중...")
|
||||||
|
|
||||||
|
# ── 3단계: 메인 트랙 다운로드 + 등록 ──
|
||||||
|
track = _download_and_register(
|
||||||
|
task_id=task_id,
|
||||||
|
song=completed_song,
|
||||||
|
params=params,
|
||||||
|
filename_suffix="",
|
||||||
|
)
|
||||||
|
if not track:
|
||||||
|
return
|
||||||
|
|
||||||
|
audio_url = track["audio_url"]
|
||||||
|
update_task(task_id, "succeeded", 100, "생성 완료", audio_url=audio_url)
|
||||||
|
|
||||||
|
# ── 4단계: 두 번째 변형이 있으면 추가 등록 ──
|
||||||
|
if len(songs) > 1:
|
||||||
|
second_id = songs[1].get("id", "")
|
||||||
|
if second_id:
|
||||||
|
try:
|
||||||
|
second_song = _fetch_song(second_id)
|
||||||
|
if second_song and second_song.get("status") == "complete":
|
||||||
|
_download_and_register(
|
||||||
|
task_id=f"{task_id}_v2",
|
||||||
|
song=second_song,
|
||||||
|
params=params,
|
||||||
|
filename_suffix="_v2",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # 보조 변형 실패는 무시
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
update_task(task_id, "failed", 0, "", error="Suno API 타임아웃")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Suno generation error for task %s", task_id)
|
||||||
|
update_task(task_id, "failed", 0, "", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_suno_payload(params: dict) -> dict:
|
||||||
|
"""프론트엔드 params → Suno API 요청 형식으로 변환."""
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
# 프롬프트 조합: prompt + genre + moods
|
||||||
|
parts = []
|
||||||
|
if params.get("prompt"):
|
||||||
|
parts.append(params["prompt"])
|
||||||
|
if params.get("genre"):
|
||||||
|
parts.append(params["genre"])
|
||||||
|
if params.get("moods"):
|
||||||
|
parts.append(", ".join(params["moods"]))
|
||||||
|
payload["prompt"] = " ".join(parts) if parts else "instrumental music"
|
||||||
|
|
||||||
|
# 스타일 태그
|
||||||
|
style_parts = []
|
||||||
|
if params.get("genre"):
|
||||||
|
style_parts.append(params["genre"])
|
||||||
|
if params.get("moods"):
|
||||||
|
style_parts.extend(params["moods"])
|
||||||
|
if params.get("instruments"):
|
||||||
|
style_parts.extend(params["instruments"][:3]) # 너무 많으면 잘림
|
||||||
|
if style_parts:
|
||||||
|
payload["style"] = " ".join(style_parts)
|
||||||
|
|
||||||
|
# 제목
|
||||||
|
if params.get("title"):
|
||||||
|
payload["title"] = params["title"]
|
||||||
|
|
||||||
|
# 가사 / 인스트루멘탈
|
||||||
|
if params.get("instrumental", False):
|
||||||
|
payload["instrumental"] = True
|
||||||
|
elif params.get("lyrics"):
|
||||||
|
payload["lyrics"] = params["lyrics"]
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_song(song_id: str) -> Optional[dict]:
|
||||||
|
"""Suno에서 단일 곡 상태 조회."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{SUNO_BASE_URL}/songs/{song_id}",
|
||||||
|
headers=_headers(),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Suno fetch song error: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_until_complete(task_id: str, suno_song_id: str) -> Optional[dict]:
|
||||||
|
"""Suno 곡 상태를 폴링하여 complete가 될 때까지 대기."""
|
||||||
|
for attempt in range(POLL_MAX_ATTEMPTS):
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
song = _fetch_song(suno_song_id)
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = song.get("status", "")
|
||||||
|
progress = min(15 + int((attempt / POLL_MAX_ATTEMPTS) * 65), 79)
|
||||||
|
|
||||||
|
if status == "streaming":
|
||||||
|
update_task(task_id, "processing", progress, "AI가 음악을 작곡 중...")
|
||||||
|
elif status == "complete":
|
||||||
|
return song
|
||||||
|
elif status == "error":
|
||||||
|
error_msg = song.get("error_message", "Suno 생성 실패")
|
||||||
|
update_task(task_id, "failed", 0, "", error=error_msg)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
update_task(task_id, "processing", progress, f"대기 중... ({status})")
|
||||||
|
|
||||||
|
update_task(task_id, "failed", 0, "", error="Suno 생성 타임아웃 (5분 초과)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_and_register(
|
||||||
|
task_id: str, song: dict, params: dict, filename_suffix: str = "",
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Suno CDN에서 MP3 다운로드 → 로컬 저장 → 라이브러리 등록."""
|
||||||
|
audio_url_remote = song.get("audio_url", "")
|
||||||
|
if not audio_url_remote:
|
||||||
|
update_task(task_id, "failed", 0, "", error="Suno 응답에 audio_url이 없습니다")
|
||||||
|
return None
|
||||||
|
|
||||||
|
filename = f"{task_id}{filename_suffix}.mp3"
|
||||||
|
file_path = os.path.join(MUSIC_DATA_DIR, filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dl = requests.get(audio_url_remote, timeout=120, stream=True)
|
||||||
|
dl.raise_for_status()
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
for chunk in dl.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
except Exception as e:
|
||||||
|
update_task(task_id, "failed", 0, "", error=f"오디오 다운로드 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
local_audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
|
||||||
|
|
||||||
|
# 메타데이터 조합
|
||||||
|
genre = params.get("genre", song.get("style", ""))
|
||||||
|
moods = params.get("moods", [])
|
||||||
|
mood_str = moods[0] if moods else "Original"
|
||||||
|
title = (
|
||||||
|
song.get("title")
|
||||||
|
or params.get("title")
|
||||||
|
or (f"{genre} — {mood_str} Mix" if genre else f"{mood_str} Mix")
|
||||||
|
)
|
||||||
|
|
||||||
|
track_data = {
|
||||||
|
"title": title,
|
||||||
|
"genre": genre,
|
||||||
|
"moods": moods,
|
||||||
|
"instruments": params.get("instruments", []),
|
||||||
|
"duration_sec": int(song["duration"]) if song.get("duration") else params.get("duration_sec"),
|
||||||
|
"bpm": params.get("bpm"),
|
||||||
|
"key": params.get("key", ""),
|
||||||
|
"scale": params.get("scale", ""),
|
||||||
|
"prompt": params.get("prompt", ""),
|
||||||
|
"audio_url": local_audio_url,
|
||||||
|
"file_path": file_path,
|
||||||
|
"task_id": task_id,
|
||||||
|
"provider": "suno",
|
||||||
|
"lyrics": song.get("lyrics", params.get("lyrics", "")),
|
||||||
|
"image_url": song.get("image_url", ""),
|
||||||
|
"suno_id": song.get("id", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
return add_track(track_data)
|
||||||
Reference in New Issue
Block a user