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

View File

@ -8,13 +8,21 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from biliup_next.app.task_actions import bind_full_video_action
from biliup_next.app.task_actions import merge_session_action
from biliup_next.app.task_actions import receive_full_video_webhook
from biliup_next.app.task_actions import rebind_session_full_video_action
from biliup_next.app.task_actions import reset_to_step_action
from biliup_next.app.task_actions import retry_step_action
from biliup_next.app.task_actions import run_task_action
from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.bootstrap import reset_initialized_state
from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher
from biliup_next.app.dashboard import render_dashboard_html
from biliup_next.app.control_plane_post_dispatcher import ControlPlanePostDispatcher
from biliup_next.app.retry_meta import retry_meta_for_step
from biliup_next.app.scheduler import build_scheduler_preview
from biliup_next.app.serializers import ControlPlaneSerializer
from biliup_next.app.worker import run_once
from biliup_next.core.config import SettingsService
from biliup_next.core.models import ActionRecord, utc_now_iso
@ -28,61 +36,32 @@ from biliup_next.infra.systemd_runtime import SystemdRuntime
class ApiHandler(BaseHTTPRequestHandler):
server_version = "biliup-next/0.1"
def _task_payload(self, task_id: str, state: dict[str, object]) -> dict[str, object] | None:
task = state["repo"].get_task(task_id)
if task is None:
return None
payload = task.to_dict()
retry_state = self._task_retry_state(task_id, state)
if retry_state:
payload["retry_state"] = retry_state
payload["delivery_state"] = self._task_delivery_state(task_id, state)
return payload
@staticmethod
def _attention_state(task_payload: dict[str, object]) -> str:
if task_payload.get("status") == "failed_manual":
return "manual_now"
retry_state = task_payload.get("retry_state")
if isinstance(retry_state, dict) and retry_state.get("retry_due"):
return "retry_now"
if task_payload.get("status") == "failed_retryable" and isinstance(retry_state, dict) and retry_state.get("next_retry_at"):
return "waiting_retry"
if task_payload.get("status") == "running":
return "running"
return "stable"
@staticmethod
def _delivery_state_label(task_payload: dict[str, object]) -> str:
delivery_state = task_payload.get("delivery_state")
if not isinstance(delivery_state, dict):
return "stable"
if delivery_state.get("split_comment") == "pending" or delivery_state.get("full_video_timeline_comment") == "pending":
return "pending_comment"
if delivery_state.get("source_video_present") is False or delivery_state.get("split_videos_present") is False:
return "cleanup_removed"
return "stable"
def _step_payload(self, step, state: dict[str, object]) -> dict[str, object]: # type: ignore[no-untyped-def]
payload = step.to_dict()
retry_meta = retry_meta_for_step(step, state["settings"])
if retry_meta:
payload.update(retry_meta)
return payload
def _task_retry_state(self, task_id: str, state: dict[str, object]) -> dict[str, object] | None:
for step in state["repo"].list_steps(task_id):
retry_meta = retry_meta_for_step(step, state["settings"])
if retry_meta:
return {"step_name": step.step_name, **retry_meta}
return None
def _task_delivery_state(self, task_id: str, state: dict[str, object]) -> dict[str, object]:
task = state["repo"].get_task(task_id)
if task is None:
return {}
session_dir = Path(str(state["settings"]["paths"]["session_dir"])) / task.title
source_path = Path(task.source_path)
split_dir = session_dir / "split_video"
legacy_comment_done = (session_dir / "comment_done.flag").exists()
def comment_status(flag_name: str, *, enabled: bool) -> str:
if not enabled:
return "disabled"
if flag_name == "comment_full_done.flag" and legacy_comment_done and not (session_dir / flag_name).exists():
return "legacy_untracked"
return "done" if (session_dir / flag_name).exists() else "pending"
return {
"split_comment": comment_status("comment_split_done.flag", enabled=state["settings"]["comment"].get("post_split_comment", True)),
"full_video_timeline_comment": comment_status(
"comment_full_done.flag",
enabled=state["settings"]["comment"].get("post_full_video_timeline_comment", True),
),
"full_video_bvid_resolved": (session_dir / "full_video_bvid.txt").exists(),
"source_video_present": source_path.exists(),
"split_videos_present": split_dir.exists(),
"cleanup_enabled": {
"delete_source_video_after_collection_synced": state["settings"].get("cleanup", {}).get("delete_source_video_after_collection_synced", False),
"delete_split_videos_after_collection_synced": state["settings"].get("cleanup", {}).get("delete_split_videos_after_collection_synced", False),
},
}
return ControlPlaneSerializer(state).step_payload(step)
def _serve_asset(self, asset_name: str) -> None:
root = ensure_initialized()["root"]
@ -116,10 +95,22 @@ class ApiHandler(BaseHTTPRequestHandler):
dist = self._frontend_dist_dir()
if not (dist / "index.html").exists():
return False
if parsed_path in {"/ui", "/ui/"}:
if parsed_path in {"/", "/ui", "/ui/"}:
self._html((dist / "index.html").read_text(encoding="utf-8"))
return True
if parsed_path.startswith("/assets/"):
relative = parsed_path.removeprefix("/")
asset_path = dist / relative
if asset_path.exists() and asset_path.is_file():
body = asset_path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", self._guess_content_type(asset_path))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return True
if not parsed_path.startswith("/ui/"):
return False
@ -143,13 +134,16 @@ class ApiHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path.startswith("/ui") and self._serve_frontend_dist(parsed.path):
if (parsed.path == "/" or parsed.path.startswith("/ui") or parsed.path.startswith("/assets/")) and self._serve_frontend_dist(parsed.path):
return
if not self._check_auth(parsed.path):
return
if parsed.path.startswith("/assets/"):
self._serve_asset(parsed.path.removeprefix("/assets/"))
return
if parsed.path == "/classic":
self._html(render_dashboard_html())
return
if parsed.path == "/":
self._html(render_dashboard_html())
return
@ -158,16 +152,23 @@ class ApiHandler(BaseHTTPRequestHandler):
self._json({"ok": True})
return
state = ensure_initialized()
get_dispatcher = ControlPlaneGetDispatcher(
state,
attention_state_fn=self._attention_state,
delivery_state_label_fn=self._delivery_state_label,
build_scheduler_preview_fn=build_scheduler_preview,
settings_service_factory=SettingsService,
)
if parsed.path == "/settings":
state = ensure_initialized()
service = SettingsService(state["root"])
self._json(service.load_redacted().settings)
body, status = get_dispatcher.handle_settings()
self._json(body, status=status)
return
if parsed.path == "/settings/schema":
state = ensure_initialized()
service = SettingsService(state["root"])
self._json(service.load().schema)
body, status = get_dispatcher.handle_settings_schema()
self._json(body, status=status)
return
if parsed.path == "/doctor":
@ -180,8 +181,8 @@ class ApiHandler(BaseHTTPRequestHandler):
return
if parsed.path == "/scheduler/preview":
state = ensure_initialized()
self._json(build_scheduler_preview(state, include_stage_scan=False, limit=200))
body, status = get_dispatcher.handle_scheduler_preview()
self._json(body, status=status)
return
if parsed.path == "/logs":
@ -196,146 +197,78 @@ class ApiHandler(BaseHTTPRequestHandler):
return
if parsed.path == "/history":
state = ensure_initialized()
query = parse_qs(parsed.query)
limit = int(query.get("limit", ["100"])[0])
task_id = query.get("task_id", [None])[0]
action_name = query.get("action_name", [None])[0]
status = query.get("status", [None])[0]
items = [
item.to_dict()
for item in state["repo"].list_action_records(
task_id=task_id,
limit=limit,
action_name=action_name,
status=status,
)
]
self._json({"items": items})
body, http_status = get_dispatcher.handle_history(
limit=limit,
task_id=task_id,
action_name=action_name,
status=status,
)
self._json(body, status=http_status)
return
if parsed.path == "/modules":
state = ensure_initialized()
self._json({"items": state["registry"].list_manifests(), "discovered_manifests": state["manifests"]})
body, status = get_dispatcher.handle_modules()
self._json(body, status=status)
return
if parsed.path == "/tasks":
state = ensure_initialized()
query = parse_qs(parsed.query)
limit = int(query.get("limit", ["100"])[0])
tasks = [self._task_payload(task.id, state) for task in state["repo"].list_tasks(limit=limit)]
self._json({"items": tasks})
offset = int(query.get("offset", ["0"])[0])
status = query.get("status", [None])[0]
search = query.get("search", [None])[0]
sort = query.get("sort", ["updated_desc"])[0]
attention = query.get("attention", [None])[0]
delivery = query.get("delivery", [None])[0]
body, http_status = get_dispatcher.handle_tasks(
limit=limit,
offset=offset,
status=status,
search=search,
sort=sort,
attention=attention,
delivery=delivery,
)
self._json(body, status=http_status)
return
if parsed.path.startswith("/tasks/"):
state = ensure_initialized()
if parsed.path.startswith("/sessions/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 2:
task = self._task_payload(parts[1], state)
if task is None:
self._json({"error": "task not found"}, status=HTTPStatus.NOT_FOUND)
return
self._json(task)
body, status = get_dispatcher.handle_session(parts[1])
self._json(body, status=status)
return
if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 2:
body, status = get_dispatcher.handle_task(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "steps":
steps = [self._step_payload(step, state) for step in state["repo"].list_steps(parts[1])]
self._json({"items": steps})
body, status = get_dispatcher.handle_task_steps(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "context":
body, status = get_dispatcher.handle_task_context(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "artifacts":
artifacts = [artifact.to_dict() for artifact in state["repo"].list_artifacts(parts[1])]
self._json({"items": artifacts})
body, status = get_dispatcher.handle_task_artifacts(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "history":
actions = [item.to_dict() for item in state["repo"].list_action_records(parts[1], limit=100)]
self._json({"items": actions})
body, status = get_dispatcher.handle_task_history(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "timeline":
task = state["repo"].get_task(parts[1])
if task is None:
self._json({"error": "task not found"}, status=HTTPStatus.NOT_FOUND)
return
steps = state["repo"].list_steps(parts[1])
artifacts = state["repo"].list_artifacts(parts[1])
actions = state["repo"].list_action_records(parts[1], limit=200)
items: list[dict[str, object]] = []
if task.created_at:
items.append({
"kind": "task",
"time": task.created_at,
"title": "Task Created",
"summary": task.title,
"status": task.status,
})
if task.updated_at and task.updated_at != task.created_at:
items.append({
"kind": "task",
"time": task.updated_at,
"title": "Task Updated",
"summary": task.status,
"status": task.status,
})
for step in steps:
if step.started_at:
items.append({
"kind": "step",
"time": step.started_at,
"title": f"{step.step_name} started",
"summary": step.status,
"status": step.status,
})
if step.finished_at:
retry_meta = retry_meta_for_step(step, state["settings"])
retry_note = ""
if retry_meta and retry_meta.get("next_retry_at"):
retry_note = f" | next retry: {retry_meta['next_retry_at']}"
items.append({
"kind": "step",
"time": step.finished_at,
"title": f"{step.step_name} finished",
"summary": f"{step.error_message or step.status}{retry_note}",
"status": step.status,
"retry_state": retry_meta,
})
for artifact in artifacts:
if artifact.created_at:
items.append({
"kind": "artifact",
"time": artifact.created_at,
"title": artifact.artifact_type,
"summary": artifact.path,
"status": "created",
})
for action in actions:
summary = action.summary
try:
details = json.loads(action.details_json or "{}")
except json.JSONDecodeError:
details = {}
if action.action_name == "comment" and isinstance(details, dict):
split_status = details.get("split", {}).get("status")
full_status = details.get("full", {}).get("status")
fragments = []
if split_status:
fragments.append(f"split={split_status}")
if full_status:
fragments.append(f"full={full_status}")
if fragments:
summary = f"{summary} | {' '.join(fragments)}"
if action.action_name in {"collection_a", "collection_b"} and isinstance(details, dict):
cleanup = details.get("result", {}).get("cleanup") or details.get("cleanup")
if isinstance(cleanup, dict):
removed = cleanup.get("removed") or []
if removed:
summary = f"{summary} | cleanup removed={len(removed)}"
items.append({
"kind": "action",
"time": action.created_at,
"title": action.action_name,
"summary": summary,
"status": action.status,
})
items.sort(key=lambda item: str(item["time"]), reverse=True)
self._json({"items": items})
body, status = get_dispatcher.handle_task_timeline(parts[1])
self._json(body, status=status)
return
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
@ -353,74 +286,86 @@ class ApiHandler(BaseHTTPRequestHandler):
service = SettingsService(root)
service.save_staged_from_redacted(payload)
service.promote_staged()
reset_initialized_state()
ensure_initialized()
self._json({"ok": True})
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if not self._check_auth(parsed.path):
return
state = ensure_initialized()
dispatcher = ControlPlanePostDispatcher(
state,
bind_full_video_action=bind_full_video_action,
merge_session_action=merge_session_action,
receive_full_video_webhook=receive_full_video_webhook,
rebind_session_full_video_action=rebind_session_full_video_action,
reset_to_step_action=reset_to_step_action,
retry_step_action=retry_step_action,
run_task_action=run_task_action,
run_once=run_once,
stage_importer_factory=StageImporter,
systemd_runtime_factory=SystemdRuntime,
)
if parsed.path == "/webhooks/full-video-uploaded":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_webhook_full_video(payload)
self._json(body, status=status)
return
if parsed.path != "/tasks":
if parsed.path.startswith("/sessions/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "merge":
session_key = parts[1]
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_session_merge(session_key, payload)
self._json(body, status=status)
return
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "rebind":
session_key = parts[1]
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_session_rebind(session_key, payload)
self._json(body, status=status)
return
if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "bind-full-video":
task_id = parts[1]
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_bind_full_video(task_id, payload)
self._json(body, status=status)
return
if len(parts) == 4 and parts[0] == "tasks" and parts[2] == "actions":
task_id = parts[1]
action = parts[3]
if action == "run":
result = run_task_action(task_id)
self._json(result, status=HTTPStatus.ACCEPTED)
return
if action == "retry-step":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
step_name = payload.get("step_name")
if not step_name:
self._json({"error": "missing step_name"}, status=HTTPStatus.BAD_REQUEST)
return
result = retry_step_action(task_id, step_name)
self._json(result, status=HTTPStatus.ACCEPTED)
return
if action == "reset-to-step":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
step_name = payload.get("step_name")
if not step_name:
self._json({"error": "missing step_name"}, status=HTTPStatus.BAD_REQUEST)
return
result = reset_to_step_action(task_id, step_name)
self._json(result, status=HTTPStatus.ACCEPTED)
if action in {"run", "retry-step", "reset-to-step"}:
payload = {}
if action != "run":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_task_action(task_id, action, payload)
self._json(body, status=status)
return
if parsed.path == "/worker/run-once":
payload = run_once()
self._record_action(None, "worker_run_once", "ok", "worker run once invoked", payload)
self._json(payload, status=HTTPStatus.ACCEPTED)
body, status = dispatcher.handle_worker_run_once()
self._json(body, status=status)
return
if parsed.path.startswith("/runtime/services/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 4 and parts[0] == "runtime" and parts[1] == "services":
try:
payload = SystemdRuntime().act(parts[2], parts[3])
except ValueError as exc:
self._json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
return
self._record_action(None, "service_action", "ok" if payload.get("command_ok") else "error", f"{parts[3]} {parts[2]}", payload)
self._json(payload, status=HTTPStatus.ACCEPTED)
body, status = dispatcher.handle_runtime_service_action(parts[2], parts[3])
self._json(body, status=status)
return
if parsed.path == "/stage/import":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
source_path = payload.get("source_path")
if not source_path:
self._json({"error": "missing source_path"}, status=HTTPStatus.BAD_REQUEST)
return
state = ensure_initialized()
stage_dir = Path(state["settings"]["paths"]["stage_dir"])
try:
result = StageImporter().import_file(Path(source_path), stage_dir)
except Exception as exc:
self._json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
return
self._record_action(None, "stage_import", "ok", "imported file into stage", result)
self._json(result, status=HTTPStatus.CREATED)
body, status = dispatcher.handle_stage_import(payload)
self._json(body, status=status)
return
if parsed.path == "/stage/upload":
content_type = self.headers.get("Content-Type", "")
@ -437,44 +382,19 @@ class ApiHandler(BaseHTTPRequestHandler):
},
)
file_item = form["file"] if "file" in form else None
if file_item is None or not getattr(file_item, "filename", None):
self._json({"error": "missing file"}, status=HTTPStatus.BAD_REQUEST)
return
state = ensure_initialized()
stage_dir = Path(state["settings"]["paths"]["stage_dir"])
try:
result = StageImporter().import_upload(file_item.filename, file_item.file, stage_dir)
except Exception as exc:
self._json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
return
self._record_action(None, "stage_upload", "ok", "uploaded file into stage", result)
self._json(result, status=HTTPStatus.CREATED)
body, status = dispatcher.handle_stage_upload(file_item)
self._json(body, status=status)
return
if parsed.path == "/scheduler/run-once":
result = run_once()
self._record_action(None, "scheduler_run_once", "ok", "scheduler run once completed", result.get("scheduler", {}))
self._json(result, status=HTTPStatus.ACCEPTED)
body, status = dispatcher.handle_scheduler_run_once()
self._json(body, status=status)
return
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
source_path = payload.get("source_path")
if not source_path:
self._json({"error": "missing source_path"}, status=HTTPStatus.BAD_REQUEST)
return
state = ensure_initialized()
try:
task = state["ingest_service"].create_task_from_file(
Path(source_path),
state["settings"]["ingest"],
)
except Exception as exc: # keep API small for now
status = HTTPStatus.CONFLICT if exc.__class__.__name__ == "ModuleError" else HTTPStatus.INTERNAL_SERVER_ERROR
payload = exc.to_dict() if hasattr(exc, "to_dict") else {"error": str(exc)}
self._json(payload, status=status)
return
self._json(task.to_dict(), status=HTTPStatus.CREATED)
body, status = dispatcher.handle_create_task(payload)
self._json(body, status=status)
def log_message(self, format: str, *args) -> None: # noqa: A003
return
@ -510,7 +430,7 @@ class ApiHandler(BaseHTTPRequestHandler):
)
def _check_auth(self, path: str) -> bool:
if path in {"/", "/health", "/ui", "/ui/"} or path.startswith("/assets/") or path.startswith("/ui/assets/"):
if path in {"/", "/health", "/ui", "/ui/", "/classic"} or path.startswith("/assets/") or path.startswith("/ui/assets/"):
return True
state = ensure_initialized()
expected = str(state["settings"]["runtime"].get("control_token", "")).strip()

View File

@ -1,11 +1,11 @@
from __future__ import annotations
from pathlib import Path
from dataclasses import asdict
from pathlib import Path
from threading import RLock
from biliup_next.core.config import SettingsService
from biliup_next.core.registry import Registry
from biliup_next.infra.comment_flag_migration import CommentFlagMigrationService
from biliup_next.infra.db import Database
from biliup_next.infra.plugin_loader import PluginLoader
from biliup_next.infra.task_repository import TaskRepository
@ -22,56 +22,67 @@ def project_root() -> Path:
return Path(__file__).resolve().parents[3]
_APP_STATE: dict[str, object] | None = None
_APP_STATE_LOCK = RLock()
def reset_initialized_state() -> None:
global _APP_STATE
with _APP_STATE_LOCK:
_APP_STATE = None
def ensure_initialized() -> dict[str, object]:
root = project_root()
settings_service = SettingsService(root)
bundle = settings_service.load()
db_path = (root / bundle.settings["runtime"]["database_path"]).resolve()
db = Database(db_path)
db.initialize()
repo = TaskRepository(db)
registry = Registry()
plugin_loader = PluginLoader(root)
manifests = plugin_loader.load_manifests()
for manifest in manifests:
if not manifest.enabled_by_default:
continue
provider = plugin_loader.instantiate_provider(manifest)
provider_manifest = getattr(provider, "manifest", None)
if provider_manifest is None:
raise RuntimeError(f"provider missing manifest: {manifest.entrypoint}")
if provider_manifest.id != manifest.id or provider_manifest.provider_type != manifest.provider_type:
raise RuntimeError(f"provider manifest mismatch: {manifest.entrypoint}")
registry.register(
manifest.provider_type,
manifest.id,
provider,
provider_manifest,
)
session_dir = (root / bundle.settings["paths"]["session_dir"]).resolve()
imported = repo.bootstrap_from_legacy_sessions(session_dir)
comment_flag_migration = CommentFlagMigrationService().migrate(session_dir)
ingest_service = IngestService(registry, repo)
transcribe_service = TranscribeService(registry, repo)
song_detect_service = SongDetectService(registry, repo)
split_service = SplitService(registry, repo)
publish_service = PublishService(registry, repo)
comment_service = CommentService(registry, repo)
collection_service = CollectionService(registry, repo)
return {
"root": root,
"settings": bundle.settings,
"db": db,
"repo": repo,
"registry": registry,
"manifests": [asdict(m) for m in manifests],
"ingest_service": ingest_service,
"transcribe_service": transcribe_service,
"song_detect_service": song_detect_service,
"split_service": split_service,
"publish_service": publish_service,
"comment_service": comment_service,
"collection_service": collection_service,
"imported": imported,
"comment_flag_migration": comment_flag_migration,
}
global _APP_STATE
with _APP_STATE_LOCK:
if _APP_STATE is not None:
return _APP_STATE
root = project_root()
settings_service = SettingsService(root)
bundle = settings_service.load()
db_path = (root / bundle.settings["runtime"]["database_path"]).resolve()
db = Database(db_path)
db.initialize()
repo = TaskRepository(db)
registry = Registry()
plugin_loader = PluginLoader(root)
manifests = plugin_loader.load_manifests()
for manifest in manifests:
if not manifest.enabled_by_default:
continue
provider = plugin_loader.instantiate_provider(manifest)
provider_manifest = getattr(provider, "manifest", None)
if provider_manifest is None:
raise RuntimeError(f"provider missing manifest: {manifest.entrypoint}")
if provider_manifest.id != manifest.id or provider_manifest.provider_type != manifest.provider_type:
raise RuntimeError(f"provider manifest mismatch: {manifest.entrypoint}")
registry.register(
manifest.provider_type,
manifest.id,
provider,
provider_manifest,
)
ingest_service = IngestService(registry, repo)
transcribe_service = TranscribeService(registry, repo)
song_detect_service = SongDetectService(registry, repo)
split_service = SplitService(registry, repo)
publish_service = PublishService(registry, repo)
comment_service = CommentService(registry, repo)
collection_service = CollectionService(registry, repo)
_APP_STATE = {
"root": root,
"settings": bundle.settings,
"db": db,
"repo": repo,
"registry": registry,
"manifests": [asdict(m) for m in manifests],
"ingest_service": ingest_service,
"transcribe_service": transcribe_service,
"song_detect_service": song_detect_service,
"split_service": split_service,
"publish_service": publish_service,
"comment_service": comment_service,
"collection_service": collection_service,
}
return _APP_STATE

View File

@ -40,8 +40,8 @@ def main() -> None:
args = parser.parse_args()
if args.command == "init":
state = ensure_initialized()
print(json.dumps({"ok": True, "imported": state["imported"]}, ensure_ascii=False, indent=2))
ensure_initialized()
print(json.dumps({"ok": True}, ensure_ascii=False, indent=2))
return
if args.command == "doctor":
@ -93,9 +93,11 @@ def main() -> None:
if args.command == "create-task":
state = ensure_initialized()
settings = dict(state["settings"]["ingest"])
settings.update(state["settings"]["paths"])
task = state["ingest_service"].create_task_from_file(
Path(args.source_path),
state["settings"]["ingest"],
settings,
)
print(json.dumps(task.to_dict(), ensure_ascii=False, indent=2))
return

View File

@ -0,0 +1,123 @@
from __future__ import annotations
from http import HTTPStatus
from biliup_next.app.serializers import ControlPlaneSerializer
class ControlPlaneGetDispatcher:
def __init__(
self,
state: dict[str, object],
*,
attention_state_fn,
delivery_state_label_fn,
build_scheduler_preview_fn,
settings_service_factory,
) -> None: # type: ignore[no-untyped-def]
self.state = state
self.repo = state["repo"]
self.serializer = ControlPlaneSerializer(state)
self.attention_state_fn = attention_state_fn
self.delivery_state_label_fn = delivery_state_label_fn
self.build_scheduler_preview_fn = build_scheduler_preview_fn
self.settings_service_factory = settings_service_factory
def handle_settings(self) -> tuple[object, HTTPStatus]:
service = self.settings_service_factory(self.state["root"])
return service.load_redacted().settings, HTTPStatus.OK
def handle_settings_schema(self) -> tuple[object, HTTPStatus]:
service = self.settings_service_factory(self.state["root"])
return service.load().schema, HTTPStatus.OK
def handle_scheduler_preview(self) -> tuple[object, HTTPStatus]:
return self.build_scheduler_preview_fn(self.state, include_stage_scan=False, limit=200), HTTPStatus.OK
def handle_history(self, *, limit: int, task_id: str | None, action_name: str | None, status: str | None) -> tuple[object, HTTPStatus]:
items = [
item.to_dict()
for item in self.repo.list_action_records(
task_id=task_id,
limit=limit,
action_name=action_name,
status=status,
)
]
return {"items": items}, HTTPStatus.OK
def handle_modules(self) -> tuple[object, HTTPStatus]:
return {"items": self.state["registry"].list_manifests(), "discovered_manifests": self.state["manifests"]}, HTTPStatus.OK
def handle_tasks(
self,
*,
limit: int,
offset: int,
status: str | None,
search: str | None,
sort: str,
attention: str | None,
delivery: str | None,
) -> tuple[object, HTTPStatus]:
if attention or delivery:
task_items, _ = self.repo.query_tasks(
limit=5000,
offset=0,
status=status,
search=search,
sort=sort,
)
all_tasks = self.serializer.task_payloads_from_tasks(task_items)
filtered_tasks: list[dict[str, object]] = []
for item in all_tasks:
if attention and self.attention_state_fn(item) != attention:
continue
if delivery and self.delivery_state_label_fn(item) != delivery:
continue
filtered_tasks.append(item)
total = len(filtered_tasks)
tasks = filtered_tasks[offset:offset + limit]
else:
task_items, total = self.repo.query_tasks(
limit=limit,
offset=offset,
status=status,
search=search,
sort=sort,
)
tasks = self.serializer.task_payloads_from_tasks(task_items)
return {"items": tasks, "total": total, "limit": limit, "offset": offset}, HTTPStatus.OK
def handle_session(self, session_key: str) -> tuple[object, HTTPStatus]:
payload = self.serializer.session_payload(session_key)
if payload is None:
return {"error": "session not found"}, HTTPStatus.NOT_FOUND
return payload, HTTPStatus.OK
def handle_task(self, task_id: str) -> tuple[object, HTTPStatus]:
payload = self.serializer.task_payload(task_id)
if payload is None:
return {"error": "task not found"}, HTTPStatus.NOT_FOUND
return payload, HTTPStatus.OK
def handle_task_steps(self, task_id: str) -> tuple[object, HTTPStatus]:
return {"items": [self.serializer.step_payload(step) for step in self.repo.list_steps(task_id)]}, HTTPStatus.OK
def handle_task_context(self, task_id: str) -> tuple[object, HTTPStatus]:
payload = self.serializer.task_context_payload(task_id)
if payload is None:
return {"error": "task context not found"}, HTTPStatus.NOT_FOUND
return payload, HTTPStatus.OK
def handle_task_artifacts(self, task_id: str) -> tuple[object, HTTPStatus]:
return {"items": [artifact.to_dict() for artifact in self.repo.list_artifacts(task_id)]}, HTTPStatus.OK
def handle_task_history(self, task_id: str) -> tuple[object, HTTPStatus]:
return {"items": [item.to_dict() for item in self.repo.list_action_records(task_id, limit=100)]}, HTTPStatus.OK
def handle_task_timeline(self, task_id: str) -> tuple[object, HTTPStatus]:
payload = self.serializer.timeline_payload(task_id)
if payload is None:
return {"error": "task not found"}, HTTPStatus.NOT_FOUND
return payload, HTTPStatus.OK

View File

@ -0,0 +1,164 @@
from __future__ import annotations
import json
from http import HTTPStatus
from pathlib import Path
from biliup_next.core.models import ActionRecord, utc_now_iso
from biliup_next.infra.storage_guard import mb_to_bytes
class ControlPlanePostDispatcher:
def __init__(
self,
state: dict[str, object],
*,
bind_full_video_action,
merge_session_action,
receive_full_video_webhook,
rebind_session_full_video_action,
reset_to_step_action,
retry_step_action,
run_task_action,
run_once,
stage_importer_factory,
systemd_runtime_factory,
) -> None: # type: ignore[no-untyped-def]
self.state = state
self.repo = state["repo"]
self.bind_full_video_action = bind_full_video_action
self.merge_session_action = merge_session_action
self.receive_full_video_webhook = receive_full_video_webhook
self.rebind_session_full_video_action = rebind_session_full_video_action
self.reset_to_step_action = reset_to_step_action
self.retry_step_action = retry_step_action
self.run_task_action = run_task_action
self.run_once = run_once
self.stage_importer_factory = stage_importer_factory
self.systemd_runtime_factory = systemd_runtime_factory
def handle_webhook_full_video(self, payload: object) -> tuple[object, HTTPStatus]:
if not isinstance(payload, dict):
return {"error": "invalid payload"}, HTTPStatus.BAD_REQUEST
result = self.receive_full_video_webhook(payload)
if "error" in result:
return result, HTTPStatus.BAD_REQUEST
return result, HTTPStatus.ACCEPTED
def handle_session_merge(self, session_key: str, payload: object) -> tuple[object, HTTPStatus]:
if not isinstance(payload, dict) or not isinstance(payload.get("task_ids"), list):
return {"error": "missing task_ids"}, HTTPStatus.BAD_REQUEST
result = self.merge_session_action(session_key, [str(item) for item in payload["task_ids"]])
if "error" in result:
return result, HTTPStatus.BAD_REQUEST
return result, HTTPStatus.ACCEPTED
def handle_session_rebind(self, session_key: str, payload: object) -> tuple[object, HTTPStatus]:
full_video_bvid = str((payload or {}).get("full_video_bvid", "")).strip() if isinstance(payload, dict) else ""
if not full_video_bvid:
return {"error": "missing full_video_bvid"}, HTTPStatus.BAD_REQUEST
result = self.rebind_session_full_video_action(session_key, full_video_bvid)
if "error" in result:
status = HTTPStatus.NOT_FOUND if result["error"].get("code") == "SESSION_NOT_FOUND" else HTTPStatus.BAD_REQUEST
return result, status
return result, HTTPStatus.ACCEPTED
def handle_bind_full_video(self, task_id: str, payload: object) -> tuple[object, HTTPStatus]:
full_video_bvid = str((payload or {}).get("full_video_bvid", "")).strip() if isinstance(payload, dict) else ""
if not full_video_bvid:
return {"error": "missing full_video_bvid"}, HTTPStatus.BAD_REQUEST
result = self.bind_full_video_action(task_id, full_video_bvid)
if "error" in result:
status = HTTPStatus.NOT_FOUND if result["error"].get("code") == "TASK_NOT_FOUND" else HTTPStatus.BAD_REQUEST
return result, status
return result, HTTPStatus.ACCEPTED
def handle_task_action(self, task_id: str, action: str, payload: object) -> tuple[object, HTTPStatus]:
if action == "run":
return self.run_task_action(task_id), HTTPStatus.ACCEPTED
if action == "retry-step":
step_name = payload.get("step_name") if isinstance(payload, dict) else None
if not step_name:
return {"error": "missing step_name"}, HTTPStatus.BAD_REQUEST
return self.retry_step_action(task_id, step_name), HTTPStatus.ACCEPTED
if action == "reset-to-step":
step_name = payload.get("step_name") if isinstance(payload, dict) else None
if not step_name:
return {"error": "missing step_name"}, HTTPStatus.BAD_REQUEST
return self.reset_to_step_action(task_id, step_name), HTTPStatus.ACCEPTED
return {"error": "not found"}, HTTPStatus.NOT_FOUND
def handle_worker_run_once(self) -> tuple[object, HTTPStatus]:
payload = self.run_once()
self._record_action(None, "worker_run_once", "ok", "worker run once invoked", payload)
return payload, HTTPStatus.ACCEPTED
def handle_scheduler_run_once(self) -> tuple[object, HTTPStatus]:
payload = self.run_once()
self._record_action(None, "scheduler_run_once", "ok", "scheduler run once completed", payload.get("scheduler", {}))
return payload, HTTPStatus.ACCEPTED
def handle_runtime_service_action(self, service_name: str, action: str) -> tuple[object, HTTPStatus]:
try:
payload = self.systemd_runtime_factory().act(service_name, action)
except ValueError as exc:
return {"error": str(exc)}, HTTPStatus.BAD_REQUEST
self._record_action(None, "service_action", "ok" if payload.get("command_ok") else "error", f"{action} {service_name}", payload)
return payload, HTTPStatus.ACCEPTED
def handle_stage_import(self, payload: object) -> tuple[object, HTTPStatus]:
source_path = payload.get("source_path") if isinstance(payload, dict) else None
if not source_path:
return {"error": "missing source_path"}, HTTPStatus.BAD_REQUEST
stage_dir = Path(self.state["settings"]["paths"]["stage_dir"])
min_free_bytes = mb_to_bytes(self.state["settings"]["ingest"].get("stage_min_free_space_mb", 0))
try:
result = self.stage_importer_factory().import_file(Path(source_path), stage_dir, min_free_bytes=min_free_bytes)
except Exception as exc:
return {"error": str(exc)}, HTTPStatus.BAD_REQUEST
self._record_action(None, "stage_import", "ok", "imported file into stage", result)
return result, HTTPStatus.CREATED
def handle_stage_upload(self, file_item) -> tuple[object, HTTPStatus]: # type: ignore[no-untyped-def]
if file_item is None or not getattr(file_item, "filename", None):
return {"error": "missing file"}, HTTPStatus.BAD_REQUEST
stage_dir = Path(self.state["settings"]["paths"]["stage_dir"])
min_free_bytes = mb_to_bytes(self.state["settings"]["ingest"].get("stage_min_free_space_mb", 0))
try:
result = self.stage_importer_factory().import_upload(
file_item.filename,
file_item.file,
stage_dir,
min_free_bytes=min_free_bytes,
)
except Exception as exc:
return {"error": str(exc)}, HTTPStatus.BAD_REQUEST
self._record_action(None, "stage_upload", "ok", "uploaded file into stage", result)
return result, HTTPStatus.CREATED
def handle_create_task(self, payload: object) -> tuple[object, HTTPStatus]:
source_path = payload.get("source_path") if isinstance(payload, dict) else None
if not source_path:
return {"error": "missing source_path"}, HTTPStatus.BAD_REQUEST
try:
settings = dict(self.state["settings"]["ingest"])
settings.update(self.state["settings"]["paths"])
task = self.state["ingest_service"].create_task_from_file(Path(source_path), settings)
except Exception as exc:
status = HTTPStatus.CONFLICT if exc.__class__.__name__ == "ModuleError" else HTTPStatus.INTERNAL_SERVER_ERROR
body = exc.to_dict() if hasattr(exc, "to_dict") else {"error": str(exc)}
return body, status
return task.to_dict(), HTTPStatus.CREATED
def _record_action(self, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None:
self.repo.add_action_record(
ActionRecord(
id=None,
task_id=task_id,
action_name=action_name,
status=status,
summary=summary,
details_json=json.dumps(details, ensure_ascii=False),
created_at=utc_now_iso(),
)
)

View File

@ -215,7 +215,6 @@ def render_dashboard_html() -> str:
</select>
<select id="taskDeliveryFilter">
<option value="">全部交付状态</option>
<option value="legacy_untracked">主视频评论未追踪</option>
<option value="pending_comment">评论待完成</option>
<option value="cleanup_removed">已清理视频</option>
</select>
@ -249,6 +248,17 @@ def render_dashboard_html() -> str:
</div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Session Workspace</h3>
<div class="button-row">
<button id="refreshSessionBtn" class="secondary compact">刷新 Session</button>
</div>
</div>
<div id="sessionWorkspaceState" class="task-workspace-state show">当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息。</div>
<div id="sessionPanel" class="summary-card session-panel"></div>
</section>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Steps</h3></div>

View File

@ -2,6 +2,11 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
STEP_SETTINGS_GROUP = {
"publish": "publish",
"comment": "comment",
}
def parse_iso(value: str | None) -> datetime | None:
if not value:
@ -12,7 +17,14 @@ def parse_iso(value: str | None) -> datetime | None:
return None
def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
def retry_schedule_seconds(
settings: dict[str, object],
*,
count_key: str,
backoff_key: str,
default_count: int,
default_backoff: int,
) -> list[int]:
raw_schedule = settings.get("retry_schedule_minutes")
if isinstance(raw_schedule, list):
schedule: list[int] = []
@ -21,25 +33,57 @@ def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
schedule.append(item * 60)
if schedule:
return schedule
retry_count = settings.get("retry_count", 5)
retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else 5
retry_count = settings.get(count_key, default_count)
retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else default_count
retry_count = max(retry_count, 0)
retry_backoff = settings.get("retry_backoff_seconds", 300)
retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else 300
retry_backoff = settings.get(backoff_key, default_backoff)
retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else default_backoff
retry_backoff = max(retry_backoff, 0)
return [retry_backoff] * retry_count
def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds(
settings,
count_key="retry_count",
backoff_key="retry_backoff_seconds",
default_count=5,
default_backoff=300,
)
def comment_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds(
settings,
count_key="max_retries",
backoff_key="base_delay_seconds",
default_count=5,
default_backoff=180,
)
def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def]
if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0:
return None
if getattr(step, "step_name", None) != "publish":
step_name = getattr(step, "step_name", None)
settings_group = STEP_SETTINGS_GROUP.get(step_name)
if settings_group is None:
return None
group_settings = settings_by_group.get(settings_group, {})
if not isinstance(group_settings, dict):
group_settings = {}
if step_name == "publish":
schedule = publish_retry_schedule_seconds(group_settings)
elif step_name == "comment":
schedule = comment_retry_schedule_seconds(group_settings)
else:
return None
publish_settings = settings_by_group.get("publish", {})
if not isinstance(publish_settings, dict):
publish_settings = {}
schedule = publish_retry_schedule_seconds(publish_settings)
attempt_index = step.retry_count - 1
if attempt_index >= len(schedule):
return {

View File

@ -0,0 +1,254 @@
from __future__ import annotations
import json
from pathlib import Path
from biliup_next.app.retry_meta import retry_meta_for_step
class ControlPlaneSerializer:
def __init__(self, state: dict[str, object]):
self.state = state
@staticmethod
def video_url(bvid: object) -> str | None:
if isinstance(bvid, str) and bvid.startswith("BV"):
return f"https://www.bilibili.com/video/{bvid}"
return None
def task_related_maps(
self,
tasks,
) -> tuple[dict[str, object], dict[str, list[object]]]: # type: ignore[no-untyped-def]
task_ids = [task.id for task in tasks]
contexts_by_task_id = self.state["repo"].list_task_contexts_for_task_ids(task_ids)
steps_by_task_id = self.state["repo"].list_steps_for_task_ids(task_ids)
return contexts_by_task_id, steps_by_task_id
def task_payload(self, task_id: str) -> dict[str, object] | None:
task = self.state["repo"].get_task(task_id)
if task is None:
return None
return self.task_payload_from_task(task)
def task_payloads_from_tasks(self, tasks) -> list[dict[str, object]]: # type: ignore[no-untyped-def]
contexts_by_task_id, steps_by_task_id = self.task_related_maps(tasks)
return [
self.task_payload_from_task(
task,
context=contexts_by_task_id.get(task.id),
steps=steps_by_task_id.get(task.id, []),
)
for task in tasks
]
def task_payload_from_task(
self,
task,
*,
context=None, # type: ignore[no-untyped-def]
steps=None, # type: ignore[no-untyped-def]
) -> dict[str, object]:
payload = task.to_dict()
session_context = self.task_context_payload(task.id, task=task, context=context)
if session_context:
payload["session_context"] = session_context
retry_state = self.task_retry_state(task.id, steps=steps)
if retry_state:
payload["retry_state"] = retry_state
payload["delivery_state"] = self.task_delivery_state(task.id, task=task)
return payload
def step_payload(self, step) -> dict[str, object]: # type: ignore[no-untyped-def]
payload = step.to_dict()
retry_meta = retry_meta_for_step(step, self.state["settings"])
if retry_meta:
payload.update(retry_meta)
return payload
def task_retry_state(self, task_id: str, *, steps=None) -> dict[str, object] | None: # type: ignore[no-untyped-def]
step_items = steps if steps is not None else self.state["repo"].list_steps(task_id)
for step in step_items:
retry_meta = retry_meta_for_step(step, self.state["settings"])
if retry_meta:
return {"step_name": step.step_name, **retry_meta}
return None
def task_delivery_state(self, task_id: str, *, task=None) -> dict[str, object]: # type: ignore[no-untyped-def]
task = task or self.state["repo"].get_task(task_id)
if task is None:
return {}
session_dir = Path(str(self.state["settings"]["paths"]["session_dir"])) / task.title
source_path = Path(task.source_path)
split_dir = session_dir / "split_video"
def comment_status(flag_name: str, *, enabled: bool) -> str:
if not enabled:
return "disabled"
return "done" if (session_dir / flag_name).exists() else "pending"
return {
"split_comment": comment_status("comment_split_done.flag", enabled=self.state["settings"]["comment"].get("post_split_comment", True)),
"full_video_timeline_comment": comment_status(
"comment_full_done.flag",
enabled=self.state["settings"]["comment"].get("post_full_video_timeline_comment", True),
),
"full_video_bvid_resolved": (session_dir / "full_video_bvid.txt").exists(),
"source_video_present": source_path.exists(),
"split_videos_present": split_dir.exists(),
"cleanup_enabled": {
"delete_source_video_after_collection_synced": self.state["settings"].get("cleanup", {}).get("delete_source_video_after_collection_synced", False),
"delete_split_videos_after_collection_synced": self.state["settings"].get("cleanup", {}).get("delete_split_videos_after_collection_synced", False),
},
}
def task_context_payload(self, task_id: str, *, task=None, context=None) -> dict[str, object] | None: # type: ignore[no-untyped-def]
task = task or self.state["repo"].get_task(task_id)
if task is None:
return None
context = context or self.state["repo"].get_task_context(task_id)
if context is None:
payload = {
"task_id": task.id,
"session_key": None,
"streamer": None,
"room_id": None,
"source_title": task.title,
"segment_started_at": None,
"segment_duration_seconds": None,
"full_video_bvid": None,
"created_at": task.created_at,
"updated_at": task.updated_at,
"context_source": "fallback",
}
else:
payload = context.to_dict()
payload["context_source"] = "task_context"
payload["split_bvid"] = self.read_task_text_artifact(task_id, "bvid.txt", task=task)
full_video_bvid = self.read_task_text_artifact(task_id, "full_video_bvid.txt", task=task)
if full_video_bvid:
payload["full_video_bvid"] = full_video_bvid
payload["video_links"] = {
"split_video_url": self.video_url(payload.get("split_bvid")),
"full_video_url": self.video_url(payload.get("full_video_bvid")),
}
return payload
def session_payload(self, session_key: str) -> dict[str, object] | None:
contexts = self.state["repo"].list_task_contexts_by_session_key(session_key)
if not contexts:
return None
tasks = []
full_video_bvid = None
for context in contexts:
task = self.state["repo"].get_task(context.task_id)
if task is None:
continue
tasks.append(task)
if not full_video_bvid and context.full_video_bvid:
full_video_bvid = context.full_video_bvid
return {
"session_key": session_key,
"task_count": len(tasks),
"full_video_bvid": full_video_bvid,
"full_video_url": self.video_url(full_video_bvid),
"tasks": self.task_payloads_from_tasks(tasks),
}
def timeline_payload(self, task_id: str) -> dict[str, object] | None:
task = self.state["repo"].get_task(task_id)
if task is None:
return None
steps = self.state["repo"].list_steps(task_id)
artifacts = self.state["repo"].list_artifacts(task_id)
actions = self.state["repo"].list_action_records(task_id, limit=200)
items: list[dict[str, object]] = []
if task.created_at:
items.append({
"kind": "task",
"time": task.created_at,
"title": "Task Created",
"summary": task.title,
"status": task.status,
})
if task.updated_at and task.updated_at != task.created_at:
items.append({
"kind": "task",
"time": task.updated_at,
"title": "Task Updated",
"summary": task.status,
"status": task.status,
})
for step in steps:
if step.started_at:
items.append({
"kind": "step",
"time": step.started_at,
"title": f"{step.step_name} started",
"summary": step.status,
"status": step.status,
})
if step.finished_at:
retry_meta = retry_meta_for_step(step, self.state["settings"])
retry_note = ""
if retry_meta and retry_meta.get("next_retry_at"):
retry_note = f" | next retry: {retry_meta['next_retry_at']}"
items.append({
"kind": "step",
"time": step.finished_at,
"title": f"{step.step_name} finished",
"summary": f"{step.error_message or step.status}{retry_note}",
"status": step.status,
"retry_state": retry_meta,
})
for artifact in artifacts:
if artifact.created_at:
items.append({
"kind": "artifact",
"time": artifact.created_at,
"title": artifact.artifact_type,
"summary": artifact.path,
"status": "created",
})
for action in actions:
summary = action.summary
try:
details = json.loads(action.details_json or "{}")
except json.JSONDecodeError:
details = {}
if action.action_name == "comment" and isinstance(details, dict):
split_status = details.get("split", {}).get("status")
full_status = details.get("full", {}).get("status")
fragments = []
if split_status:
fragments.append(f"split={split_status}")
if full_status:
fragments.append(f"full={full_status}")
if fragments:
summary = f"{summary} | {' '.join(fragments)}"
if action.action_name in {"collection_a", "collection_b"} and isinstance(details, dict):
cleanup = details.get("result", {}).get("cleanup") or details.get("cleanup")
if isinstance(cleanup, dict):
removed = cleanup.get("removed") or []
if removed:
summary = f"{summary} | cleanup removed={len(removed)}"
items.append({
"kind": "action",
"time": action.created_at,
"title": action.action_name,
"summary": summary,
"status": action.status,
})
items.sort(key=lambda item: str(item["time"]), reverse=True)
return {"items": items}
def read_task_text_artifact(self, task_id: str, filename: str, *, task=None) -> str | None: # type: ignore[no-untyped-def]
task = task or self.state["repo"].get_task(task_id)
if task is None:
return None
session_dir = Path(str(self.state["settings"]["paths"]["session_dir"])) / task.title
path = session_dir / filename
if not path.exists():
return None
value = path.read_text(encoding="utf-8").strip()
return value or None

View File

@ -0,0 +1,254 @@
from __future__ import annotations
import json
from pathlib import Path
import re
from biliup_next.core.models import ActionRecord, SessionBinding, TaskContext, utc_now_iso
class SessionDeliveryService:
def __init__(self, state: dict[str, object]):
self.state = state
self.repo = state["repo"]
self.settings = state["settings"]
def bind_task_full_video(self, task_id: str, full_video_bvid: str) -> dict[str, object]:
task = self.repo.get_task(task_id)
if task is None:
return {"error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}}
bvid = self._normalize_bvid(full_video_bvid)
if bvid is None:
return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}}
now = utc_now_iso()
context = self.repo.get_task_context(task_id)
if context is None:
context = TaskContext(
id=None,
task_id=task.id,
session_key=f"task:{task.id}",
streamer=None,
room_id=None,
source_title=task.title,
segment_started_at=None,
segment_duration_seconds=None,
full_video_bvid=bvid,
created_at=task.created_at,
updated_at=now,
)
full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now)
return {
"task_id": task.id,
"session_key": context.session_key,
"full_video_bvid": bvid,
"path": str(full_video_bvid_path),
}
def rebind_session_full_video(self, session_key: str, full_video_bvid: str) -> dict[str, object]:
bvid = self._normalize_bvid(full_video_bvid)
if bvid is None:
return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}}
contexts = self.repo.list_task_contexts_by_session_key(session_key)
if not contexts:
return {"error": {"code": "SESSION_NOT_FOUND", "message": f"session not found: {session_key}"}}
now = utc_now_iso()
self.repo.update_session_full_video_bvid(session_key, bvid, now)
updated_tasks: list[dict[str, object]] = []
for context in contexts:
task = self.repo.get_task(context.task_id)
if task is None:
continue
full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now)
updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)})
return {
"session_key": session_key,
"full_video_bvid": bvid,
"updated_count": len(updated_tasks),
"tasks": updated_tasks,
}
def merge_session(self, session_key: str, task_ids: list[str]) -> dict[str, object]:
normalized_task_ids: list[str] = []
for raw in task_ids:
task_id = str(raw).strip()
if task_id and task_id not in normalized_task_ids:
normalized_task_ids.append(task_id)
if not normalized_task_ids:
return {"error": {"code": "TASK_IDS_EMPTY", "message": "task_ids is empty"}}
now = utc_now_iso()
inherited_bvid = None
existing_contexts = self.repo.list_task_contexts_by_session_key(session_key)
for context in existing_contexts:
if context.full_video_bvid:
inherited_bvid = context.full_video_bvid
break
merged_tasks: list[dict[str, object]] = []
missing_tasks: list[str] = []
for task_id in normalized_task_ids:
task = self.repo.get_task(task_id)
if task is None:
missing_tasks.append(task_id)
continue
context = self.repo.get_task_context(task_id)
if context is None:
context = TaskContext(
id=None,
task_id=task.id,
session_key=session_key,
streamer=None,
room_id=None,
source_title=task.title,
segment_started_at=None,
segment_duration_seconds=None,
full_video_bvid=inherited_bvid,
created_at=task.created_at,
updated_at=now,
)
else:
context.session_key = session_key
context.updated_at = now
if inherited_bvid and not context.full_video_bvid:
context.full_video_bvid = inherited_bvid
self.repo.upsert_task_context(context)
if context.full_video_bvid:
full_video_bvid_path = self._persist_task_full_video_bvid(task, context, context.full_video_bvid, now=now)
else:
full_video_bvid_path = None
payload = {
"task_id": task.id,
"session_key": session_key,
"full_video_bvid": context.full_video_bvid,
}
if full_video_bvid_path is not None:
payload["path"] = str(full_video_bvid_path)
merged_tasks.append(payload)
return {
"session_key": session_key,
"merged_count": len(merged_tasks),
"tasks": merged_tasks,
"missing_task_ids": missing_tasks,
}
def receive_full_video_webhook(self, payload: dict[str, object]) -> dict[str, object]:
raw_bvid = str(payload.get("full_video_bvid") or payload.get("bvid") or "").strip()
bvid = self._normalize_bvid(raw_bvid)
if bvid is None:
return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {raw_bvid}"}}
session_key = str(payload.get("session_key") or "").strip() or None
source_title = str(payload.get("source_title") or "").strip() or None
streamer = str(payload.get("streamer") or "").strip() or None
room_id = str(payload.get("room_id") or "").strip() or None
if session_key is None and source_title is None:
return {"error": {"code": "SESSION_KEY_OR_SOURCE_TITLE_REQUIRED", "message": "session_key or source_title required"}}
now = utc_now_iso()
self.repo.upsert_session_binding(
SessionBinding(
id=None,
session_key=session_key,
source_title=source_title,
streamer=streamer,
room_id=room_id,
full_video_bvid=bvid,
created_at=now,
updated_at=now,
)
)
contexts = self.repo.list_task_contexts_by_session_key(session_key) if session_key else []
if not contexts and source_title:
contexts = self.repo.list_task_contexts_by_source_title(source_title)
updated_tasks: list[dict[str, object]] = []
for context in contexts:
task = self.repo.get_task(context.task_id)
if task is None:
continue
if session_key and (context.session_key.startswith("task:") or context.session_key != session_key):
context.session_key = session_key
full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now)
updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)})
self.repo.add_action_record(
ActionRecord(
id=None,
task_id=None,
action_name="webhook_full_video_uploaded",
status="ok",
summary=f"full video webhook received: {bvid}",
details_json=json.dumps(
{
"session_key": session_key,
"source_title": source_title,
"streamer": streamer,
"room_id": room_id,
"updated_count": len(updated_tasks),
},
ensure_ascii=False,
),
created_at=now,
)
)
return {
"ok": True,
"session_key": session_key,
"source_title": source_title,
"full_video_bvid": bvid,
"updated_count": len(updated_tasks),
"tasks": updated_tasks,
}
def _normalize_bvid(self, full_video_bvid: str) -> str | None:
bvid = full_video_bvid.strip()
if not re.fullmatch(r"BV[0-9A-Za-z]+", bvid):
return None
return bvid
def _full_video_bvid_path(self, task_title: str) -> Path:
session_dir = Path(str(self.settings["paths"]["session_dir"])) / task_title
session_dir.mkdir(parents=True, exist_ok=True)
return session_dir / "full_video_bvid.txt"
def _upsert_session_binding_for_context(self, context: TaskContext, full_video_bvid: str, now: str) -> None:
self.repo.upsert_session_binding(
SessionBinding(
id=None,
session_key=context.session_key,
source_title=context.source_title,
streamer=context.streamer,
room_id=context.room_id,
full_video_bvid=full_video_bvid,
created_at=now,
updated_at=now,
)
)
def _persist_task_full_video_bvid(
self,
task,
context: TaskContext,
full_video_bvid: str,
*,
now: str,
) -> Path: # type: ignore[no-untyped-def]
context.full_video_bvid = full_video_bvid
context.updated_at = now
self.repo.upsert_task_context(context)
self._upsert_session_binding_for_context(context, full_video_bvid, now)
path = self._full_video_bvid_path(task.title)
path.write_text(full_video_bvid, encoding="utf-8")
return path

