music-lab 신규 서비스 추가 (AI 음악 생성 + 라이브러리 관리)

- music-lab/ 신규 서비스 (포트 18600)
  - POST /api/music/generate     비동기 음악 생성 (task_id 반환)
  - GET  /api/music/status/:id   폴링 (queued→processing→succeeded/failed)
  - GET  /api/music/library      라이브러리 조회
  - POST /api/music/library      트랙 수동 추가
  - DELETE /api/music/library/:id 트랙 삭제 (파일 포함)
- SQLite: music_tasks + music_library 테이블
- 생성 완료 시 라이브러리 자동 등록
- AI 서버 응답: binary audio / JSON audio_url 모두 지원
- nginx: /api/music/ 프록시 + /media/music/ 오디오 파일 직접 서빙
- docker-compose: music-lab 서비스 + frontend 볼륨 마운트 추가
- CLAUDE.md 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:32:26 +09:00
parent f1eab292a2
commit 868020f7ed
8 changed files with 723 additions and 0 deletions

9
music-lab/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

177
music-lab/app/db.py Normal file
View File

@@ -0,0 +1,177 @@
import os
import sqlite3
import json
from typing import Any, Dict, List, Optional
DB_PATH = "/app/data/music.db"
def _conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with _conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS music_tasks (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
audio_url TEXT,
error TEXT,
params TEXT NOT NULL DEFAULT '{}',
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'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_created ON music_tasks(created_at DESC)")
conn.execute("""
CREATE TABLE IF NOT EXISTS music_library (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
genre TEXT NOT NULL DEFAULT '',
moods TEXT NOT NULL DEFAULT '[]',
instruments TEXT NOT NULL DEFAULT '[]',
duration_sec INTEGER,
bpm INTEGER,
key TEXT NOT NULL DEFAULT '',
scale TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
audio_url TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
task_id TEXT,
tags TEXT NOT NULL DEFAULT '[]',
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)")
# ── music_tasks CRUD ──────────────────────────────────────────────────────────
def _task_row_to_dict(r) -> Dict[str, Any]:
return {
"task_id": r["id"],
"status": r["status"],
"progress": r["progress"],
"message": r["message"],
"audio_url": r["audio_url"],
"error": r["error"],
"params": json.loads(r["params"]),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
def create_task(task_id: str, params: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"INSERT INTO music_tasks (id, params) VALUES (?, ?)",
(task_id, json.dumps(params)),
)
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
return _task_row_to_dict(row)
def update_task(
task_id: str,
status: str,
progress: int,
message: str,
audio_url: str = None,
error: str = None,
) -> None:
with _conn() as conn:
conn.execute(
"""
UPDATE music_tasks
SET status = ?, progress = ?, message = ?, audio_url = ?, error = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?
""",
(status, progress, message, audio_url, error, task_id),
)
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM music_tasks WHERE id = ?", (task_id,)).fetchone()
return _task_row_to_dict(row) if row else None
# ── music_library CRUD ────────────────────────────────────────────────────────
def _track_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"title": r["title"],
"genre": r["genre"],
"moods": json.loads(r["moods"]) if r["moods"] else [],
"instruments": json.loads(r["instruments"]) if r["instruments"] else [],
"duration_sec": r["duration_sec"],
"bpm": r["bpm"],
"key": r["key"],
"scale": r["scale"],
"prompt": r["prompt"],
"audio_url": r["audio_url"],
"file_path": r["file_path"],
"task_id": r["task_id"],
"tags": json.loads(r["tags"]) if r["tags"] else [],
"created_at": r["created_at"],
}
def get_all_tracks() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM music_library ORDER BY created_at DESC").fetchall()
return [_track_row_to_dict(r) for r in rows]
def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"""
INSERT INTO music_library
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
prompt, audio_url, file_path, task_id, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
data.get("title", ""),
data.get("genre", ""),
json.dumps(data.get("moods", [])),
json.dumps(data.get("instruments", [])),
data.get("duration_sec"),
data.get("bpm"),
data.get("key", ""),
data.get("scale", ""),
data.get("prompt", ""),
data.get("audio_url", ""),
data.get("file_path", ""),
data.get("task_id"),
json.dumps(data.get("tags", [])),
),
)
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
return _track_row_to_dict(row)
def delete_track(track_id: int) -> bool:
with _conn() as conn:
# 파일 경로 먼저 조회 (삭제 후 파일도 지울 수 있도록)
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
if not row:
return False
conn.execute("DELETE FROM music_library WHERE id = ?", (track_id,))
return True
def get_track_file_path(track_id: int) -> Optional[str]:
with _conn() as conn:
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
return row["file_path"] if row else None

