From 9eef2c50150e7c36759d6ce3f5b641df5824d709 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 19 May 2026 04:39:31 +0900 Subject: [PATCH] feat(music-render): nas_client webhook adapter (SP-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAS DB 직접 접근 불가 → webhook_update_task/webhook_add_track으로 변환. X-Internal-Key 헤더 자동 첨부. 실패 시 raise 안 함 (logger.error). env var는 call time에 읽어 monkeypatch 테스트 호환성 확보. Plan-B-Music Phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/music-render/nas_client.py | 80 +++++++++++++++++++ .../music-render/tests/test_nas_client.py | 79 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 services/music-render/nas_client.py create mode 100644 services/music-render/tests/test_nas_client.py diff --git a/services/music-render/nas_client.py b/services/music-render/nas_client.py new file mode 100644 index 0000000..da08a85 --- /dev/null +++ b/services/music-render/nas_client.py @@ -0,0 +1,80 @@ +"""NAS webhook 어댑터 — Windows worker가 NAS DB 직접 접근 못하므로 HTTP로 위임. + +기존 NAS suno_provider/local_provider의 `update_task`, `add_track` 호출을 +이 모듈의 webhook_update_task/webhook_add_track으로 치환. + +webhook 실패는 raise하지 않고 logger.error로 기록 (provider 로직 흐름 유지). +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +logger = logging.getLogger(__name__) + +_TIMEOUT = 10.0 + + +def _post(payload: Dict[str, Any]) -> None: + nas_base_url = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18600") + internal_api_key = os.getenv("INTERNAL_API_KEY", "") + url = f"{nas_base_url}/api/internal/music/update" + try: + r = httpx.post( + url, + headers={"X-Internal-Key": internal_api_key}, + json=payload, + timeout=_TIMEOUT, + ) + if r.status_code != 200: + logger.error("webhook %s returned %d: %s", + payload.get("task_id"), r.status_code, r.text[:200]) + except Exception: + logger.exception("webhook %s 호출 실패", payload.get("task_id")) + + +def webhook_update_task( + task_id: str, + status: str, + progress: int, + message: str = "", + audio_url: Optional[str] = None, + error: Optional[str] = None, +) -> None: + """기존 update_task(task_id, status, progress, message, audio_url=None, error=None) 대체.""" + payload: Dict[str, Any] = { + "task_id": task_id, + "status": status, + "progress": progress, + "message": message, + } + if audio_url is not None: + payload["audio_url"] = audio_url + if error is not None: + payload["error"] = error + _post(payload) + + +def webhook_add_track( + task_id: str, + status: str, + progress: int, + message: str = "", + audio_url: Optional[str] = None, + track: Optional[Dict[str, Any]] = None, +) -> None: + """update + add_track을 한 webhook 호출로 결합 (NAS internal_router가 둘 다 처리).""" + payload: Dict[str, Any] = { + "task_id": task_id, + "status": status, + "progress": progress, + "message": message, + } + if audio_url is not None: + payload["audio_url"] = audio_url + if track is not None: + payload["track"] = track + _post(payload) diff --git a/services/music-render/tests/test_nas_client.py b/services/music-render/tests/test_nas_client.py new file mode 100644 index 0000000..4d49fd9 --- /dev/null +++ b/services/music-render/tests/test_nas_client.py @@ -0,0 +1,79 @@ +"""nas_client — webhook adapter tests.""" +import os +import pytest +import respx +import httpx + +from nas_client import webhook_update_task, webhook_add_track + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("NAS_BASE_URL", "http://nas-test:18600") + monkeypatch.setenv("INTERNAL_API_KEY", "test-key") + + +@respx.mock +def test_webhook_update_task_sends_x_internal_key(): + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-1", "processing", 30, message="downloading") + assert route.called + req = route.calls[0].request + assert req.headers["X-Internal-Key"] == "test-key" + import json + body = json.loads(req.content) + assert body["task_id"] == "task-1" + assert body["status"] == "processing" + assert body["progress"] == 30 + assert body["message"] == "downloading" + + +@respx.mock +def test_webhook_update_task_with_audio_url(): + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-2", "succeeded", 100, message="완료", + audio_url="/media/music/task-2.mp3") + import json + payload = json.loads(route.calls[0].request.content) + assert payload["audio_url"] == "/media/music/task-2.mp3" + assert payload["status"] == "succeeded" + + +@respx.mock +def test_webhook_update_task_with_error(): + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + webhook_update_task("task-3", "failed", 0, error="API rate limit") + import json + payload = json.loads(route.calls[0].request.content) + assert payload["error"] == "API rate limit" + + +@respx.mock +def test_webhook_add_track_uses_track_field(): + """add_track은 update와 동시에 (succeeded 시).""" + route = respx.post("http://nas-test:18600/api/internal/music/update").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + track = {"title": "x", "audio_url": "/media/music/t.mp3", "provider": "suno"} + webhook_add_track("task-4", "succeeded", 100, message="ok", + audio_url="/media/music/t.mp3", track=track) + import json + payload = json.loads(route.calls[0].request.content) + assert payload["track"]["title"] == "x" + assert payload["status"] == "succeeded" + + +@respx.mock +def test_webhook_swallows_network_error(caplog): + """webhook 실패해도 raise 안 함 (logger.error).""" + respx.post("http://nas-test:18600/api/internal/music/update").mock( + side_effect=httpx.ConnectError("no host") + ) + # raise 안 하면 통과 + webhook_update_task("task-5", "processing", 10)