View File

@ -9,13 +9,14 @@ import {
setTaskPageSize,
state,
} from "./state.js";
import { showBanner, syncSettingsEditorFromState } from "./utils.js";
import { showBanner, syncSettingsEditorFromState, withButtonBusy } from "./utils.js";
import { renderSettingsForm } from "./views/settings.js";
import { renderTasks } from "./views/tasks.js";
export function bindActions({
loadOverview,
loadTaskDetail,
refreshSelectedTaskOnly,
refreshLog,
handleSettingsFieldChange,
}) {
@ -170,29 +171,33 @@ export function bindActions({
document.getElementById("runTaskBtn").onclick = async () => {
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
try {
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" });
await loadOverview();
showBanner(`任务已推进processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`任务执行失败: ${err}`, "err");
}
await withButtonBusy(document.getElementById("runTaskBtn"), "执行中…", async () => {
try {
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" });
await refreshSelectedTaskOnly(state.selectedTaskId);
showBanner(`任务已推进processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`任务执行失败: ${err}`, "err");
}
});
};
document.getElementById("retryStepBtn").onclick = async () => {
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
try {
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: state.selectedStepName }),
});
await loadOverview();
showBanner(`已重试 step=${state.selectedStepName}processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`重试失败: ${err}`, "err");
}
await withButtonBusy(document.getElementById("retryStepBtn"), "重试中…", async () => {
try {
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: state.selectedStepName }),
});
await refreshSelectedTaskOnly(state.selectedTaskId);
showBanner(`已重试 step=${state.selectedStepName}processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`重试失败: ${err}`, "err");
}
});
};
document.getElementById("resetStepBtn").onclick = async () => {
@ -200,16 +205,18 @@ export function bindActions({
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
const ok = window.confirm(`确认重置到 step=${state.selectedStepName} 并清理其后的产物吗?`);
if (!ok) return;
try {
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: state.selectedStepName }),
});
await loadOverview();
showBanner(`已重置并重跑 step=${state.selectedStepName}processed=${result.run.processed.length}`, "ok");
} catch (err) {
showBanner(`重置失败: ${err}`, "err");
}
await withButtonBusy(document.getElementById("resetStepBtn"), "重置中…", async () => {
try {
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: state.selectedStepName }),
});
await refreshSelectedTaskOnly(state.selectedTaskId);
showBanner(`已重置并重跑 step=${state.selectedStepName}processed=${result.run.processed.length}`, "ok");
} catch (err) {
showBanner(`重置失败: ${err}`, "err");
}
});
};
}