209
music-lab/app/main.py Normal file
View File

@@ -0,0 +1,209 @@
import os
import uuid
import requests
from typing import List, Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from .db import (
init_db,
create_task, update_task, get_task,
get_all_tracks, add_track, delete_track, get_track_file_path,
)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
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")
@app.on_event("startup")
def on_startup():
init_db()
os.makedirs(MUSIC_DATA_DIR, exist_ok=True)
@app.get("/health")
def health():
return {"ok": True}
# ── 음악 생성 워커 ────────────────────────────────────────────────────────────
def _run_generation(task_id: str, params: dict) -> None:
"""BackgroundTask: AI 서버에 생성 요청 → 파일 저장 → 라이브러리 등록"""
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, "음악 생성 중... (수 분 소요될 수 있습니다)")
resp = requests.post(
f"{MUSIC_AI_SERVER_URL}/generate",
json=params,
timeout=600, # 10분
stream=True,
)
if resp.status_code != 200:
update_task(task_id, "failed", 0, "", error=f"AI 서버 오류: {resp.status_code} {resp.text[:200]}")
return
update_task(task_id, "processing", 80, "파일 저장 중...")
# AI 서버 응답: binary audio 또는 JSON {"audio_url": "..."}
content_type = resp.headers.get("content-type", "")
filename = f"{task_id}.mp3"
file_path = os.path.join(MUSIC_DATA_DIR, filename)
if "application/json" in content_type:
result = resp.json()
remote_url = result.get("audio_url") or result.get("url")
if not remote_url:
update_task(task_id, "failed", 0, "", error="AI 서버 응답에 audio_url이 없습니다")
return
# 원격 URL에서 파일 다운로드
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)
else:
# binary audio 직접 저장
with open(file_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
audio_url = f"{MUSIC_MEDIA_BASE}/{filename}"
# 라이브러리 자동 등록
genre = params.get("genre", "")
title = f"{genre} {task_id[:8]}" if genre else task_id[:8]
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,
})
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:
update_task(task_id, "failed", 0, "", error=str(e))
# ── 음악 생성 API ─────────────────────────────────────────────────────────────
class GenerateRequest(BaseModel):
genre: str = ""
moods: List[str] = []
instruments: List[str] = []
duration_sec: Optional[int] = None
bpm: Optional[int] = None
key: str = ""
scale: str = ""
prompt: str = ""
@app.post("/api/music/generate")
def generate_music(req: GenerateRequest, background_tasks: BackgroundTasks):
"""
음악 생성 작업 시작. task_id 즉시 반환 후 백그라운드에서 AI 서버 호출.
생성 완료 시 music_library에 자동 등록됨.
"""
task_id = str(uuid.uuid4())
params = req.model_dump()
create_task(task_id, params)
background_tasks.add_task(_run_generation, task_id, params)
return {"task_id": task_id}
@app.get("/api/music/status/{task_id}")
def get_status(task_id: str):
"""
생성 작업 상태 조회. 프론트는 succeeded 또는 failed가 될 때까지 폴링.
status: queued | processing | succeeded | failed
"""
task = get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {
"status": task["status"],
"progress": task["progress"],
"message": task["message"],
"audio_url": task["audio_url"],
"error": task["error"],
}
# ── 라이브러리 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] = []
@app.get("/api/music/library")
def list_library():
"""저장된 트랙 목록 전체 조회 (생성일 내림차순)"""
return {"tracks": get_all_tracks()}
@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 # 파일 삭제 실패해도 DB에서는 이미 삭제됨
return {"ok": True}

View File

@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.30.6
requests==2.32.3
python-multipart==0.0.12