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:
0
music-lab/app/__init__.py
Normal file
0
music-lab/app/__init__.py
Normal file
177
music-lab/app/db.py
Normal file
177
music-lab/app/db.py
Normal 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
209
music-lab/app/main.py
Normal 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}
|
||||
Reference in New Issue
Block a user