View File

@ -40,13 +40,22 @@ export async function loadOverviewPayload() {
return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler };
}
export async function loadTasksPayload(limit = 100) {
return fetchJson(`/tasks?limit=${limit}`);
}
export async function loadTaskPayload(taskId) {
const [task, steps, artifacts, history, timeline] = await Promise.all([
const [task, steps, artifacts, history, timeline, context] = await Promise.all([
fetchJson(`/tasks/${taskId}`),
fetchJson(`/tasks/${taskId}/steps`),
fetchJson(`/tasks/${taskId}/artifacts`),
fetchJson(`/tasks/${taskId}/history`),
fetchJson(`/tasks/${taskId}/timeline`),
fetchJson(`/tasks/${taskId}/context`).catch(() => null),
]);
return { task, steps, artifacts, history, timeline };
return { task, steps, artifacts, history, timeline, context };
}
export async function loadSessionPayload(sessionKey) {
return fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`);
}

View File

@ -0,0 +1,70 @@
import { escapeHtml, taskDisplayStatus } from "../utils.js";
export function renderSessionPanel(session, actions = {}) {
const wrap = document.getElementById("sessionPanel");
const stateEl = document.getElementById("sessionWorkspaceState");
if (!wrap || !stateEl) return;
if (!session) {
stateEl.className = "task-workspace-state show";
stateEl.textContent = "当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息。";
wrap.innerHTML = "";
return;
}
stateEl.className = "task-workspace-state";
const tasks = session.tasks || [];
wrap.innerHTML = `
<div class="session-hero">
<div>
<div class="summary-title">Session Key</div>
<div class="session-key">${escapeHtml(session.session_key || "-")}</div>
</div>
<div class="session-meta-strip">
<span class="pill">${escapeHtml(`tasks ${session.task_count || tasks.length || 0}`)}</span>
<span class="pill">${escapeHtml(`full BV ${session.full_video_bvid || "-"}`)}</span>
</div>
</div>
<div class="session-actions-grid">
<div class="bind-form">
<div class="summary-title">Session Rebind</div>
<input id="sessionRebindInput" value="${escapeHtml(session.full_video_bvid || "")}" placeholder="BV1..." />
<div class="button-row">
<button id="sessionRebindBtn" class="secondary compact">整个 Session 重绑 BV</button>
${session.full_video_url ? `<a class="detail-link session-link-btn" href="${escapeHtml(session.full_video_url)}" target="_blank" rel="noreferrer">打开完整版</a>` : ""}
</div>
</div>
<div class="bind-form">
<div class="summary-title">Merge Tasks</div>
<input id="sessionMergeInput" placeholder="输入 task id用逗号分隔" />
<div class="button-row">
<button id="sessionMergeBtn" class="secondary compact">合并到当前 Session</button>
</div>
<div class="muted-note">适用于同一场直播断流后产生的多个片段。</div>
</div>
</div>
<div class="summary-title" style="margin-top:14px;">Session Tasks</div>
<div class="stack-list">
${tasks.map((task) => `
<div class="row-card session-task-card" data-session-task-id="${escapeHtml(task.id)}">
<div class="step-card-title">
<strong>${escapeHtml(task.title)}</strong>
<span class="pill">${escapeHtml(taskDisplayStatus(task))}</span>
</div>
<div class="muted-note">${escapeHtml(task.session_context?.split_bvid || "-")} · ${escapeHtml(task.session_context?.full_video_bvid || "-")}</div>
</div>
`).join("")}
</div>
`;
const rebindBtn = document.getElementById("sessionRebindBtn");
if (rebindBtn) {
rebindBtn.onclick = () => actions.onRebind?.(session.session_key, document.getElementById("sessionRebindInput")?.value || "");
}
const mergeBtn = document.getElementById("sessionMergeBtn");
if (mergeBtn) {
mergeBtn.onclick = () => actions.onMerge?.(session.session_key, document.getElementById("sessionMergeInput")?.value || "");
}
wrap.querySelectorAll("[data-session-task-id]").forEach((node) => {
node.onclick = () => actions.onSelectTask?.(node.dataset.sessionTaskId);
});
}

View File

@ -1,22 +1,41 @@
import { escapeHtml, statusClass } from "../utils.js";
function displayTaskStatus(task) {
if (task.status === "failed_manual") return "需人工处理";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
if (task.status === "failed_retryable") return "等待自动重试";
return {
created: "已接收",
transcribed: "已转录",
songs_detected: "已识歌",
split_done: "已切片",
published: "已上传",
collection_synced: "已完成",
running: "处理中",
}[task.status] || task.status || "-";
}
export function renderTaskHero(task, steps) {
const wrap = document.getElementById("taskHero");
const succeeded = steps.items.filter((step) => step.status === "succeeded").length;
const running = steps.items.filter((step) => step.status === "running").length;
const failed = steps.items.filter((step) => step.status.startsWith("failed")).length;
const delivery = task.delivery_state || {};
const sessionContext = task.session_context || {};
wrap.className = "task-hero";
wrap.innerHTML = `
<div class="task-hero-title">${escapeHtml(task.title)}</div>
<div class="task-hero-subtitle">${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}</div>
<div class="hero-meta-grid">
<div class="mini-stat"><div class="mini-stat-label">Task Status</div><div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(task.status)}</span></div></div>
<div class="mini-stat"><div class="mini-stat-label">Task Status</div><div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(displayTaskStatus(task))}</span></div></div>
<div class="mini-stat"><div class="mini-stat-label">Succeeded Steps</div><div class="mini-stat-value">${succeeded}/${steps.items.length}</div></div>
<div class="mini-stat"><div class="mini-stat-label">Running / Failed</div><div class="mini-stat-value">${running} / ${failed}</div></div>
</div>
<div class="task-hero-delivery muted-note">
split comment=${escapeHtml(delivery.split_comment || "-")} · full timeline=${escapeHtml(delivery.full_video_timeline_comment || "-")} · source=${delivery.source_video_present ? "present" : "removed"} · split videos=${delivery.split_videos_present ? "present" : "removed"}
</div>
<div class="task-hero-delivery muted-note">
session=${escapeHtml(sessionContext.session_key || "-")} · split_bv=${escapeHtml(sessionContext.split_bvid || "-")} · full_bv=${escapeHtml(sessionContext.full_video_bvid || "-")}
</div>
`;
}

View File

@ -1,4 +1,4 @@
import { fetchJson, loadOverviewPayload, loadTaskPayload } from "./api.js";
import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } from "./api.js";
import { bindActions } from "./actions.js";
import { currentRoute, initRouter, navigate } from "./router.js";
import {
@ -11,11 +11,12 @@ import {
setSelectedLog,
setSelectedStep,
setSelectedTask,
setCurrentSession,
setTaskDetailStatus,
setTaskListLoading,
state,
} from "./state.js";
import { settingsFieldKey, showBanner } from "./utils.js";
import { settingsFieldKey, showBanner, withButtonBusy } from "./utils.js";
import {
renderDoctor,
renderModules,
@ -27,6 +28,7 @@ import {
import { renderLogContent, renderLogsList } from "./views/logs.js";
import { renderSettingsForm } from "./views/settings.js";
import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js";
import { renderSessionPanel } from "./components/session-panel.js";
async function refreshLog() {
const name = state.selectedLogName;
@ -56,7 +58,41 @@ async function loadTaskDetail(taskId) {
renderTaskDetail(payload, async (stepName) => {
setSelectedStep(stepName);
await loadTaskDetail(taskId);
}, {
onBindFullVideo: async (currentTaskId, fullVideoBvid) => {
const button = document.getElementById("bindFullVideoBtn");
const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn");
return;
}
await withButtonBusy(button, "绑定中…", async () => {
try {
await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }),
});
await refreshSelectedTaskOnly(currentTaskId);
showBanner(`已绑定完整版 BV: ${bvid}`, "ok");
} catch (err) {
showBanner(`绑定完整版失败: ${err}`, "err");
}
});
},
onOpenSession: async (sessionKey) => {
if (!sessionKey) {
showBanner("当前任务没有可用的 session_key", "warn");
return;
}
try {
await loadSessionDetail(sessionKey);
} catch (err) {
showBanner(`读取 Session 失败: ${err}`, "err");
}
},
});
await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null);
setTaskDetailStatus("ready");
renderTaskWorkspaceState("ready");
} catch (err) {
@ -67,6 +103,79 @@ async function loadTaskDetail(taskId) {
}
}
async function loadSessionDetail(sessionKey) {
if (!sessionKey) {
setCurrentSession(null);
renderSessionPanel(null);
return;
}
const session = await loadSessionPayload(sessionKey);
setCurrentSession(session);
renderSessionPanel(session, {
onSelectTask: async (taskId) => {
if (!taskId) return;
taskSelectHandler(taskId);
},
onRebind: async (currentSessionKey, fullVideoBvid) => {
const button = document.getElementById("sessionRebindBtn");
const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn");
return;
}
await withButtonBusy(button, "重绑中…", async () => {
try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }),
});
await refreshSelectedTaskOnly();
showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok");
} catch (err) {
showBanner(`Session 重绑失败: ${err}`, "err");
}
});
},
onMerge: async (currentSessionKey, rawTaskIds) => {
const button = document.getElementById("sessionMergeBtn");
const taskIds = String(rawTaskIds || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (!taskIds.length) {
showBanner("请先输入至少一个 task id", "warn");
return;
}
await withButtonBusy(button, "合并中…", async () => {
try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_ids: taskIds }),
});
await refreshSelectedTaskOnly();
showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok");
} catch (err) {
showBanner(`Session 合并失败: ${err}`, "err");
}
});
},
});
}
async function refreshTaskListOnly() {
const payload = await loadTasksPayload(100);
state.currentTasks = payload.items || [];
renderTasks(taskSelectHandler, taskRowActionHandler);
}
async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) {
if (!taskId) return;
await refreshTaskListOnly();
await loadTaskDetail(taskId);
}
function taskSelectHandler(taskId) {
setSelectedTask(taskId);
setSelectedStep(null);
@ -79,7 +188,7 @@ async function taskRowActionHandler(action, taskId) {
if (action !== "run") return;
try {
const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" });
await loadOverview();
await refreshSelectedTaskOnly(taskId);
showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`任务执行失败: ${err}`, "err");
@ -201,6 +310,7 @@ async function handleRouteChange(route) {
bindActions({
loadOverview,
loadTaskDetail,
refreshSelectedTaskOnly,
refreshLog,
handleSettingsFieldChange,
});

View File

@ -13,6 +13,7 @@ export const state = {
taskListLoading: true,
taskDetailStatus: "idle",
taskDetailError: "",
currentSession: null,
currentLogs: [],
selectedLogName: null,
logListLoading: true,
@ -74,6 +75,10 @@ export function setTaskDetailStatus(status, error = "") {
state.taskDetailError = error;
}
export function setCurrentSession(session) {
state.currentSession = session;
}
export function setLogs(logs) {
state.currentLogs = logs;
}

View File

@ -1,9 +1,11 @@
import { state } from "./state.js";
let bannerTimer = null;
export function statusClass(status) {
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
if (["done", "resolved", "present"].includes(status)) return "good";
if (["legacy_untracked", "pending", "unresolved"].includes(status)) return "warn";
if (["pending", "unresolved"].includes(status)) return "warn";
if (["removed", "disabled"].includes(status)) return "";
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
@ -14,6 +16,11 @@ export function showBanner(message, kind) {
const el = document.getElementById("banner");
el.textContent = message;
el.className = `banner show ${kind}`;
if (bannerTimer) window.clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => {
el.className = "banner";
el.textContent = "";
}, kind === "err" ? 6000 : 3200);
}
export function escapeHtml(text) {
@ -59,3 +66,92 @@ export function compareFieldEntries(a, b) {
export function settingsFieldKey(group, field) {
return `${group}.${field}`;
}
export function taskDisplayStatus(task) {
if (!task) return "-";
if (task.status === "failed_manual") return "需人工处理";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
if (task.status === "failed_retryable") return "等待自动重试";
return {
created: "已接收",
transcribed: "已转录",
songs_detected: "已识歌",
split_done: "已切片",
published: "已上传",
commented: "评论完成",
collection_synced: "已完成",
running: "处理中",
}[task.status] || task.status || "-";
}
export function taskPrimaryActionLabel(task) {
if (!task) return "执行";
if (task.status === "failed_manual") return "人工重跑";
if (task.retry_state?.retry_due) return "立即重试";
if (task.status === "failed_retryable") return "继续等待";
if (task.status === "collection_synced") return "查看结果";
return "执行";
}
export function taskCurrentStep(task, steps = []) {
const running = steps.find((step) => step.status === "running");
if (running) return stepLabel(running.step_name);
if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)}: ${taskDisplayStatus(task)}`;
const pending = steps.find((step) => step.status === "pending");
if (pending) return stepLabel(pending.step_name);
return {
created: "转录字幕",
transcribed: "识别歌曲",
songs_detected: "切分分P",
split_done: "上传分P",
published: "评论与合集",
commented: "同步合集",
collection_synced: "链路完成",
}[task?.status] || "-";
}
export function stepLabel(stepName) {
return {
ingest: "接收视频",
transcribe: "转录字幕",
song_detect: "识别歌曲",
split: "切分分P",
publish: "上传分P",
comment: "发布评论",
collection_a: "加入完整版合集",
collection_b: "加入分P合集",
}[stepName] || stepName || "-";
}
export function actionAdvice(task) {
if (!task) return "";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
return "B站通常需要一段时间完成转码和审核系统会自动重试评论。";
}
if (task.status === "failed_retryable") {
return "当前错误可自动恢复,等到重试时间或手工触发即可。";
}
if (task.status === "failed_manual") {
return "这个任务需要人工判断,先看错误信息,再决定是重试当前步骤还是绑定完整版 BV。";
}
if (task.status === "collection_synced") {
return "链路已完成可以直接打开分P链接检查结果。";
}
return "系统会继续推进后续步骤,必要时可在这里手工干预。";
}
export async function withButtonBusy(button, loadingText, fn) {
if (!button) return fn();
const originalHtml = button.innerHTML;
const originalDisabled = button.disabled;
button.disabled = true;
button.classList.add("is-busy");
if (loadingText) button.textContent = loadingText;
try {
return await fn();
} finally {
button.disabled = originalDisabled;
button.classList.remove("is-busy");
button.innerHTML = originalHtml;
}
}

