feat(music-lab): verify_internal_key + /api/internal/music/update (SP-6)
X-Internal-Key 헤더 검증 dependency (insta-lab 동일 패턴). Windows music-render webhook 수신 endpoint — update_task + 옵션 add_track. Plan-B-Music Phase 1 (수신부). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
music-lab/app/auth.py
Normal file
17
music-lab/app/auth.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""SP-6 — Windows worker → NAS internal webhook 인증.
|
||||
|
||||
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
|
||||
서버 측 키 미설정 시 401 (안전한 기본값).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
|
||||
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
expected = os.getenv("INTERNAL_API_KEY")
|
||||
if not expected:
|
||||
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||
if x_internal_key != expected:
|
||||
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||
61
music-lab/app/internal_router.py
Normal file
61
music-lab/app/internal_router.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""SP-6 — Windows music-render → NAS internal webhook.
|
||||
|
||||
POST /api/internal/music/update
|
||||
- X-Internal-Key 인증 필수
|
||||
- music_tasks 테이블 row update (status, progress, message, audio_url, error)
|
||||
- 옵션 `track` 페이로드가 있으면 music_library에 add_track 호출
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import db
|
||||
from .auth import verify_internal_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdatePayload(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="processing|succeeded|failed")
|
||||
progress: int = Field(..., ge=0, le=100)
|
||||
message: str = ""
|
||||
audio_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
track: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/internal/music/update",
|
||||
dependencies=[Depends(verify_internal_key)],
|
||||
)
|
||||
def music_update(payload: UpdatePayload):
|
||||
task = db.get_task(payload.task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||
|
||||
db.update_task(
|
||||
payload.task_id,
|
||||
payload.status,
|
||||
payload.progress,
|
||||
message=payload.message,
|
||||
audio_url=payload.audio_url,
|
||||
error=payload.error,
|
||||
)
|
||||
|
||||
if payload.track:
|
||||
try:
|
||||
db.add_track(payload.track)
|
||||
except Exception:
|
||||
logger.exception("add_track 실패 task=%s (무시)", payload.task_id)
|
||||
|
||||
logger.info(
|
||||
"internal/music/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
)
|
||||
return {"ok": True}
|
||||
23
music-lab/tests/test_auth.py
Normal file
23
music-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""verify_internal_key dependency — Windows music-render webhook 인증."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from app.auth import verify_internal_key
|
||||
|
||||
|
||||
def test_valid_key_passes(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
verify_internal_key(x_internal_key="secret123")
|
||||
|
||||
|
||||
def test_invalid_key_raises_401(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="wrong")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_missing_env_key_raises_401(monkeypatch):
|
||||
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_internal_key(x_internal_key="any")
|
||||
assert exc.value.status_code == 401
|
||||
103
music-lab/tests/test_internal_router.py
Normal file
103
music-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""POST /api/internal/music/update — Windows music-render webhook."""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from app.internal_router import router
|
||||
from app import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_key(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MUSIC_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_music.db"))
|
||||
db.init_db()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_task():
|
||||
tid = "test-task-1"
|
||||
db.create_task(tid, {"provider": "suno", "title": "T"}, provider="suno")
|
||||
return tid
|
||||
|
||||
|
||||
def test_update_with_valid_key_updates_db(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/music/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "processing"
|
||||
assert task["progress"] == 30
|
||||
|
||||
|
||||
def test_update_with_invalid_key_returns_401(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/music/update",
|
||||
headers={"X-Internal-Key": "wrong"},
|
||||
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_update_succeeded_with_audio_url(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/music/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={
|
||||
"task_id": tid, "status": "succeeded", "progress": 100,
|
||||
"message": "완료", "audio_url": "/media/music/test-task-1.mp3",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "succeeded"
|
||||
assert task["audio_url"] == "/media/music/test-task-1.mp3"
|
||||
|
||||
|
||||
def test_update_failed_records_error(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/music/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Suno API rate limit"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
task = db.get_task(tid)
|
||||
assert task["status"] == "failed"
|
||||
assert "Suno" in (task.get("error") or "")
|
||||
|
||||
|
||||
def test_add_track_action_inserts_library(client):
|
||||
tid = _make_task()
|
||||
r = client.post(
|
||||
"/api/internal/music/update",
|
||||
headers={"X-Internal-Key": "test-secret"},
|
||||
json={
|
||||
"task_id": tid, "status": "succeeded", "progress": 100,
|
||||
"message": "ok",
|
||||
"track": {
|
||||
"title": "My Song", "genre": "lofi", "moods": ["chill"],
|
||||
"instruments": [], "duration_sec": 180, "bpm": 80,
|
||||
"key": "C", "scale": "major", "prompt": "",
|
||||
"audio_url": "/media/music/test-task-1.mp3",
|
||||
"file_path": "/app/data/test-task-1.mp3",
|
||||
"task_id": tid, "provider": "suno",
|
||||
"lyrics": "", "image_url": "", "suno_id": "suno-abc",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
tracks = db.get_all_tracks()
|
||||
assert any(t["title"] == "My Song" for t in tracks)
|
||||
Reference in New Issue
Block a user