diff --git a/CLAUDE.md b/CLAUDE.md index c011082..8bbca22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,6 +268,7 @@ docker compose up -d | POST/GET | `/api/music/compile` (+ `/compiles/{id}/export`) | 컴파일 | | POST/GET/DELETE | `/api/music/video-project` (+ `/{id}/render`, `/export`) | 영상 프로젝트 | | ALL | `/api/music/pipeline` (생성/start/feedback/cancel/publish/retry/telegram-msg/lookup) | YouTube 자동화 파이프라인. `POST /{id}/retry`=실패 step 재개(publish+업로드완료 시 409) | +| DELETE | `/api/music/pipeline/{id}` | 파이프라인 행 하드 삭제(자식 jobs/feedback 포함, 전체 목록에서 제거). 없으면 404 | | GET/PUT | `/api/music/setup` | 파이프라인 설정 | | GET | `/api/music/youtube/auth-url`, `/callback`, `/status`; POST `/disconnect` | YouTube OAuth | | GET/POST/PUT/DELETE | `/api/music/revenue` (+ `/dashboard`) | 수익 기록 | diff --git a/music-lab/app/db.py b/music-lab/app/db.py index fe71a2f..bbf4046 100644 --- a/music-lab/app/db.py +++ b/music-lab/app/db.py @@ -1100,6 +1100,19 @@ def get_pipeline(pid: int) -> Optional[Dict[str, Any]]: return _parse_pipeline_row(row) +def delete_pipeline(pid: int) -> bool: + """파이프라인과 자식행(pipeline_feedback, pipeline_jobs)을 하드 삭제. + + SQLite FK를 강제하지 않으므로 자식행을 명시적으로 먼저 삭제한다. + 파이프라인이 존재했으면 True, 없었으면 False. + """ + with _conn() as conn: + conn.execute("DELETE FROM pipeline_feedback WHERE pipeline_id = ?", (pid,)) + conn.execute("DELETE FROM pipeline_jobs WHERE pipeline_id = ?", (pid,)) + cur = conn.execute("DELETE FROM video_pipelines WHERE id = ?", (pid,)) + return cur.rowcount > 0 + + def update_pipeline_state(pid: int, state: str, **fields) -> None: """파이프라인 state를 갱신하고 옵션 컬럼을 함께 업데이트한다. diff --git a/music-lab/app/main.py b/music-lab/app/main.py index 7747579..848ed3d 100644 --- a/music-lab/app/main.py +++ b/music-lab/app/main.py @@ -1133,6 +1133,14 @@ def cancel_pipeline(pid: int): return {"ok": True} +@app.delete("/api/music/pipeline/{pid}") +def delete_pipeline_endpoint(pid: int): + """파이프라인 행을 하드 삭제(전체 목록에서 완전 제거). 없으면 404.""" + if not _db_module.delete_pipeline(pid): + raise HTTPException(404) + return {"ok": True, "deleted": pid} + + @app.post("/api/music/pipeline/{pid}/retry", status_code=202) async def retry_pipeline(pid: int, bg: BackgroundTasks): from .pipeline.state_machine import STEPS diff --git a/music-lab/tests/test_pipeline_endpoints.py b/music-lab/tests/test_pipeline_endpoints.py index 208319b..6b60663 100644 --- a/music-lab/tests/test_pipeline_endpoints.py +++ b/music-lab/tests/test_pipeline_endpoints.py @@ -105,6 +105,29 @@ def test_cancel_pipeline(client): assert db.get_pipeline(pid)["state"] == "cancelled" +def test_delete_pipeline_removes_from_db(client): + pid = client.post("/api/music/pipeline", json={"track_id": 1}).json()["id"] + r = client.request("DELETE", f"/api/music/pipeline/{pid}") + assert r.status_code == 200 + assert r.json()["ok"] is True + assert db.get_pipeline(pid) is None + all_ids = [p["id"] for p in client.get("/api/music/pipeline?status=all").json()["pipelines"]] + assert pid not in all_ids + + +def test_delete_pipeline_not_found_returns_404(client): + r = client.request("DELETE", "/api/music/pipeline/99999") + assert r.status_code == 404 + + +def test_delete_pipeline_removes_child_jobs(client): + pid = client.post("/api/music/pipeline", json={"track_id": 1}).json()["id"] + db.create_pipeline_job(pid, "cover") + assert len(db.list_pipeline_jobs(pid)) == 1 + client.request("DELETE", f"/api/music/pipeline/{pid}") + assert db.list_pipeline_jobs(pid) == [] + + def test_setup_get_returns_defaults(client): r = client.get("/api/music/setup") assert r.status_code == 200