Compare commits
9 Commits
6f505b8cb1
...
4b28ef3afa
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b28ef3afa | |||
| 211aff1e45 | |||
| 37ca8e594e | |||
| c9a094969d | |||
| e8dbf8092a | |||
| 21cf0114f4 | |||
| 20f83cee33 | |||
| 1e77123394 | |||
| fbd8d26ec6 |
@@ -90,6 +90,29 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
video-lab:
|
||||||
|
build:
|
||||||
|
context: ./video-lab
|
||||||
|
container_name: video-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18801:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- VIDEO_DATA_DIR=/app/data
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/video:/app/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
insta-lab:
|
insta-lab:
|
||||||
build:
|
build:
|
||||||
context: ./insta-lab
|
context: ./insta-lab
|
||||||
@@ -265,6 +288,7 @@ services:
|
|||||||
- personal
|
- personal
|
||||||
- packs-lab
|
- packs-lab
|
||||||
- travel-proxy
|
- travel-proxy
|
||||||
|
- video-lab
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -274,6 +298,7 @@ services:
|
|||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||||
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
||||||
|
- ${RUNTIME_PATH}/data/video:/data/video:ro
|
||||||
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|||||||
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,17 @@ server {
|
|||||||
autoindex off;
|
autoindex off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# video media — Nginx 직접 mp4 서빙
|
||||||
|
location ^~ /media/video/ {
|
||||||
|
alias /data/video/;
|
||||||
|
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, max-age=86400" always;
|
||||||
|
add_header Accept-Ranges bytes always;
|
||||||
|
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
# music videos — Nginx가 직접 비디오 파일 서빙
|
# music videos — Nginx가 직접 비디오 파일 서빙
|
||||||
location ^~ /media/insta/ {
|
location ^~ /media/insta/ {
|
||||||
alias /data/insta_cards/;
|
alias /data/insta_cards/;
|
||||||
@@ -68,6 +79,21 @@ server {
|
|||||||
proxy_pass http://$music_backend$request_uri;
|
proxy_pass http://$music_backend$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# video-lab — 영상 생성 gateway
|
||||||
|
location /api/video/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $video_backend video-lab:8000;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_pass http://$video_backend$request_uri;
|
||||||
|
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
# realestate API
|
# realestate API
|
||||||
location /api/realestate/ {
|
location /api/realestate/ {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -230,6 +256,26 @@ server {
|
|||||||
proxy_pass http://$music_internal_backend$request_uri;
|
proxy_pass http://$music_internal_backend$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plan-B-Video — Windows video-render → NAS video-lab internal webhook
|
||||||
|
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
||||||
|
# Layer 3: X-Internal-Key (FastAPI dependency)
|
||||||
|
location /api/internal/video/ {
|
||||||
|
allow 192.168.45.0/24; # LAN 화이트리스트
|
||||||
|
allow 100.64.0.0/10; # Tailscale CGNAT
|
||||||
|
allow 127.0.0.1; # NAS 내부
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $video_internal_backend video-lab:8000;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Internal-Key $http_x_internal_key;
|
||||||
|
proxy_pass http://$video_internal_backend$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
# portfolio API (Stock) — trailing slash 유무 모두 매칭
|
||||||
location /api/portfolio {
|
location /api/portfolio {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
10
video-lab/Dockerfile
Normal file
10
video-lab/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
0
video-lab/app/__init__.py
Normal file
0
video-lab/app/__init__.py
Normal file
17
video-lab/app/auth.py
Normal file
17
video-lab/app/auth.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""SP-8 — Windows worker → NAS video-lab 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")
|
||||||
95
video-lab/app/db.py
Normal file
95
video-lab/app/db.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""SQLite persistence for video_tasks. Single table — task 단위 추적만."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.getenv("VIDEO_DATA_DIR", "/app/data"), "video.db")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS video_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'queued',
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
message TEXT DEFAULT '',
|
||||||
|
video_url TEXT,
|
||||||
|
error TEXT,
|
||||||
|
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"provider": row["provider"],
|
||||||
|
"params": row["params"],
|
||||||
|
"status": row["status"],
|
||||||
|
"progress": row["progress"],
|
||||||
|
"message": row["message"],
|
||||||
|
"video_url": row["video_url"],
|
||||||
|
"error": row["error"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(task_id: str, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO video_tasks (id, provider, params) VALUES (?, ?, ?)",
|
||||||
|
(task_id, provider, json.dumps(params)),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM video_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(
|
||||||
|
task_id: str,
|
||||||
|
status: str,
|
||||||
|
progress: int,
|
||||||
|
message: str = "",
|
||||||
|
video_url: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE video_tasks
|
||||||
|
SET status = ?, progress = ?, message = ?, video_url = ?, error = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, progress, message, video_url, error, task_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM video_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
52
video-lab/app/internal_router.py
Normal file
52
video-lab/app/internal_router.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""SP-8 — Windows video-render → NAS video-lab internal webhook.
|
||||||
|
|
||||||
|
POST /api/internal/video/update
|
||||||
|
- X-Internal-Key 인증 필수
|
||||||
|
- video_tasks row update (status, progress, message, video_url, error)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import 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 = ""
|
||||||
|
video_url: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
dependencies=[Depends(verify_internal_key)],
|
||||||
|
)
|
||||||
|
def video_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,
|
||||||
|
video_url=payload.video_url,
|
||||||
|
error=payload.error,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"internal/video/update task=%s status=%s progress=%d",
|
||||||
|
payload.task_id, payload.status, payload.progress,
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
117
video-lab/app/main.py
Normal file
117
video-lab/app/main.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""FastAPI entrypoint for video-lab.
|
||||||
|
|
||||||
|
POST /api/video/generate — provider + params → Redis push → task_id 반환
|
||||||
|
GET /api/video/tasks/{id} — DB 조회
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .internal_router import router as internal_router
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CORS_ALLOW_ORIGINS = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080")
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||||
|
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
|
||||||
|
SUPPORTED_PROVIDERS = {"sora", "veo", "kling", "seedance"}
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(internal_router)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[o.strip() for o in CORS_ALLOW_ORIGINS.split(",")],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "video-lab"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/video/providers")
|
||||||
|
def list_providers():
|
||||||
|
"""4 provider 항상 노출 (key 누락은 worker가 failed 보고)."""
|
||||||
|
return {"providers": [
|
||||||
|
{"id": "sora", "name": "Sora 2", "models": ["sora-2", "sora-2-pro"],
|
||||||
|
"durations": [8, 16, 20], "sizes": ["1280x720", "1920x1080", "1080x1920", "848x480", "480x848"]},
|
||||||
|
{"id": "veo", "name": "Veo 3.1", "models": ["veo-3.1-generate-001", "veo-3.1-fast-generate-001"],
|
||||||
|
"durations": [4, 6, 8], "aspect_ratios": ["16:9", "9:16"]},
|
||||||
|
{"id": "kling", "name": "Kling", "models": ["1.5", "1.6", "2.1", "2.1-master", "2.5", "2.6"],
|
||||||
|
"durations": [5, 10], "aspect_ratios": ["16:9", "9:16", "1:1"]},
|
||||||
|
{"id": "seedance", "name": "Seedance 2.0", "models": ["seedance-2.0"],
|
||||||
|
"durations": [4, 5, 6, 8, 10, 12, 15], "aspect_ratios": ["16:9", "9:16", "1:1", "4:3"]},
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
provider: str = Field(..., description="sora|veo|kling|seedance")
|
||||||
|
model: Optional[str] = None
|
||||||
|
prompt: str
|
||||||
|
# Optional 공통
|
||||||
|
duration: Optional[int] = None
|
||||||
|
aspect_ratio: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
negative_prompt: Optional[str] = None
|
||||||
|
# Provider 별 추가 키는 extra 허용
|
||||||
|
extra: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "allow"
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_render_job(task_id: str, job_type: str, params: dict) -> None:
|
||||||
|
"""Redis queue:video-render에 push."""
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
payload = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"kind": "video",
|
||||||
|
"job_type": job_type,
|
||||||
|
"params": params,
|
||||||
|
"submitted_at": datetime.now(kst).isoformat(),
|
||||||
|
}
|
||||||
|
await redis_client.rpush("queue:video-render", json.dumps(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/video/generate")
|
||||||
|
async def generate_video(req: GenerateRequest):
|
||||||
|
"""영상 생성 — Redis 큐로 Windows video-render에 위임."""
|
||||||
|
if req.provider not in SUPPORTED_PROVIDERS:
|
||||||
|
raise HTTPException(400, f"지원하지 않는 provider: {req.provider} (supported: {sorted(SUPPORTED_PROVIDERS)})")
|
||||||
|
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
params = req.model_dump(exclude_none=True)
|
||||||
|
db.create_task(task_id, req.provider, params)
|
||||||
|
|
||||||
|
job_type = f"{req.provider}_generation" # sora_generation, veo_generation, kling_generation, seedance_generation
|
||||||
|
await _push_render_job(task_id, job_type, params)
|
||||||
|
return {"task_id": task_id, "provider": req.provider}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/video/tasks/{task_id}")
|
||||||
|
def get_task_status(task_id: str):
|
||||||
|
t = db.get_task(task_id)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "task not found")
|
||||||
|
return t
|
||||||
6
video-lab/requirements.txt
Normal file
6
video-lab/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
redis>=5.0
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.21
|
||||||
|
httpx>=0.27.0
|
||||||
0
video-lab/tests/__init__.py
Normal file
0
video-lab/tests/__init__.py
Normal file
23
video-lab/tests/test_auth.py
Normal file
23
video-lab/tests/test_auth.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""verify_internal_key — Windows video-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
|
||||||
89
video-lab/tests/test_internal_router.py
Normal file
89
video-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""POST /api/internal/video/update — Windows video-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("VIDEO_DATA_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_video.db"))
|
||||||
|
db.init_db()
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task():
|
||||||
|
tid = "video-task-1"
|
||||||
|
db.create_task(tid, "sora", {"prompt": "test"})
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_with_valid_key_updates_db(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/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
|
||||||
|
assert task["message"] == "downloading"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_with_invalid_key_returns_401(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "wrong"},
|
||||||
|
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_succeeded_with_video_url(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={
|
||||||
|
"task_id": tid, "status": "succeeded", "progress": 100,
|
||||||
|
"message": "완료", "video_url": "/media/video/video-task-1.mp4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
task = db.get_task(tid)
|
||||||
|
assert task["status"] == "succeeded"
|
||||||
|
assert task["video_url"] == "/media/video/video-task-1.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_failed_records_error(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Sora API rate limit"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
task = db.get_task(tid)
|
||||||
|
assert task["status"] == "failed"
|
||||||
|
assert "Sora" in (task.get("error") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_unknown_task_returns_404(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={"task_id": "nonexistent", "status": "processing", "progress": 10},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
Reference in New Issue
Block a user