View File

@ -1,5 +1,14 @@
import { state, setTaskPage } from "../state.js";
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
import {
actionAdvice,
escapeHtml,
formatDate,
formatDuration,
statusClass,
taskCurrentStep,
taskDisplayStatus,
taskPrimaryActionLabel,
} from "../utils.js";
import { renderArtifactList } from "../components/artifact-list.js";
import { renderHistoryList } from "../components/history-list.js";
import { renderRetryPanel } from "../components/retry-banner.js";
@ -8,13 +17,13 @@ import { renderTaskHero } from "../components/task-hero.js";
import { renderTimelineList } from "../components/timeline-list.js";
const STATUS_LABELS = {
created: "待转录",
transcribed: "待识歌",
songs_detected: "待切歌",
split_done: "待上传",
published: "待收尾",
created: "已接收",
transcribed: "已转录",
songs_detected: "已识歌",
split_done: "已切片",
published: "已上传",
collection_synced: "已完成",
failed_retryable: "待重试",
failed_retryable: "待重试",
failed_manual: "待人工",
running: "处理中",
};
@ -22,15 +31,17 @@ const STATUS_LABELS = {
const DELIVERY_LABELS = {
done: "已发送",
pending: "待处理",
legacy_untracked: "历史未追踪",
resolved: "已定位",
unresolved: "未定位",
present: "保留",
removed: "已清理",
};
function displayStatus(status) {
return STATUS_LABELS[status] || status || "-";
function displayTaskStatus(task) {
if (task.status === "failed_manual") return "需人工处理";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
if (task.status === "failed_retryable") return "等待自动重试";
return taskDisplayStatus(task);
}
function displayDelivery(status) {
@ -162,7 +173,6 @@ export function filteredTasks() {
if (search && !haystack.includes(search)) return false;
if (status && task.status !== status) return false;
const deliveryState = task.delivery_state || {};
if (delivery === "legacy_untracked" && deliveryState.full_video_timeline_comment !== "legacy_untracked") return false;
if (delivery === "pending_comment" && deliveryState.split_comment !== "pending" && deliveryState.full_video_timeline_comment !== "pending") return false;
if (delivery === "cleanup_removed" && deliveryState.source_video_present !== false && deliveryState.split_videos_present !== false) return false;
if (attention && attentionState(task) !== attention) return false;
@ -304,9 +314,9 @@ export function renderTasks(onSelect, onRowAction = null) {
row.innerHTML = `
<td>
<div class="task-cell-title">${escapeHtml(item.title)}</div>
<div class="task-cell-subtitle">${escapeHtml(item.id)}</div>
<div class="task-cell-subtitle">${escapeHtml(taskCurrentStep(item))}</div>
</td>
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayStatus(item.status))}</span></td>
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayTaskStatus(item))}</span></td>
<td><span class="pill ${attentionClass(attention)}">${escapeHtml(displayAttention(attention))}</span></td>
<td><span class="pill ${statusClass(delivery.split_comment || "")}">${escapeHtml(displayDelivery(delivery.split_comment || "-"))}</span></td>
<td><span class="pill ${statusClass(delivery.full_video_timeline_comment || "")}">${escapeHtml(displayDelivery(delivery.full_video_timeline_comment || "-"))}</span></td>
@ -321,7 +331,7 @@ export function renderTasks(onSelect, onRowAction = null) {
</td>
<td class="task-table-actions">
<button class="secondary compact inline-action-btn" data-task-action="open">打开</button>
<button class="compact inline-action-btn" data-task-action="run">${attention === "manual_now" || attention === "retry_now" ? "重跑" : "执行"}</button>
<button class="compact inline-action-btn" data-task-action="run">${escapeHtml(taskPrimaryActionLabel(item))}</button>
</td>
`;
row.onclick = () => onSelect(item.id);
@ -346,7 +356,7 @@ export function renderTasks(onSelect, onRowAction = null) {
wrap.appendChild(table);
}
export function renderTaskDetail(payload, onStepSelect) {
export function renderTaskDetail(payload, onStepSelect, actions = {}) {
const { task, steps, artifacts, history, timeline } = payload;
renderTaskHero(task, steps);
renderRetryPanel(task);
@ -355,7 +365,8 @@ export function renderTaskDetail(payload, onStepSelect) {
detail.innerHTML = "";
[
["Task ID", task.id],
["Status", task.status],
["Status", displayTaskStatus(task)],
["Current Step", taskCurrentStep(task, steps.items)],
["Created", formatDate(task.created_at)],
["Updated", formatDate(task.updated_at)],
["Source", task.source_path],
@ -385,10 +396,40 @@ export function renderTaskDetail(payload, onStepSelect) {
}
}
const delivery = task.delivery_state || {};
const sessionContext = task.session_context || {};
const splitVideoUrl = sessionContext.video_links?.split_video_url;
const fullVideoUrl = sessionContext.video_links?.full_video_url;
const summaryEl = document.getElementById("taskSummary");
summaryEl.innerHTML = `
<div class="summary-title">Recent Result</div>
<div class="summary-text">${escapeHtml(summaryText)}</div>
<div class="summary-title" style="margin-top:14px;">Recommended Next Step</div>
<div class="summary-text">${escapeHtml(actionAdvice(task))}</div>
<div class="summary-title" style="margin-top:14px;">Delivery Links</div>
<div class="delivery-grid">
${renderDeliveryState("Split BV", sessionContext.split_bvid || "-", "")}
${renderDeliveryState("Full BV", sessionContext.full_video_bvid || "-", "")}
${renderLinkState("Split Video", splitVideoUrl)}
${renderLinkState("Full Video", fullVideoUrl)}
</div>
<div class="summary-title" style="margin-top:14px;">Session Context</div>
<div class="delivery-grid">
${renderDeliveryState("Session Key", sessionContext.session_key || "-", "")}
${renderDeliveryState("Streamer", sessionContext.streamer || "-", "")}
${renderDeliveryState("Room ID", sessionContext.room_id || "-", "")}
${renderDeliveryState("Context Source", sessionContext.context_source || "-", "")}
${renderDeliveryState("Segment Start", sessionContext.segment_started_at ? formatDate(sessionContext.segment_started_at) : "-", "")}
${renderDeliveryState("Segment Duration", sessionContext.segment_duration_seconds != null ? formatDuration(sessionContext.segment_duration_seconds) : "-", "")}
</div>
<div class="summary-title" style="margin-top:14px;">Bind Full Video BV</div>
<div class="bind-form">
<input id="bindFullVideoInput" value="${escapeHtml(sessionContext.full_video_bvid || "")}" placeholder="BV1..." />
<div class="button-row">
<button id="bindFullVideoBtn" class="secondary compact">绑定完整版 BV</button>
${sessionContext.session_key ? `<button id="openSessionBtn" class="secondary compact">查看 Session</button>` : ""}
</div>
<div class="muted-note">用于修复评论 / 合集查不到完整版视频的问题。</div>
</div>
<div class="summary-title" style="margin-top:14px;">Delivery State</div>
<div class="delivery-grid">
${renderDeliveryState("Split Comment", delivery.split_comment || "-")}
@ -403,6 +444,14 @@ export function renderTaskDetail(payload, onStepSelect) {
)}
</div>
`;
const bindBtn = document.getElementById("bindFullVideoBtn");
if (bindBtn) {
bindBtn.onclick = () => actions.onBindFullVideo?.(task.id, document.getElementById("bindFullVideoInput")?.value || "");
}
const openSessionBtn = document.getElementById("openSessionBtn");
if (openSessionBtn) {
openSessionBtn.onclick = () => actions.onOpenSession?.(sessionContext.session_key);
}
renderStepList(steps, onStepSelect);
renderArtifactList(artifacts);
@ -420,8 +469,21 @@ function renderDeliveryState(label, value, forcedClass = null) {
`;
}
function renderLinkState(label, url) {
return `
<div class="delivery-card">
<div class="delivery-label">${escapeHtml(label)}</div>
<div class="delivery-value">
${url ? `<a class="detail-link" href="${escapeHtml(url)}" target="_blank" rel="noreferrer">打开</a>` : `<span class="muted-note">-</span>`}
</div>
</div>
`;
}
export function renderTaskWorkspaceState(mode, message = "") {
const stateEl = document.getElementById("taskWorkspaceState");
const sessionStateEl = document.getElementById("sessionWorkspaceState");
const sessionPanel = document.getElementById("sessionPanel");
const hero = document.getElementById("taskHero");
const retry = document.getElementById("taskRetryPanel");
const detail = document.getElementById("taskDetail");
@ -459,4 +521,11 @@ export function renderTaskWorkspaceState(mode, message = "") {
artifactList.innerHTML = "";
historyList.innerHTML = "";
timelineList.innerHTML = "";
if (sessionStateEl) {
sessionStateEl.className = "task-workspace-state show";
sessionStateEl.textContent = mode === "error"
? "Session 区域暂不可用。"
: "当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息。";
}
if (sessionPanel) sessionPanel.innerHTML = "";
}

View File

@ -134,6 +134,11 @@ button.compact {
font-size: 13px;
}
button.is-busy {
opacity: 0.72;
cursor: wait;
}
.content {
display: grid;
gap: 16px;
@ -258,6 +263,79 @@ button.compact {
line-height: 1.6;
}
.task-cell-subtitle {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.bind-form {
display: grid;
gap: 10px;
margin-top: 10px;
}
.bind-form input {
width: 100%;
}
.detail-link {
color: var(--accent-2);
text-decoration: none;
font-weight: 600;
}
.detail-link:hover {
text-decoration: underline;
}
.session-panel {
display: grid;
gap: 16px;
}
.session-hero {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.session-key {
margin-top: 6px;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
}
.session-meta-strip,
.session-actions-grid {
display: grid;
gap: 12px;
}
.session-actions-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.session-task-card {
cursor: pointer;
}
.session-task-card:hover {
border-color: var(--line-strong);
}
.session-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 12px;
background: rgba(255,255,255,0.78);
}
.delivery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@ -1,29 +1,98 @@
from __future__ import annotations
from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.task_control_service import TaskControlService
from biliup_next.app.session_delivery_service import SessionDeliveryService
from biliup_next.app.task_audit import record_task_action
from biliup_next.app.task_runner import process_task
from biliup_next.infra.task_reset import TaskResetService
def run_task_action(task_id: str) -> dict[str, object]:
result = process_task(task_id)
state = ensure_initialized()
result = TaskControlService(state).run_task(task_id)
record_task_action(state["repo"], task_id, "task_run", "ok", "task run invoked", result)
return result
def retry_step_action(task_id: str, step_name: str) -> dict[str, object]:
result = process_task(task_id, reset_step=step_name)
state = ensure_initialized()
result = TaskControlService(state).retry_step(task_id, step_name)
record_task_action(state["repo"], task_id, "retry_step", "ok", f"retry step invoked: {step_name}", result)
return result
def reset_to_step_action(task_id: str, step_name: str) -> dict[str, object]:
state = ensure_initialized()
reset_result = TaskResetService(state["repo"]).reset_to_step(task_id, step_name)
process_result = process_task(task_id)
payload = {"reset": reset_result, "run": process_result}
payload = TaskControlService(state).reset_to_step(task_id, step_name)
record_task_action(state["repo"], task_id, "reset_to_step", "ok", f"reset to step invoked: {step_name}", payload)
return payload
def bind_full_video_action(task_id: str, full_video_bvid: str) -> dict[str, object]:
state = ensure_initialized()
payload = SessionDeliveryService(state).bind_task_full_video(task_id, full_video_bvid)
if "error" in payload:
return payload
record_task_action(
state["repo"],
task_id,
"bind_full_video",
"ok",
f"full video bvid bound: {payload['full_video_bvid']}",
payload,
)
return payload
def rebind_session_full_video_action(session_key: str, full_video_bvid: str) -> dict[str, object]:
state = ensure_initialized()
payload = SessionDeliveryService(state).rebind_session_full_video(session_key, full_video_bvid)
if "error" in payload:
return payload
for item in payload["tasks"]:
record_task_action(
state["repo"],
item["task_id"],
"rebind_session_full_video",
"ok",
f"session full video bvid rebound: {payload['full_video_bvid']}",
{
"session_key": session_key,
"full_video_bvid": payload["full_video_bvid"],
"path": item["path"],
},
)
return payload
def merge_session_action(session_key: str, task_ids: list[str]) -> dict[str, object]:
state = ensure_initialized()
payload = SessionDeliveryService(state).merge_session(session_key, task_ids)
if "error" in payload:
return payload
for item in payload["tasks"]:
record_task_action(state["repo"], item["task_id"], "merge_session", "ok", f"task merged into session: {session_key}", item)
return payload
def receive_full_video_webhook(payload: dict[str, object]) -> dict[str, object]:
state = ensure_initialized()
result = SessionDeliveryService(state).receive_full_video_webhook(payload)
if "error" in result:
return result
for item in result["tasks"]:
record_task_action(
state["repo"],
item["task_id"],
"webhook_full_video_uploaded",
"ok",
f"full video bvid received via webhook: {result['full_video_bvid']}",
{
"session_key": result["session_key"],
"source_title": result["source_title"],
"full_video_bvid": result["full_video_bvid"],
"path": item["path"],
},
)
return result

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
from biliup_next.app.task_runner import process_task
from biliup_next.infra.task_reset import TaskResetService
class TaskControlService:
def __init__(self, state: dict[str, object]):
self.state = state
def run_task(self, task_id: str) -> dict[str, object]:
return process_task(task_id)
def retry_step(self, task_id: str, step_name: str) -> dict[str, object]:
return process_task(task_id, reset_step=step_name)
def reset_to_step(self, task_id: str, step_name: str) -> dict[str, object]:
reset_result = TaskResetService(
self.state["repo"],
Path(str(self.state["settings"]["paths"]["session_dir"])),
).reset_to_step(task_id, step_name)
process_result = process_task(task_id)
return {"reset": reset_result, "run": process_result}

View File

@ -22,6 +22,12 @@ def settings_for(state: dict[str, object], group: str) -> dict[str, object]:
def infer_error_step_name(task, steps: dict[str, object]) -> str: # type: ignore[no-untyped-def]
running = next((step for step in steps.values() if step.status == "running"), None)
if running is not None:
return running.step_name
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is not None:
return failed.step_name
if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}:
return "transcribe"
if task.status == "transcribed":
@ -57,6 +63,9 @@ def retry_wait_payload(task_id: str, step, state: dict[str, object]) -> dict[str
def next_runnable_step(task, steps: dict[str, object], state: dict[str, object]) -> tuple[str | None, dict[str, object] | None]: # type: ignore[no-untyped-def]
if any(step.status == "running" for step in steps.values()):
return None, None
if task.status == "failed_retryable":
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is None:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
from biliup_next.app.retry_meta import comment_retry_schedule_seconds
from biliup_next.app.retry_meta import publish_retry_schedule_seconds
from biliup_next.app.task_engine import infer_error_step_name, settings_for as task_engine_settings_for
from biliup_next.core.models import utc_now_iso
@ -40,6 +41,12 @@ def resolve_failure(task, repo, state: dict[str, object], exc) -> dict[str, obje
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
if exc.retryable and step_name == "comment":
schedule = comment_retry_schedule_seconds(settings_for(state, "comment"))
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
failed_at = utc_now_iso()
repo.update_step_status(
task.id,

View File

@ -10,6 +10,7 @@ from biliup_next.app.task_policies import apply_disabled_step_fallbacks
from biliup_next.app.task_policies import resolve_failure
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import utc_now_iso
from biliup_next.infra.task_reset import STATUS_BEFORE_STEP
def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]:
@ -41,7 +42,8 @@ def process_task(task_id: str, *, reset_step: str | None = None, include_stage_s
started_at=None,
finished_at=None,
)
repo.update_task_status(task_id, task.status, utc_now_iso())
target_status = STATUS_BEFORE_STEP.get(reset_step, "created")
repo.update_task_status(task_id, target_status, utc_now_iso())
processed.append({"task_id": task_id, "step": reset_step, "reset": True})
record_task_action(repo, task_id, "retry_step", "ok", f"step reset to pending: {reset_step}", {"step_name": reset_step})
@ -60,6 +62,19 @@ def process_task(task_id: str, *, reset_step: str | None = None, include_stage_s
if step_name is None:
break
claimed_at = utc_now_iso()
if not repo.claim_step_running(task.id, step_name, started_at=claimed_at):
processed.append(
{
"task_id": task.id,
"step": step_name,
"skipped": True,
"reason": "step_already_claimed",
}
)
return {"processed": processed}
repo.update_task_status(task.id, "running", claimed_at)
payload = execute_step(state, task.id, step_name)
if current_task.status == "failed_retryable":
payload["retry"] = True