feat: professionalize control plane and standalone delivery

This commit is contained in:
theshy
2026-04-07 10:46:30 +08:00
parent d0cf1fd0df
commit 862db502b0
100 changed files with 8313 additions and 1483 deletions

177
tests/test_serializers.py Normal file
View File

@ -0,0 +1,177 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from biliup_next.app.serializers import ControlPlaneSerializer
from biliup_next.core.models import ActionRecord, Artifact, Task, TaskContext, TaskStep
class FakeSerializerRepo:
def __init__(
self,
*,
task: Task,
context: TaskContext | None = None,
steps: list[TaskStep] | None = None,
artifacts: list[Artifact] | None = None,
actions: list[ActionRecord] | None = None,
) -> None:
self.task = task
self.context = context
self.steps = steps or []
self.artifacts = artifacts or []
self.actions = actions or []
def get_task(self, task_id: str) -> Task | None:
return self.task if task_id == self.task.id else None
def get_task_context(self, task_id: str) -> TaskContext | None:
return self.context if task_id == self.task.id else None
def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]:
if self.context and self.context.task_id in task_ids:
return {self.context.task_id: self.context}
return {}
def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]:
if self.task.id in task_ids:
return {self.task.id: list(self.steps)}
return {}
def list_steps(self, task_id: str) -> list[TaskStep]:
return list(self.steps) if task_id == self.task.id else []
def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]:
if self.context and self.context.session_key == session_key:
return [self.context]
return []
def list_artifacts(self, task_id: str) -> list[Artifact]:
return list(self.artifacts) if task_id == self.task.id else []
def list_action_records(self, task_id: str, limit: int = 200) -> list[ActionRecord]:
return list(self.actions)[:limit] if task_id == self.task.id else []
class SerializerTests(unittest.TestCase):
def test_task_payload_includes_context_retry_and_delivery_state(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00")
session_dir = Path(tmpdir) / "session" / "task-title"
session_dir.mkdir(parents=True, exist_ok=True)
(session_dir / "full_video_bvid.txt").write_text("BVFULL123", encoding="utf-8")
(session_dir / "bvid.txt").write_text("BVSPLIT123", encoding="utf-8")
steps = [
TaskStep(None, "task-1", "publish", "failed_retryable", "ERR", "upload failed", 1, None, "2099-01-01T00:00:00+00:00"),
]
context = TaskContext(
id=None,
task_id="task-1",
session_key="session-1",
streamer="streamer",
room_id="room",
source_title="task-title",
segment_started_at=None,
segment_duration_seconds=None,
full_video_bvid=None,
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
)
repo = FakeSerializerRepo(task=task, context=context, steps=steps)
state = {
"repo": repo,
"settings": {
"paths": {"session_dir": str(Path(tmpdir) / "session")},
"comment": {"post_split_comment": True, "post_full_video_timeline_comment": True},
"cleanup": {},
"publish": {"retry_schedule_minutes": [10]},
},
}
payload = ControlPlaneSerializer(state).task_payload("task-1")
self.assertIsNotNone(payload)
self.assertEqual(payload["session_context"]["session_key"], "session-1")
self.assertEqual(payload["session_context"]["full_video_bvid"], "BVFULL123")
self.assertEqual(payload["retry_state"]["step_name"], "publish")
self.assertEqual(payload["delivery_state"]["split_comment"], "pending")
def test_session_payload_reuses_task_payload_serialization(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00")
context = TaskContext(
id=None,
task_id="task-1",
session_key="session-1",
streamer="streamer",
room_id="room",
source_title="task-title",
segment_started_at=None,
segment_duration_seconds=None,
full_video_bvid="BVFULL123",
created_at="2026-01-01T00:00:00+00:00",
updated_at="2026-01-01T00:00:00+00:00",
)
repo = FakeSerializerRepo(task=task, context=context)
state = {
"repo": repo,
"settings": {
"paths": {"session_dir": str(Path(tmpdir) / "session")},
"comment": {"post_split_comment": True, "post_full_video_timeline_comment": True},
"cleanup": {},
"publish": {},
},
}
payload = ControlPlaneSerializer(state).session_payload("session-1")
self.assertIsNotNone(payload)
self.assertEqual(payload["session_key"], "session-1")
self.assertEqual(payload["task_count"], 1)
self.assertEqual(payload["full_video_url"], "https://www.bilibili.com/video/BVFULL123")
self.assertEqual(payload["tasks"][0]["id"], "task-1")
def test_timeline_payload_includes_task_step_artifact_and_action_entries(self) -> None:
task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00")
steps = [
TaskStep(None, "task-1", "comment", "succeeded", None, None, 0, "2026-01-01T00:01:00+00:00", "2026-01-01T00:01:30+00:00"),
]
artifacts = [
Artifact(None, "task-1", "publish_bvid", "/tmp/bvid.txt", "{}", "2026-01-01T00:01:40+00:00"),
]
actions = [
ActionRecord(
id=None,
task_id="task-1",
action_name="comment",
status="ok",
summary="comment succeeded",
details_json=json.dumps({"split": {"status": "ok"}, "full": {"status": "skipped"}}),
created_at="2026-01-01T00:01:50+00:00",
)
]
repo = FakeSerializerRepo(task=task, steps=steps, artifacts=artifacts, actions=actions)
state = {
"repo": repo,
"settings": {
"paths": {"session_dir": "/tmp/session"},
"comment": {"post_split_comment": True, "post_full_video_timeline_comment": True},
"cleanup": {},
"publish": {},
},
}
payload = ControlPlaneSerializer(state).timeline_payload("task-1")
self.assertIsNotNone(payload)
action_item = next(item for item in payload["items"] if item["kind"] == "action")
self.assertIn("split=ok", action_item["summary"])
kinds = {item["kind"] for item in payload["items"]}
self.assertTrue({"task", "step", "artifact", "action"}.issubset(kinds))
if __name__ == "__main__":
unittest.main()