feat(music-lab): pipeline 오케스트레이터 + 14 엔드포인트
This commit is contained in:
@@ -22,9 +22,12 @@ from .db import (
|
||||
create_compile_job, get_compile_jobs, get_compile_job,
|
||||
update_compile_job, delete_compile_job,
|
||||
)
|
||||
from . import db as _db_module
|
||||
from .compiler import run_compile
|
||||
from .market import ingest_trends, get_suggestions
|
||||
from .local_provider import run_local_generation
|
||||
from .pipeline import orchestrator
|
||||
from .pipeline import youtube as yt_module
|
||||
from .suno_provider import (
|
||||
run_suno_generation, run_suno_extend, run_vocal_removal,
|
||||
run_cover_image, run_wav_convert, run_stem_split,
|
||||
@@ -921,3 +924,194 @@ def list_market_reports(limit: int = 10):
|
||||
@app.get("/api/music/market/suggest")
|
||||
def market_suggest(limit: int = 5):
|
||||
return {"suggestions": get_suggestions(limit)}
|
||||
|
||||
|
||||
# ── Pipeline endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
class PipelineCreate(BaseModel):
|
||||
track_id: int
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
step: str
|
||||
intent: str # approve | reject
|
||||
feedback_text: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/music/pipeline", status_code=201)
|
||||
def create_pipeline(req: PipelineCreate):
|
||||
actives = _db_module.list_pipelines(active_only=True)
|
||||
if any(p["track_id"] == req.track_id for p in actives):
|
||||
raise HTTPException(409, "이미 진행 중인 파이프라인이 있습니다")
|
||||
pid = _db_module.create_pipeline(req.track_id)
|
||||
return _db_module.get_pipeline(pid)
|
||||
|
||||
|
||||
@app.get("/api/music/pipeline")
|
||||
def list_pipelines_endpoint(status: str = "all"):
|
||||
pipelines = _db_module.list_pipelines(active_only=(status == "active"))
|
||||
return {"pipelines": pipelines}
|
||||
|
||||
|
||||
@app.get("/api/music/pipeline/lookup-by-msg/{msg_id}")
|
||||
def lookup_by_msg(msg_id: int):
|
||||
for p in _db_module.list_pipelines(active_only=True):
|
||||
for step, mid in p["last_telegram_msg_ids"].items():
|
||||
if mid == msg_id:
|
||||
return {"pipeline_id": p["id"], "step": step}
|
||||
raise HTTPException(404)
|
||||
|
||||
|
||||
@app.get("/api/music/pipeline/{pid}")
|
||||
def get_pipeline_endpoint(pid: int):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
p["jobs"] = _db_module.list_pipeline_jobs(pid)
|
||||
p["feedback"] = _db_module.get_feedback_history(pid)
|
||||
return p
|
||||
|
||||
|
||||
@app.post("/api/music/pipeline/{pid}/start", status_code=202)
|
||||
async def start_pipeline(pid: int, bg: BackgroundTasks):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
if p["state"] != "created":
|
||||
raise HTTPException(409, f"이미 시작됨 ({p['state']})")
|
||||
bg.add_task(orchestrator.run_step, pid, "cover")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _state_to_step(state: str) -> Optional[str]:
|
||||
return {
|
||||
"video_pending": "video",
|
||||
"thumb_pending": "thumb",
|
||||
"meta_pending": "meta",
|
||||
"ai_review": "review",
|
||||
"publish_pending": None, # 사용자 명시 발행 호출 필요
|
||||
"publishing": "publish",
|
||||
}.get(state)
|
||||
|
||||
|
||||
@app.post("/api/music/pipeline/{pid}/feedback", status_code=202)
|
||||
async def feedback(pid: int, req: FeedbackRequest, bg: BackgroundTasks):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
if p["state"] == "awaiting_manual":
|
||||
raise HTTPException(409, "수동 개입 대기 중")
|
||||
state = p["state"]
|
||||
expected = f"{req.step}_pending"
|
||||
if state != expected:
|
||||
# 멱등 처리 — 이미 다음 단계로 넘어갔으면 무시
|
||||
return {"ok": True, "skipped": True}
|
||||
|
||||
if req.intent == "approve":
|
||||
from .pipeline.state_machine import next_state_on_approve
|
||||
next_st = next_state_on_approve(state)
|
||||
_db_module.update_pipeline_state(pid, next_st)
|
||||
next_step = _state_to_step(next_st)
|
||||
if next_step:
|
||||
bg.add_task(orchestrator.run_step, pid, next_step)
|
||||
return {"ok": True}
|
||||
|
||||
elif req.intent == "reject":
|
||||
count = _db_module.increment_feedback_count(pid, req.step)
|
||||
if count > 5:
|
||||
_db_module.update_pipeline_state(pid, "awaiting_manual")
|
||||
raise HTTPException(409, "재생성 한도 초과")
|
||||
if req.feedback_text:
|
||||
_db_module.record_feedback(pid, req.step, req.feedback_text)
|
||||
bg.add_task(orchestrator.run_step, pid, req.step, req.feedback_text or "")
|
||||
return {"ok": True}
|
||||
|
||||
else:
|
||||
raise HTTPException(400, f"unknown intent: {req.intent}")
|
||||
|
||||
|
||||
@app.post("/api/music/pipeline/{pid}/cancel")
|
||||
def cancel_pipeline(pid: int):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
_db_module.update_pipeline_state(pid, "cancelled", cancelled_at=_db_module._now())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/music/pipeline/{pid}/publish", status_code=202)
|
||||
async def publish_pipeline(pid: int, bg: BackgroundTasks):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
if p["state"] != "publish_pending":
|
||||
raise HTTPException(409, f"발행 단계 아님 ({p['state']})")
|
||||
_db_module.update_pipeline_state(pid, "publishing")
|
||||
bg.add_task(orchestrator.run_step, pid, "publish")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# Telegram 메시지 매칭용 엔드포인트 (agent-office용)
|
||||
|
||||
class TelegramMsgPatch(BaseModel):
|
||||
step: str
|
||||
message_id: int
|
||||
|
||||
|
||||
@app.patch("/api/music/pipeline/{pid}/telegram-msg")
|
||||
def save_telegram_msg(pid: int, req: TelegramMsgPatch):
|
||||
p = _db_module.get_pipeline(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
ids = p["last_telegram_msg_ids"]
|
||||
ids[req.step] = req.message_id
|
||||
_db_module.update_pipeline_state(
|
||||
pid, p["state"], last_telegram_msg_ids=json.dumps(ids)
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Setup endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
metadata_template: Optional[Dict[str, Any]] = None
|
||||
cover_prompts: Optional[Dict[str, Any]] = None
|
||||
review_weights: Optional[Dict[str, Any]] = None
|
||||
review_threshold: Optional[int] = None
|
||||
visual_defaults: Optional[Dict[str, Any]] = None
|
||||
publish_policy: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@app.get("/api/music/setup")
|
||||
def get_setup():
|
||||
return _db_module.get_youtube_setup()
|
||||
|
||||
|
||||
@app.put("/api/music/setup")
|
||||
def put_setup(req: SetupRequest):
|
||||
payload = {k: v for k, v in req.dict().items() if v is not None}
|
||||
_db_module.update_youtube_setup(**payload)
|
||||
return _db_module.get_youtube_setup()
|
||||
|
||||
|
||||
# ── YouTube OAuth endpoints ───────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/music/youtube/auth-url")
|
||||
def youtube_auth_url():
|
||||
return {"url": yt_module.get_auth_url()}
|
||||
|
||||
|
||||
@app.get("/api/music/youtube/callback")
|
||||
async def youtube_callback(code: str):
|
||||
return await yt_module.exchange_code(code)
|
||||
|
||||
|
||||
@app.post("/api/music/youtube/disconnect")
|
||||
def youtube_disconnect():
|
||||
yt_module.disconnect()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/music/youtube/status")
|
||||
def youtube_status():
|
||||
return yt_module.get_status() or {"connected": False}
|
||||
|
||||
Reference in New Issue
Block a user