init biliup-next

This commit is contained in:
theshy
2026-04-01 00:44:58 +08:00
commit d0cf1fd0df
127 changed files with 15582 additions and 0 deletions

View File

@ -0,0 +1,6 @@
Metadata-Version: 2.4
Name: biliup-next
Version: 0.1.0
Summary: Next-generation control-plane-first biliup pipeline
Requires-Python: >=3.11
Requires-Dist: requests>=2.32.0

View File

@ -0,0 +1,42 @@
README.md
pyproject.toml
src/biliup_next/__init__.py
src/biliup_next.egg-info/PKG-INFO
src/biliup_next.egg-info/SOURCES.txt
src/biliup_next.egg-info/dependency_links.txt
src/biliup_next.egg-info/entry_points.txt
src/biliup_next.egg-info/requires.txt
src/biliup_next.egg-info/top_level.txt
src/biliup_next/app/api_server.py
src/biliup_next/app/bootstrap.py
src/biliup_next/app/cli.py
src/biliup_next/app/dashboard.py
src/biliup_next/app/worker.py
src/biliup_next/core/config.py
src/biliup_next/core/errors.py
src/biliup_next/core/models.py
src/biliup_next/core/providers.py
src/biliup_next/core/registry.py
src/biliup_next/infra/db.py
src/biliup_next/infra/legacy_paths.py
src/biliup_next/infra/log_reader.py
src/biliup_next/infra/plugin_loader.py
src/biliup_next/infra/runtime_doctor.py
src/biliup_next/infra/stage_importer.py
src/biliup_next/infra/systemd_runtime.py
src/biliup_next/infra/task_repository.py
src/biliup_next/infra/task_reset.py
src/biliup_next/infra/adapters/bilibili_collection_legacy.py
src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py
src/biliup_next/infra/adapters/biliup_publish_legacy.py
src/biliup_next/infra/adapters/codex_legacy.py
src/biliup_next/infra/adapters/ffmpeg_split_legacy.py
src/biliup_next/infra/adapters/groq_legacy.py
src/biliup_next/modules/collection/service.py
src/biliup_next/modules/comment/service.py
src/biliup_next/modules/ingest/service.py
src/biliup_next/modules/ingest/providers/local_file.py
src/biliup_next/modules/publish/service.py
src/biliup_next/modules/song_detect/service.py
src/biliup_next/modules/split/service.py
src/biliup_next/modules/transcribe/service.py

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,2 @@
[console_scripts]
biliup-next = biliup_next.app.cli:main

View File

@ -0,0 +1 @@
requests>=2.32.0

View File

@ -0,0 +1 @@
biliup_next

View File

@ -0,0 +1 @@
"""biliup-next package."""

View File

@ -0,0 +1,530 @@
from __future__ import annotations
import cgi
import json
import mimetypes
from http import HTTPStatus
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 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.dashboard import render_dashboard_html
from biliup_next.app.retry_meta import retry_meta_for_step
from biliup_next.app.scheduler import build_scheduler_preview
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
from biliup_next.infra.log_reader import LogReader
from biliup_next.infra.runtime_doctor import RuntimeDoctor
from biliup_next.infra.stage_importer import StageImporter
from biliup_next.infra.storage_guard import mb_to_bytes
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
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),
},
}
def _serve_asset(self, asset_name: str) -> None:
root = ensure_initialized()["root"]
asset_path = root / "src" / "biliup_next" / "app" / "static" / asset_name
if not asset_path.exists():
self._json({"error": "asset not found"}, status=HTTPStatus.NOT_FOUND)
return
content_type = self._guess_content_type(asset_path)
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(asset_path.read_bytes())
def _guess_content_type(self, path: Path) -> str:
guessed, _ = mimetypes.guess_type(path.name)
if guessed:
if guessed.startswith("text/") or guessed in {"application/javascript", "application/json"}:
return f"{guessed}; charset=utf-8"
return guessed
return "application/octet-stream"
def _frontend_dist_dir(self) -> Path:
root = ensure_initialized()["root"]
return root / "frontend" / "dist"
def _frontend_dist_ready(self) -> bool:
dist = self._frontend_dist_dir()
return (dist / "index.html").exists()
def _serve_frontend_dist(self, parsed_path: str) -> bool:
dist = self._frontend_dist_dir()
if not (dist / "index.html").exists():
return False
if parsed_path in {"/ui", "/ui/"}:
self._html((dist / "index.html").read_text(encoding="utf-8"))
return True
if not parsed_path.startswith("/ui/"):
return False
relative = parsed_path.removeprefix("/ui/")
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 in Path(relative).name:
self._html((dist / "index.html").read_text(encoding="utf-8"))
return True
self._json({"error": "frontend asset not found"}, status=HTTPStatus.NOT_FOUND)
return True
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path.startswith("/ui") 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 == "/":
self._html(render_dashboard_html())
return
if parsed.path == "/health":
self._json({"ok": True})
return
if parsed.path == "/settings":
state = ensure_initialized()
service = SettingsService(state["root"])
self._json(service.load_redacted().settings)
return
if parsed.path == "/settings/schema":
state = ensure_initialized()
service = SettingsService(state["root"])
self._json(service.load().schema)
return
if parsed.path == "/doctor":
doctor = RuntimeDoctor(ensure_initialized()["root"])
self._json(doctor.run())
return
if parsed.path == "/runtime/services":
self._json(SystemdRuntime().list_services())
return
if parsed.path == "/scheduler/preview":
state = ensure_initialized()
self._json(build_scheduler_preview(state, include_stage_scan=False, limit=200))
return
if parsed.path == "/logs":
query = parse_qs(parsed.query)
name = query.get("name", [None])[0]
if not name:
self._json(LogReader().list_logs())
return
lines = int(query.get("lines", ["200"])[0])
contains = query.get("contains", [None])[0]
self._json(LogReader().tail(name, lines, contains))
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})
return
if parsed.path == "/modules":
state = ensure_initialized()
self._json({"items": state["registry"].list_manifests(), "discovered_manifests": state["manifests"]})
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})
return
if parsed.path.startswith("/tasks/"):
state = ensure_initialized()
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)
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})
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})
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})
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})
return
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if not self._check_auth(parsed.path):
return
if parsed.path != "/settings":
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"{}")
root = ensure_initialized()["root"]
service = SettingsService(root)
service.save_staged_from_redacted(payload)
service.promote_staged()
self._json({"ok": True})
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if not self._check_auth(parsed.path):
return
if parsed.path != "/tasks":
if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
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)
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)
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)
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)
return
if parsed.path == "/stage/upload":
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type:
self._json({"error": "content-type must be multipart/form-data"}, status=HTTPStatus.BAD_REQUEST)
return
form = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": content_type,
"CONTENT_LENGTH": self.headers.get("Content-Length", "0"),
},
)
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)
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)
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)
def log_message(self, format: str, *args) -> None: # noqa: A003
return
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None:
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _record_action(self, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None:
state = ensure_initialized()
state["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(),
)
)
def _check_auth(self, path: str) -> bool:
if path in {"/", "/health", "/ui", "/ui/"} or path.startswith("/assets/") or path.startswith("/ui/assets/"):
return True
state = ensure_initialized()
expected = str(state["settings"]["runtime"].get("control_token", "")).strip()
if not expected:
return True
provided = self.headers.get("X-Biliup-Token", "").strip()
if provided == expected:
return True
self._json({"error": "unauthorized"}, status=HTTPStatus.UNAUTHORIZED)
return False
def serve(host: str, port: int) -> None:
ensure_initialized()
server = ThreadingHTTPServer((host, port), ApiHandler)
print(f"biliup-next api listening on http://{host}:{port}")
server.serve_forever()

View File

@ -0,0 +1,77 @@
from __future__ import annotations
from pathlib import Path
from dataclasses import asdict
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
from biliup_next.modules.collection.service import CollectionService
from biliup_next.modules.comment.service import CommentService
from biliup_next.modules.ingest.service import IngestService
from biliup_next.modules.publish.service import PublishService
from biliup_next.modules.song_detect.service import SongDetectService
from biliup_next.modules.split.service import SplitService
from biliup_next.modules.transcribe.service import TranscribeService
def project_root() -> Path:
return Path(__file__).resolve().parents[3]
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,
}

120
src/biliup_next/app/cli.py Normal file
View File

@ -0,0 +1,120 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from biliup_next.app.api_server import serve
from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.scheduler import run_scheduler_cycle
from biliup_next.app.worker import run_forever, run_once
from biliup_next.infra.legacy_asset_sync import LegacyAssetSync
from biliup_next.infra.runtime_doctor import RuntimeDoctor
def main() -> None:
parser = argparse.ArgumentParser(prog="biliup-next")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("init")
sub.add_parser("doctor")
sub.add_parser("list-tasks")
sub.add_parser("list-modules")
sub.add_parser("run-once")
sub.add_parser("schedule-once")
sub.add_parser("init-workspace")
sub.add_parser("sync-legacy-assets")
sub.add_parser("scan-stage")
delete_task_parser = sub.add_parser("delete-task")
delete_task_parser.add_argument("task_id")
worker_parser = sub.add_parser("worker")
worker_parser.add_argument("--interval", type=int, default=5)
create_task_parser = sub.add_parser("create-task")
create_task_parser.add_argument("source_path")
serve_parser = sub.add_parser("serve")
serve_parser.add_argument("--host", default="127.0.0.1")
serve_parser.add_argument("--port", type=int, default=8787)
args = parser.parse_args()
if args.command == "init":
state = ensure_initialized()
print(json.dumps({"ok": True, "imported": state["imported"]}, ensure_ascii=False, indent=2))
return
if args.command == "doctor":
root = ensure_initialized()["root"]
doctor = RuntimeDoctor(root)
print(json.dumps(doctor.run(), ensure_ascii=False, indent=2))
return
if args.command == "init-workspace":
state = ensure_initialized()
paths = state["settings"]["paths"]
created = []
for key in ("stage_dir", "backup_dir", "session_dir"):
path = (state["root"] / paths[key]).resolve()
path.mkdir(parents=True, exist_ok=True)
created.append(str(path))
print(json.dumps({"ok": True, "created": created}, ensure_ascii=False, indent=2))
return
if args.command == "sync-legacy-assets":
root = ensure_initialized()["root"]
result = LegacyAssetSync(root).sync()
print(json.dumps(result, ensure_ascii=False, indent=2))
return
if args.command == "list-tasks":
state = ensure_initialized()
tasks = [task.to_dict() for task in state["repo"].list_tasks()]
print(json.dumps({"items": tasks}, ensure_ascii=False, indent=2))
return
if args.command == "list-modules":
state = ensure_initialized()
print(json.dumps({"items": state["registry"].list_manifests()}, ensure_ascii=False, indent=2))
return
if args.command == "delete-task":
state = ensure_initialized()
state["repo"].delete_task(args.task_id)
print(json.dumps({"ok": True, "deleted_task_id": args.task_id}, ensure_ascii=False, indent=2))
return
if args.command == "scan-stage":
state = ensure_initialized()
settings = dict(state["settings"]["ingest"])
settings.update(state["settings"]["paths"])
print(json.dumps(state["ingest_service"].scan_stage(settings), ensure_ascii=False, indent=2))
return
if args.command == "create-task":
state = ensure_initialized()
task = state["ingest_service"].create_task_from_file(
Path(args.source_path),
state["settings"]["ingest"],
)
print(json.dumps(task.to_dict(), ensure_ascii=False, indent=2))
return
if args.command == "run-once":
print(json.dumps(run_once(), ensure_ascii=False, indent=2))
return
if args.command == "schedule-once":
print(json.dumps(run_scheduler_cycle(include_stage_scan=True, limit=200).preview, ensure_ascii=False, indent=2))
return
if args.command == "worker":
run_forever(args.interval)
return
if args.command == "serve":
serve(args.host, args.port)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,346 @@
from __future__ import annotations
def render_dashboard_html() -> str:
return """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>biliup-next Control</title>
<link rel="stylesheet" href="/assets/dashboard.css" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar-brand">
<p class="eyebrow">Biliup Next</p>
<h1>Control</h1>
<p class="sidebar-copy">围绕任务状态、运行时健康和配置管理组织的本地控制面。</p>
</div>
<nav class="sidebar-nav">
<button class="nav-btn active" data-view="overview">Overview</button>
<button class="nav-btn" data-view="tasks">Tasks</button>
<button class="nav-btn" data-view="settings">Settings</button>
<button class="nav-btn" data-view="logs">Logs</button>
</nav>
<div class="sidebar-section">
<label class="sidebar-label">Control Token</label>
<div class="sidebar-token">
<input id="tokenInput" placeholder="optional control token" />
<button id="saveTokenBtn" class="secondary compact">保存</button>
</div>
</div>
<div class="sidebar-section">
<div class="button-stack">
<button id="refreshBtn">刷新视图</button>
<button id="runOnceBtn" class="secondary">执行一轮 Worker</button>
<button id="saveSettingsBtn" class="secondary">保存 Settings</button>
</div>
</div>
</aside>
<main class="content">
<header class="topbar">
<div>
<p class="eyebrow">Operational Workspace</p>
<h2 id="viewTitle">Overview</h2>
</div>
<div class="topbar-meta">
<div class="status-chip">API · <span id="healthValue">-</span></div>
<div class="status-chip">Doctor · <span id="doctorValue">-</span></div>
<div class="status-chip">Tasks · <span id="tasksValue">-</span></div>
</div>
</header>
<div id="banner" class="banner"></div>
<section class="view active" data-view="overview">
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Runtime Snapshot</h3></div>
<div class="stats">
<div class="stat-card">
<span class="stat-label">Health</span>
<strong id="overviewHealthValue" class="stat-value">-</strong>
</div>
<div class="stat-card">
<span class="stat-label">Doctor</span>
<strong id="overviewDoctorValue" class="stat-value">-</strong>
</div>
<div class="stat-card">
<span class="stat-label">Tasks</span>
<strong id="overviewTasksValue" class="stat-value">-</strong>
</div>
</div>
<div id="overviewTaskSummary" class="summary-strip" style="margin-top:14px;"></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Import To Stage</h3></div>
<div class="field-grid">
<input id="stageSourcePath" placeholder="/absolute/path/to/video.mp4" />
<button id="importStageBtn" class="secondary">复制到隔离 Stage</button>
</div>
<div class="field-grid upload-grid">
<input id="stageFileInput" type="file" />
<button id="uploadStageBtn" class="secondary">上传到隔离 Stage</button>
</div>
<p class="muted-note">只会复制或上传到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
</section>
</div>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Services</h3></div>
<div id="serviceList" class="stack-list"></div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Recent Actions</h3>
<button id="refreshHistoryBtn" class="secondary compact">刷新</button>
</div>
<div class="filter-grid">
<label class="checkbox-row"><input id="historyCurrentTask" type="checkbox" />仅当前任务</label>
<select id="historyStatusFilter">
<option value="">全部状态</option>
<option value="ok">ok</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
<input id="historyActionFilter" placeholder="action_name如 worker_run_once" />
</div>
<div id="recentActionList" class="stack-list"></div>
</section>
</div>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head">
<h3>Scheduler Queue</h3>
<button id="refreshSchedulerBtn" class="secondary compact">刷新</button>
</div>
<div id="schedulerSummary" class="summary-strip"></div>
<div id="schedulerList" class="stack-list"></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Stage Scan Result</h3></div>
<div id="stageScanSummary" class="stack-list"></div>
</section>
</div>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Doctor Checks</h3></div>
<div id="doctorChecks" class="stack-list"></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Retry & Manual Attention</h3></div>
<div id="overviewRetrySummary" class="stack-list"></div>
</section>
</div>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Modules</h3></div>
<div id="moduleList" class="stack-list"></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Overview Notes</h3></div>
<div class="stack-list">
<div class="row-card">
<strong>先看 Health / Doctor</strong>
<div class="muted-note">系统级异常通常先体现在依赖检查,而不是单任务状态。</div>
</div>
<div class="row-card">
<strong>再看 Retry Summary</strong>
<div class="muted-note">优先处理已到重试时间和需要人工介入的任务。</div>
</div>
<div class="row-card">
<strong>最后看 Recent Actions</strong>
<div class="muted-note">用动作流判断最近系统是否真的在前进。</div>
</div>
</div>
</section>
</div>
</section>
<section class="view" data-view="tasks">
<div class="tasks-layout">
<section class="panel task-index-panel">
<div class="panel-head"><h3>Task Index</h3></div>
<div class="task-index-summary">
<div id="taskStatusSummary" class="summary-strip"></div>
<div class="task-pagination-toolbar">
<div id="taskPaginationSummary" class="muted-note">-</div>
<div class="button-row">
<select id="taskPageSizeSelect">
<option value="12">12 / page</option>
<option value="24" selected>24 / page</option>
<option value="48">48 / page</option>
</select>
<button id="taskPrevPageBtn" class="secondary compact">上一页</button>
<button id="taskNextPageBtn" class="secondary compact">下一页</button>
</div>
</div>
</div>
<div class="task-filters">
<input id="taskSearchInput" placeholder="搜索任务标题或 task id" />
<select id="taskStatusFilter">
<option value="">全部状态</option>
<option value="created">created</option>
<option value="transcribed">transcribed</option>
<option value="songs_detected">songs_detected</option>
<option value="split_done">split_done</option>
<option value="published">published</option>
<option value="collection_synced">collection_synced</option>
<option value="failed_retryable">failed_retryable</option>
<option value="failed_manual">failed_manual</option>
</select>
<select id="taskSortSelect">
<option value="updated_desc">最近更新</option>
<option value="updated_asc">最早更新</option>
<option value="title_asc">标题 A-Z</option>
<option value="title_desc">标题 Z-A</option>
<option value="attention_state">按关注状态</option>
<option value="status_group">按状态分组</option>
<option value="split_comment_status">按纯享评论</option>
<option value="full_comment_status">按主视频评论</option>
<option value="cleanup_state">按清理状态</option>
<option value="next_retry_asc">按下次重试</option>
</select>
<select id="taskDeliveryFilter">
<option value="">全部交付状态</option>
<option value="legacy_untracked">主视频评论未追踪</option>
<option value="pending_comment">评论待完成</option>
<option value="cleanup_removed">已清理视频</option>
</select>
<select id="taskAttentionFilter">
<option value="">全部关注状态</option>
<option value="manual_now">仅看需人工</option>
<option value="retry_now">仅看到点重试</option>
<option value="waiting_retry">仅看等待重试</option>
</select>
</div>
<div id="taskListState" class="task-list-state">正在加载任务列表…</div>
<div id="taskList" class="task-table-wrap"></div>
</section>
<div class="task-workspace">
<section class="panel task-panel">
<div class="panel-head">
<h3>Task Detail</h3>
<div class="button-row">
<button id="runTaskBtn" class="secondary compact">执行当前任务</button>
<button id="retryStepBtn" class="secondary compact">重试选中 Step</button>
<button id="resetStepBtn" class="secondary compact">重置到选中 Step</button>
</div>
</div>
<div id="taskWorkspaceState" class="task-workspace-state show">选择一个任务后,这里会显示当前链路、重试状态和最近动作。</div>
<div id="taskHero" class="task-hero empty">选择一个任务后,这里会显示当前链路、重试状态和最近动作。</div>
<div id="taskRetryPanel" class="retry-banner hidden"></div>
<div class="detail-layout">
<div id="taskDetail" class="detail-grid"></div>
<div id="taskSummary" class="summary-card">暂无最近结果</div>
</div>
</section>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Steps</h3></div>
<div id="stepList" class="stack-list"></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Artifacts</h3></div>
<div id="artifactList" class="stack-list"></div>
</section>
</div>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>History</h3></div>
<div id="historyList" class="stack-list"></div>
</section>
<section class="panel">
<div class="panel-head"><h3>Timeline</h3></div>
<div id="timelineList" class="timeline-list"></div>
</section>
</div>
</div>
</div>
</section>
<section class="view" data-view="settings">
<section class="panel">
<div class="panel-head"><h3>Settings</h3></div>
<div class="settings-toolbar">
<input id="settingsSearch" placeholder="过滤配置项,例如 codex / season / retry" />
<div class="button-row">
<button id="syncFormToJsonBtn" class="secondary compact">表单同步到 JSON</button>
<button id="syncJsonToFormBtn" class="secondary compact">JSON 重绘表单</button>
</div>
</div>
<div id="settingsForm" class="settings-groups"></div>
<details class="settings-advanced">
<summary>Advanced JSON Editor</summary>
<textarea id="settingsEditor" spellcheck="false"></textarea>
</details>
<p class="muted-note">敏感字段显示为 `__BILIUP_NEXT_SECRET__`。保留占位符表示不改原值,改为空字符串表示清空。</p>
</section>
</section>
<section class="view" data-view="logs">
<div class="logs-workspace">
<section class="panel logs-index-panel">
<div class="panel-head"><h3>Log Index</h3></div>
<div class="task-filters">
<input id="logSearchInput" placeholder="搜索日志文件名" />
<label class="checkbox-row"><input id="filterCurrentTask" type="checkbox" />按当前任务标题过滤</label>
<label class="checkbox-row"><input id="logAutoRefresh" type="checkbox" />自动刷新</label>
</div>
<div class="button-row" style="margin-bottom:12px;">
<button id="refreshLogBtn" class="secondary compact">刷新日志</button>
</div>
<div id="logListState" class="task-list-state show">正在加载日志索引…</div>
<div id="logList" class="task-list"></div>
</section>
<div class="log-content-stack">
<section class="panel">
<div class="panel-head"><h3>Log Content</h3></div>
<div class="filter-grid">
<input id="logLineFilter" placeholder="过滤内容关键字" />
<div class="muted-note" id="logPath">-</div>
<div class="muted-note" id="logMeta">-</div>
</div>
<pre id="logContent"></pre>
</section>
<section class="panel">
<div class="panel-head"><h3>Logs Guide</h3></div>
<div class="stack-list">
<div class="row-card">
<strong>优先看当前任务</strong>
<div class="muted-note">勾选“按当前任务标题过滤”,可快速聚焦任务链路。</div>
</div>
<div class="row-card">
<strong>先看系统,再看任务</strong>
<div class="muted-note">如果服务异常,先看 `systemd` 状态;如果单任务异常,再看 steps/history/timeline。</div>
</div>
<div class="row-card">
<strong>上传异常</strong>
<div class="muted-note">优先看 `upload.log`、任务时间线里的 publish error以及下一次重试时间。</div>
</div>
</div>
</section>
</div>
</div>
</section>
</main>
</div>
<script type="module" src="/assets/app/main.js"></script>
</body>
</html>
"""

View File

@ -0,0 +1,73 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
def parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
raw_schedule = settings.get("retry_schedule_minutes")
if isinstance(raw_schedule, list):
schedule: list[int] = []
for item in raw_schedule:
if isinstance(item, int) and not isinstance(item, bool) and item >= 0:
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 = 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 = max(retry_backoff, 0)
return [retry_backoff] * retry_count
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":
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 {
"retry_due": False,
"retry_exhausted": True,
"retry_wait_seconds": None,
"retry_remaining_seconds": None,
"next_retry_at": None,
}
wait_seconds = schedule[attempt_index]
reference = parse_iso(getattr(step, "finished_at", None)) or parse_iso(getattr(step, "started_at", None))
if reference is None:
return {
"retry_due": True,
"retry_exhausted": False,
"retry_wait_seconds": wait_seconds,
"retry_remaining_seconds": 0,
"next_retry_at": datetime.now(timezone.utc).isoformat(),
}
next_retry_at = reference + timedelta(seconds=wait_seconds)
now = datetime.now(timezone.utc)
remaining_seconds = max(int((next_retry_at - now).total_seconds()), 0)
return {
"retry_due": now >= next_retry_at,
"retry_exhausted": False,
"retry_wait_seconds": wait_seconds,
"retry_remaining_seconds": remaining_seconds,
"next_retry_at": next_retry_at.isoformat(),
}

View File

@ -0,0 +1,181 @@
from __future__ import annotations
from dataclasses import asdict
from dataclasses import dataclass
from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.task_engine import next_runnable_step, settings_for
DEFAULT_STATUS_PRIORITY = [
"failed_retryable",
"created",
"transcribed",
"songs_detected",
"split_done",
"published",
"commented",
"collection_synced",
]
@dataclass(slots=True)
class ScheduledTask:
task_id: str
reason: str
step_name: str | None = None
remaining_seconds: int | None = None
task_status: str | None = None
updated_at: str | None = None
@dataclass(slots=True)
class SchedulerCycle:
preview: dict[str, object]
scheduled: list[ScheduledTask]
deferred: list[dict[str, object]]
def serialize_scheduled_task(item: ScheduledTask) -> dict[str, object]:
return asdict(item)
def scheduler_settings(state: dict[str, object]) -> dict[str, object]:
return dict(state["settings"].get("scheduler", {}))
def _status_priority_map(state: dict[str, object]) -> dict[str, int]:
configured = scheduler_settings(state).get("status_priority", DEFAULT_STATUS_PRIORITY)
ordered = configured if isinstance(configured, list) else DEFAULT_STATUS_PRIORITY
return {str(status): index for index, status in enumerate(ordered)}
def _task_sort_key(state: dict[str, object], item: ScheduledTask) -> tuple[int, int, str]:
settings = scheduler_settings(state)
status_priority = _status_priority_map(state)
retry_rank = 0 if settings.get("prioritize_retry_due", True) and item.reason == "retry_due" else 1
status_rank = status_priority.get(item.task_status or "", len(status_priority) + 10)
updated_at = item.updated_at or ""
if not settings.get("oldest_first", True):
updated_at = "".join(chr(255 - ord(ch)) for ch in updated_at)
return retry_rank, status_rank, updated_at
def _strategy_payload(state: dict[str, object], *, requested_limit: int) -> dict[str, object]:
settings = scheduler_settings(state)
return {
"candidate_scan_limit": int(settings.get("candidate_scan_limit", requested_limit)),
"max_tasks_per_cycle": int(settings.get("max_tasks_per_cycle", requested_limit)),
"prioritize_retry_due": bool(settings.get("prioritize_retry_due", True)),
"oldest_first": bool(settings.get("oldest_first", True)),
"status_priority": list(settings.get("status_priority", DEFAULT_STATUS_PRIORITY)),
}
def scan_stage_once(state: dict[str, object]) -> dict[str, object]:
ingest_settings = settings_for(state, "ingest")
return state["ingest_service"].scan_stage(ingest_settings)
def select_scheduled_tasks(state: dict[str, object], *, limit: int = 200) -> tuple[list[ScheduledTask], list[dict[str, object]]]:
repo = state["repo"]
scheduled: list[ScheduledTask] = []
deferred: list[dict[str, object]] = []
settings = scheduler_settings(state)
candidate_limit = int(settings.get("candidate_scan_limit", limit))
max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit))
for task in repo.list_tasks(limit=candidate_limit):
if task.status == "failed_manual":
continue
steps = {step.step_name: step for step in repo.list_steps(task.id)}
step_name, waiting_payload = next_runnable_step(task, steps, state)
if waiting_payload is not None:
deferred.append(waiting_payload)
continue
if step_name is None:
continue
scheduled.append(
ScheduledTask(
task.id,
reason="retry_due" if task.status == "failed_retryable" else "ready",
step_name=step_name,
remaining_seconds=None,
task_status=task.status,
updated_at=task.updated_at,
)
)
scheduled.sort(key=lambda item: _task_sort_key(state, item))
return scheduled[:max_tasks_per_cycle], deferred
def build_scheduler_preview(state: dict[str, object], *, limit: int = 200, include_stage_scan: bool = False) -> dict[str, object]:
repo = state["repo"]
settings = scheduler_settings(state)
candidate_limit = int(settings.get("candidate_scan_limit", limit))
max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit))
stage_scan_result: dict[str, object] = {"accepted": [], "rejected": [], "skipped": []}
if include_stage_scan:
stage_scan_result = scan_stage_once(state)
scheduled_all: list[ScheduledTask] = []
deferred: list[dict[str, object]] = []
skipped_counts = {
"failed_manual": 0,
"no_runnable_step": 0,
}
scanned_count = 0
for task in repo.list_tasks(limit=candidate_limit):
scanned_count += 1
if task.status == "failed_manual":
skipped_counts["failed_manual"] += 1
continue
steps = {step.step_name: step for step in repo.list_steps(task.id)}
step_name, waiting_payload = next_runnable_step(task, steps, state)
if waiting_payload is not None:
deferred.append(waiting_payload)
continue
if step_name is None:
skipped_counts["no_runnable_step"] += 1
continue
scheduled_all.append(
ScheduledTask(
task.id,
reason="retry_due" if task.status == "failed_retryable" else "ready",
step_name=step_name,
remaining_seconds=None,
task_status=task.status,
updated_at=task.updated_at,
)
)
scheduled_all.sort(key=lambda item: _task_sort_key(state, item))
scheduled = scheduled_all[:max_tasks_per_cycle]
truncated_count = max(0, len(scheduled_all) - len(scheduled))
return {
"stage_scan": stage_scan_result,
"scheduled": [serialize_scheduled_task(item) for item in scheduled],
"deferred": deferred,
"summary": {
"scanned_count": scanned_count,
"scheduled_count": len(scheduled),
"deferred_count": len(deferred),
"truncated_count": truncated_count,
"skipped_counts": skipped_counts,
},
"strategy": _strategy_payload(state, requested_limit=limit),
}
def run_scheduled_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> tuple[dict[str, object], list[ScheduledTask], list[dict[str, object]]]:
cycle = run_scheduler_cycle(include_stage_scan=include_stage_scan, limit=limit)
return cycle.preview["stage_scan"], cycle.scheduled, cycle.deferred
def run_scheduler_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> SchedulerCycle:
state = ensure_initialized()
preview = build_scheduler_preview(state, include_stage_scan=include_stage_scan, limit=limit)
scheduled = [ScheduledTask(**item) for item in preview["scheduled"]]
return SchedulerCycle(preview=preview, scheduled=scheduled, deferred=preview["deferred"])

View File

@ -0,0 +1,215 @@
import { fetchJson } from "./api.js";
import { navigate } from "./router.js";
import {
clearSettingsFieldState,
resetTaskPage,
setLogAutoRefreshTimer,
setSelectedTask,
setTaskPage,
setTaskPageSize,
state,
} from "./state.js";
import { showBanner, syncSettingsEditorFromState } from "./utils.js";
import { renderSettingsForm } from "./views/settings.js";
import { renderTasks } from "./views/tasks.js";
export function bindActions({
loadOverview,
loadTaskDetail,
refreshLog,
handleSettingsFieldChange,
}) {
document.querySelectorAll(".nav-btn").forEach((button) => {
button.onclick = () => navigate(button.dataset.view);
});
document.getElementById("refreshBtn").onclick = async () => {
await loadOverview();
showBanner("视图已刷新", "ok");
};
document.getElementById("runOnceBtn").onclick = async () => {
try {
const result = await fetchJson("/worker/run-once", { method: "POST" });
await loadOverview();
showBanner(`Worker 已执行一轮processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(String(err), "err");
}
};
document.getElementById("saveSettingsBtn").onclick = async () => {
try {
const payload = JSON.parse(document.getElementById("settingsEditor").value);
await fetchJson("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
clearSettingsFieldState();
await loadOverview();
showBanner("Settings 已保存", "ok");
} catch (err) {
showBanner(`保存失败: ${err}`, "err");
}
};
document.getElementById("syncFormToJsonBtn").onclick = () => {
syncSettingsEditorFromState();
showBanner("表单已同步到 JSON", "ok");
};
document.getElementById("syncJsonToFormBtn").onclick = () => {
try {
state.currentSettings = JSON.parse(document.getElementById("settingsEditor").value);
clearSettingsFieldState();
renderSettingsForm(handleSettingsFieldChange);
showBanner("JSON 已重绘到表单", "ok");
} catch (err) {
showBanner(`JSON 解析失败: ${err}`, "err");
}
};
document.getElementById("settingsSearch").oninput = () => renderSettingsForm(handleSettingsFieldChange);
document.getElementById("settingsForm").onclick = (event) => {
const button = event.target.closest("button[data-revert-group]");
if (!button) return;
const group = button.dataset.revertGroup;
const field = button.dataset.revertField;
const originalValue = state.originalSettings[group]?.[field];
state.currentSettings[group] ??= {};
if (originalValue === undefined) delete state.currentSettings[group][field];
else state.currentSettings[group][field] = JSON.parse(JSON.stringify(originalValue));
clearSettingsFieldState();
renderSettingsForm(handleSettingsFieldChange);
showBanner(`已撤销 ${group}.${field}`, "ok");
};
const rerenderTasks = () => renderTasks(async (taskId) => {
setSelectedTask(taskId);
await loadTaskDetail(taskId);
});
document.getElementById("taskSearchInput").oninput = () => { resetTaskPage(); rerenderTasks(); };
document.getElementById("taskStatusFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
document.getElementById("taskSortSelect").onchange = () => { resetTaskPage(); rerenderTasks(); };
document.getElementById("taskDeliveryFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
document.getElementById("taskAttentionFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
document.getElementById("taskPageSizeSelect").onchange = () => {
setTaskPageSize(Number(document.getElementById("taskPageSizeSelect").value) || 24);
resetTaskPage();
rerenderTasks();
};
document.getElementById("taskPrevPageBtn").onclick = () => {
setTaskPage(Math.max(1, state.taskPage - 1));
rerenderTasks();
};
document.getElementById("taskNextPageBtn").onclick = () => {
setTaskPage(state.taskPage + 1);
rerenderTasks();
};
document.getElementById("importStageBtn").onclick = async () => {
const sourcePath = document.getElementById("stageSourcePath").value.trim();
if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn");
try {
const result = await fetchJson("/stage/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_path: sourcePath }),
});
document.getElementById("stageSourcePath").value = "";
showBanner(`已导入到 stage: ${result.target_path}`, "ok");
} catch (err) {
showBanner(`导入失败: ${err}`, "err");
}
};
document.getElementById("uploadStageBtn").onclick = async () => {
const input = document.getElementById("stageFileInput");
if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn");
try {
const form = new FormData();
form.append("file", input.files[0]);
const res = await fetch("/stage/upload", { method: "POST", body: form });
const data = await res.json();
if (!res.ok) throw new Error(data.error || JSON.stringify(data));
input.value = "";
showBanner(`已上传到 stage: ${data.target_path}`, "ok");
} catch (err) {
showBanner(`上传失败: ${err}`, "err");
}
};
document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
document.getElementById("logSearchInput").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
document.getElementById("logLineFilter").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
document.getElementById("logAutoRefresh").onchange = () => {
if (state.logAutoRefreshTimer) {
clearInterval(state.logAutoRefreshTimer);
setLogAutoRefreshTimer(null);
}
if (document.getElementById("logAutoRefresh").checked) {
const timer = window.setInterval(() => {
refreshLog().catch(() => {});
}, 5000);
setLogAutoRefreshTimer(timer);
}
};
document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err"));
document.getElementById("refreshSchedulerBtn").onclick = () => loadOverview().then(() => showBanner("调度队列已刷新", "ok")).catch((err) => showBanner(`调度队列刷新失败: ${err}`, "err"));
document.getElementById("saveTokenBtn").onclick = async () => {
const token = document.getElementById("tokenInput").value.trim();
localStorage.setItem("biliup_next_token", token);
try {
await loadOverview();
showBanner("Token 已保存并生效", "ok");
} catch (err) {
showBanner(`Token 验证失败: ${err}`, "err");
}
};
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");
}
};
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");
}
};
document.getElementById("resetStepBtn").onclick = async () => {
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
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");
}
};
}

View File

@ -0,0 +1,52 @@
import { state } from "./state.js";
export async function fetchJson(url, options) {
const token = localStorage.getItem("biliup_next_token") || "";
const opts = options ? { ...options } : {};
opts.headers = { ...(opts.headers || {}) };
if (token) opts.headers["X-Biliup-Token"] = token;
const res = await fetch(url, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data));
return data;
}
export function buildHistoryUrl() {
const params = new URLSearchParams();
params.set("limit", "20");
const status = document.getElementById("historyStatusFilter")?.value || "";
const actionName = document.getElementById("historyActionFilter")?.value.trim() || "";
const currentOnly = document.getElementById("historyCurrentTask")?.checked;
if (status) params.set("status", status);
if (actionName) params.set("action_name", actionName);
if (currentOnly && state.selectedTaskId) params.set("task_id", state.selectedTaskId);
return `/history?${params.toString()}`;
}
export async function loadOverviewPayload() {
const historyUrl = buildHistoryUrl();
const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler] = await Promise.all([
fetchJson("/health"),
fetchJson("/doctor"),
fetchJson("/tasks?limit=100"),
fetchJson("/modules"),
fetchJson("/settings"),
fetchJson("/settings/schema"),
fetchJson("/runtime/services"),
fetchJson("/logs"),
fetchJson(historyUrl),
fetchJson("/scheduler/preview"),
]);
return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler };
}
export async function loadTaskPayload(taskId) {
const [task, steps, artifacts, history, timeline] = await Promise.all([
fetchJson(`/tasks/${taskId}`),
fetchJson(`/tasks/${taskId}/steps`),
fetchJson(`/tasks/${taskId}/artifacts`),
fetchJson(`/tasks/${taskId}/history`),
fetchJson(`/tasks/${taskId}/timeline`),
]);
return { task, steps, artifacts, history, timeline };
}

View File

@ -0,0 +1,16 @@
import { escapeHtml, formatDate } from "../utils.js";
export function renderArtifactList(artifacts) {
const artifactWrap = document.getElementById("artifactList");
artifactWrap.innerHTML = "";
artifacts.items.forEach((artifact) => {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
`;
artifactWrap.appendChild(row);
});
}

View File

@ -0,0 +1,15 @@
import { escapeHtml } from "../utils.js";
export function renderDoctor(checks) {
const wrap = document.getElementById("doctorChecks");
wrap.innerHTML = "";
for (const check of checks) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
<div class="muted-note">${escapeHtml(check.detail)}</div>
`;
wrap.appendChild(row);
}
}

View File

@ -0,0 +1,23 @@
import { escapeHtml, formatDate, statusClass } from "../utils.js";
export function renderHistoryList(history) {
const historyWrap = document.getElementById("historyList");
historyWrap.innerHTML = "";
history.items.forEach((item) => {
let details = "";
try {
details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2);
} catch {
details = item.details_json || "";
}
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(item.action_name)}</strong><span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span></div>
<div class="muted-note">${escapeHtml(item.summary)}</div>
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
<pre>${escapeHtml(details)}</pre>
`;
historyWrap.appendChild(row);
});
}

View File

@ -0,0 +1,15 @@
import { escapeHtml } from "../utils.js";
export function renderModules(items) {
const wrap = document.getElementById("moduleList");
wrap.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
`;
wrap.appendChild(row);
}
}

View File

@ -0,0 +1,11 @@
export function renderRuntimeSnapshot({ health, doctor, tasks }) {
const healthText = health.ok ? "OK" : "FAIL";
const doctorText = doctor.ok ? "OK" : "FAIL";
document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || "";
document.getElementById("healthValue").textContent = healthText;
document.getElementById("doctorValue").textContent = doctorText;
document.getElementById("tasksValue").textContent = tasks.items.length;
document.getElementById("overviewHealthValue").textContent = healthText;
document.getElementById("overviewDoctorValue").textContent = doctorText;
document.getElementById("overviewTasksValue").textContent = tasks.items.length;
}

View File

@ -0,0 +1,46 @@
import { statusClass } from "../utils.js";
export function renderOverviewTaskSummary(tasks) {
const wrap = document.getElementById("overviewTaskSummary");
if (!wrap) return;
const counts = new Map();
tasks.forEach((task) => counts.set(task.status, (counts.get(task.status) || 0) + 1));
const ordered = ["running", "failed_retryable", "failed_manual", "published", "collection_synced", "created", "transcribed", "songs_detected", "split_done"];
wrap.innerHTML = "";
ordered.forEach((status) => {
const count = counts.get(status);
if (!count) return;
const pill = document.createElement("div");
pill.className = `pill ${statusClass(status)}`;
pill.textContent = `${status} ${count}`;
wrap.appendChild(pill);
});
if (!wrap.children.length) {
const pill = document.createElement("div");
pill.className = "pill";
pill.textContent = "no tasks";
wrap.appendChild(pill);
}
}
export function renderOverviewRetrySummary(tasks) {
const wrap = document.getElementById("overviewRetrySummary");
if (!wrap) return;
const waitingRetry = tasks.filter((task) => task.retry_state?.next_retry_at && !task.retry_state?.retry_due);
const dueRetry = tasks.filter((task) => task.retry_state?.retry_due);
const failedManual = tasks.filter((task) => task.status === "failed_manual");
wrap.innerHTML = `
<div class="row-card">
<strong>Waiting Retry</strong>
<div class="muted-note">${waitingRetry.length} 个任务正在等待下一次重试</div>
</div>
<div class="row-card">
<strong>Retry Due</strong>
<div class="muted-note">${dueRetry.length} 个任务已到重试时间</div>
</div>
<div class="row-card">
<strong>Manual Attention</strong>
<div class="muted-note">${failedManual.length} 个任务需要人工处理</div>
</div>
`;
}

View File

@ -0,0 +1,19 @@
import { escapeHtml, formatDate, statusClass } from "../utils.js";
export function renderRecentActions(items) {
const wrap = document.getElementById("recentActionList");
wrap.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(item.action_name)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
</div>
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
`;
wrap.appendChild(row);
}
}

View File

@ -0,0 +1,19 @@
import { escapeHtml, formatDate, formatDuration } from "../utils.js";
export function renderRetryPanel(task) {
const wrap = document.getElementById("taskRetryPanel");
const retry = task.retry_state;
if (!retry || !retry.next_retry_at) {
wrap.className = "retry-banner";
wrap.style.display = "none";
wrap.textContent = "";
return;
}
wrap.style.display = "block";
wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`;
wrap.innerHTML = `
<strong>${escapeHtml(retry.step_name)}</strong>
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
`;
}

View File

@ -0,0 +1,27 @@
import { escapeHtml, statusClass } from "../utils.js";
export function renderServices(items, onServiceAction) {
const wrap = document.getElementById("serviceList");
wrap.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "service-card";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(item.id)}</strong>
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
<span class="pill">${escapeHtml(item.sub_state)}</span>
</div>
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
<div class="button-row" style="margin-top:12px;">
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
</div>
`;
wrap.appendChild(row);
}
wrap.querySelectorAll("button[data-service]").forEach((btn) => {
btn.onclick = () => onServiceAction(btn.dataset.service, btn.dataset.action);
});
}

View File

@ -0,0 +1,34 @@
import { state } from "../state.js";
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
export function renderStepList(steps, onStepSelect) {
const stepWrap = document.getElementById("stepList");
stepWrap.innerHTML = "";
steps.items.forEach((step) => {
const row = document.createElement("div");
row.className = `row-card ${state.selectedStepName === step.step_name ? "active" : ""}`;
row.style.cursor = "pointer";
const retryBlock = step.next_retry_at ? `
<div class="step-card-metrics">
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
</div>
` : "";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(step.step_name)}</strong>
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
<span class="pill">retry ${step.retry_count}</span>
</div>
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
<div class="step-card-metrics">
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
</div>
${retryBlock}
`;
row.onclick = () => onStepSelect(step.step_name);
stepWrap.appendChild(row);
});
}

View File

@ -0,0 +1,22 @@
import { escapeHtml, statusClass } from "../utils.js";
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 || {};
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">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>
`;
}

View File

@ -0,0 +1,22 @@
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
export function renderTimelineList(timeline) {
const timelineWrap = document.getElementById("timelineList");
timelineWrap.innerHTML = "";
timeline.items.forEach((item) => {
const retryNote = item.retry_state?.next_retry_at
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
: "";
const row = document.createElement("div");
row.className = "timeline-card";
row.innerHTML = `
<div class="timeline-title"><strong>${escapeHtml(item.title)}</strong><span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span><span class="pill">${escapeHtml(item.kind)}</span></div>
<div class="timeline-meta">
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
${retryNote}
</div>
`;
timelineWrap.appendChild(row);
});
}

View File

@ -0,0 +1,210 @@
import { fetchJson, loadOverviewPayload, loadTaskPayload } from "./api.js";
import { bindActions } from "./actions.js";
import { currentRoute, initRouter, navigate } from "./router.js";
import {
clearSettingsFieldState,
markSettingsFieldDirty,
setOverviewData,
setSettingsFieldError,
setLogs,
setLogListLoading,
setSelectedLog,
setSelectedStep,
setSelectedTask,
setTaskDetailStatus,
setTaskListLoading,
state,
} from "./state.js";
import { settingsFieldKey, showBanner } from "./utils.js";
import {
renderDoctor,
renderModules,
renderRecentActions,
renderSchedulerQueue,
renderServices,
renderShellStats,
} from "./views/overview.js";
import { renderLogContent, renderLogsList } from "./views/logs.js";
import { renderSettingsForm } from "./views/settings.js";
import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js";
async function refreshLog() {
const name = state.selectedLogName;
if (!name) return;
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) {
const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId);
if (currentTask?.title) {
url += `&contains=${encodeURIComponent(currentTask.title)}`;
}
}
const payload = await fetchJson(url);
renderLogContent(payload);
}
async function selectLog(name) {
setSelectedLog(name);
renderLogsList(state.currentLogs, refreshLog, selectLog);
await refreshLog();
}
async function loadTaskDetail(taskId) {
setTaskDetailStatus("loading");
renderTaskWorkspaceState("loading");
try {
const payload = await loadTaskPayload(taskId);
renderTaskDetail(payload, async (stepName) => {
setSelectedStep(stepName);
await loadTaskDetail(taskId);
});
setTaskDetailStatus("ready");
renderTaskWorkspaceState("ready");
} catch (err) {
const message = `任务详情加载失败: ${err}`;
setTaskDetailStatus("error", message);
renderTaskWorkspaceState("error", message);
throw err;
}
}
function taskSelectHandler(taskId) {
setSelectedTask(taskId);
setSelectedStep(null);
navigate("tasks", taskId);
renderTasks(taskSelectHandler, taskRowActionHandler);
return loadTaskDetail(taskId);
}
async function taskRowActionHandler(action, taskId) {
if (action !== "run") return;
try {
const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" });
await loadOverview();
showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`任务执行失败: ${err}`, "err");
}
}
function handleSettingsFieldChange(event) {
const input = event.target;
const group = input.dataset.group;
const field = input.dataset.field;
const fieldSchema = state.currentSettingsSchema.groups[group][field];
const key = settingsFieldKey(group, field);
let value;
if (fieldSchema.type === "boolean") value = input.checked;
else if (fieldSchema.type === "integer") {
value = Number(input.value);
if (input.value === "" || Number.isNaN(value)) {
state.currentSettings[group] ??= {};
state.currentSettings[group][field] = input.value;
markSettingsFieldDirty(key, true);
setSettingsFieldError(key, "必须填写整数");
renderSettingsForm(handleSettingsFieldChange);
return;
}
}
else if (fieldSchema.type === "array") {
try {
value = JSON.parse(input.value || "[]");
if (!Array.isArray(value)) throw new Error("not array");
} catch {
markSettingsFieldDirty(key, true);
setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`);
renderSettingsForm(handleSettingsFieldChange);
return;
}
} else value = input.value;
if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) {
markSettingsFieldDirty(key, true);
setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`);
renderSettingsForm(handleSettingsFieldChange);
return;
}
markSettingsFieldDirty(key, true);
setSettingsFieldError(key, "");
if (!state.currentSettings[group]) state.currentSettings[group] = {};
state.currentSettings[group][field] = value;
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
renderSettingsForm(handleSettingsFieldChange);
}
async function loadOverview() {
setTaskListLoading(true);
setLogListLoading(true);
renderTasks(taskSelectHandler, taskRowActionHandler);
const payload = await loadOverviewPayload();
setOverviewData({
tasks: payload.tasks.items,
settings: payload.settings,
settingsSchema: payload.settingsSchema,
});
clearSettingsFieldState();
setTaskListLoading(false);
renderShellStats(payload);
renderSettingsForm(handleSettingsFieldChange);
renderTasks(taskSelectHandler, taskRowActionHandler);
renderModules(payload.modules.items);
renderDoctor(payload.doctor.checks);
renderSchedulerQueue(payload.scheduler);
renderServices(payload.services.items, async (serviceId, action) => {
if (["stop", "restart"].includes(action)) {
const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`);
if (!ok) return;
}
try {
const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
await loadOverview();
showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn");
} catch (err) {
showBanner(`service 操作失败: ${err}`, "err");
}
});
setLogs(payload.logs.items);
setLogListLoading(false);
if (!state.selectedLogName && payload.logs.items.length) {
setSelectedLog(payload.logs.items[0].name);
}
renderLogsList(payload.logs.items, refreshLog, selectLog);
renderRecentActions(payload.history.items);
const route = currentRoute();
const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId);
if (route.view === "tasks" && routeTaskExists) {
setSelectedTask(route.taskId);
} else if (!state.selectedTaskId && state.currentTasks.length) {
setSelectedTask(state.currentTasks[0].id);
}
if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId);
else {
setTaskDetailStatus("idle");
renderTaskWorkspaceState("idle");
}
}
async function handleRouteChange(route) {
if (route.view !== "tasks") return;
if (!route.taskId) {
if (state.selectedTaskId) navigate("tasks", state.selectedTaskId);
return;
}
if (!state.currentTasks.length) return;
if (!state.currentTasks.some((item) => item.id === route.taskId)) return;
if (state.selectedTaskId !== route.taskId) {
setSelectedTask(route.taskId);
setSelectedStep(null);
renderTasks(taskSelectHandler, taskRowActionHandler);
await loadTaskDetail(route.taskId);
}
}
bindActions({
loadOverview,
loadTaskDetail,
refreshLog,
handleSettingsFieldChange,
});
initRouter((route) => {
handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err"));
});
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));

View File

@ -0,0 +1,18 @@
import { setView } from "./state.js";
export function renderView(view) {
setView(view);
document.querySelectorAll(".nav-btn").forEach((button) => {
button.classList.toggle("active", button.dataset.view === view);
});
document.querySelectorAll(".view").forEach((section) => {
section.classList.toggle("active", section.dataset.view === view);
});
const titleMap = {
overview: "Overview",
tasks: "Tasks",
settings: "Settings",
logs: "Logs",
};
document.getElementById("viewTitle").textContent = titleMap[view] || "Control";
}

View File

@ -0,0 +1,22 @@
import { renderView } from "./render.js";
export function currentRoute() {
const raw = window.location.hash.replace(/^#/, "") || "overview";
const [view = "overview", ...rest] = raw.split("/");
const taskId = rest.length ? decodeURIComponent(rest.join("/")) : null;
return { view: view || "overview", taskId };
}
export function navigate(view, taskId = null) {
window.location.hash = taskId ? `${view}/${encodeURIComponent(taskId)}` : view;
}
export function initRouter(onRouteChange) {
const sync = () => {
const route = currentRoute();
renderView(route.view);
if (onRouteChange) onRouteChange(route);
};
window.addEventListener("hashchange", sync);
sync();
}

View File

@ -0,0 +1,91 @@
export const state = {
selectedTaskId: null,
selectedStepName: null,
currentTasks: [],
currentSettings: {},
originalSettings: {},
currentSettingsSchema: null,
settingsDirtyFields: {},
settingsFieldErrors: {},
currentView: "overview",
taskPage: 1,
taskPageSize: 24,
taskListLoading: true,
taskDetailStatus: "idle",
taskDetailError: "",
currentLogs: [],
selectedLogName: null,
logListLoading: true,
logAutoRefreshTimer: null,
};
export function setView(view) {
state.currentView = view;
}
export function setSelectedTask(taskId) {
state.selectedTaskId = taskId;
}
export function setSelectedStep(stepName) {
state.selectedStepName = stepName;
}
export function setOverviewData({ tasks, settings, settingsSchema }) {
state.currentTasks = tasks;
state.currentSettings = settings;
state.originalSettings = JSON.parse(JSON.stringify(settings || {}));
state.currentSettingsSchema = settingsSchema;
}
export function markSettingsFieldDirty(key, dirty = true) {
if (dirty) state.settingsDirtyFields[key] = true;
else delete state.settingsDirtyFields[key];
}
export function setSettingsFieldError(key, message = "") {
if (message) state.settingsFieldErrors[key] = message;
else delete state.settingsFieldErrors[key];
}
export function clearSettingsFieldState() {
state.settingsDirtyFields = {};
state.settingsFieldErrors = {};
}
export function setTaskPage(page) {
state.taskPage = page;
}
export function setTaskPageSize(size) {
state.taskPageSize = size;
}
export function resetTaskPage() {
state.taskPage = 1;
}
export function setTaskListLoading(loading) {
state.taskListLoading = loading;
}
export function setTaskDetailStatus(status, error = "") {
state.taskDetailStatus = status;
state.taskDetailError = error;
}
export function setLogs(logs) {
state.currentLogs = logs;
}
export function setSelectedLog(name) {
state.selectedLogName = name;
}
export function setLogListLoading(loading) {
state.logListLoading = loading;
}
export function setLogAutoRefreshTimer(timerId) {
state.logAutoRefreshTimer = timerId;
}

View File

@ -0,0 +1,61 @@
import { state } from "./state.js";
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 (["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";
return "";
}
export function showBanner(message, kind) {
const el = document.getElementById("banner");
el.textContent = message;
el.className = `banner show ${kind}`;
}
export function escapeHtml(text) {
return String(text)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
export function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
export function formatDuration(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) return "-";
const total = Math.max(0, Number(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function syncSettingsEditorFromState() {
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
}
export function getGroupOrder(groupName) {
return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999);
}
export function compareFieldEntries(a, b) {
const orderA = Number(a[1].ui_order || 9999);
const orderB = Number(b[1].ui_order || 9999);
if (orderA !== orderB) return orderA - orderB;
return String(a[0]).localeCompare(String(b[0]));
}
export function settingsFieldKey(group, field) {
return `${group}.${field}`;
}

View File

@ -0,0 +1,52 @@
import { state } from "../state.js";
import { escapeHtml, formatDate, showBanner } from "../utils.js";
export function filteredLogs() {
const search = (document.getElementById("logSearchInput")?.value || "").trim().toLowerCase();
return state.currentLogs.filter((item) => !search || item.name.toLowerCase().includes(search));
}
export function renderLogsList(items, onRefreshLog, onSelectLog) {
const wrap = document.getElementById("logList");
const stateEl = document.getElementById("logListState");
wrap.innerHTML = "";
const visible = filteredLogs();
if (state.logListLoading) {
stateEl.textContent = "正在加载日志索引…";
stateEl.classList.add("show");
return;
}
if (!visible.length) {
stateEl.textContent = "没有匹配日志文件。";
stateEl.classList.add("show");
return;
}
stateEl.classList.remove("show");
visible.forEach((item) => {
const row = document.createElement("div");
row.className = `task-card log-card ${state.selectedLogName === item.name ? "active" : ""}`;
row.innerHTML = `
<div class="task-title">${escapeHtml(item.name)}</div>
<div class="muted-note">${escapeHtml(item.path || "")}</div>
`;
row.onclick = () => onSelectLog(item.name);
wrap.appendChild(row);
});
if (!state.selectedLogName && visible[0]) onSelectLog(visible[0].name);
}
export function renderLogContent(payload) {
document.getElementById("logPath").textContent = payload.path || "-";
document.getElementById("logMeta").textContent = `updated ${formatDate(new Date().toISOString())}`;
const filter = (document.getElementById("logLineFilter")?.value || "").trim().toLowerCase();
const content = payload.content || "";
if (!filter) {
document.getElementById("logContent").textContent = content;
return;
}
const filtered = content
.split("\n")
.filter((line) => line.toLowerCase().includes(filter))
.join("\n");
document.getElementById("logContent").textContent = filtered;
}

View File

@ -0,0 +1,98 @@
import { renderDoctor } from "../components/doctor-check-list.js";
import { renderModules } from "../components/modules-list.js";
import { renderRecentActions } from "../components/recent-actions-list.js";
import { renderRuntimeSnapshot } from "../components/overview-runtime.js";
import {
renderOverviewRetrySummary,
renderOverviewTaskSummary,
} from "../components/overview-task-summary.js";
import { renderServices } from "../components/service-list.js";
import { escapeHtml } from "../utils.js";
export function renderShellStats({ health, doctor, tasks }) {
renderRuntimeSnapshot({ health, doctor, tasks });
renderOverviewTaskSummary(tasks.items);
renderOverviewRetrySummary(tasks.items);
}
export function renderSchedulerQueue(scheduler) {
const summary = document.getElementById("schedulerSummary");
const list = document.getElementById("schedulerList");
const stageScan = document.getElementById("stageScanSummary");
if (!summary || !list || !stageScan) return;
summary.innerHTML = "";
list.innerHTML = "";
stageScan.innerHTML = "";
const scheduledCount = scheduler?.scheduled?.length || 0;
const deferredCount = scheduler?.deferred?.length || 0;
const summaryData = scheduler?.summary || {};
const strategy = scheduler?.strategy || {};
[
["scheduled", scheduledCount, scheduledCount ? "warn" : ""],
["deferred", deferredCount, deferredCount ? "hot" : ""],
["scanned", summaryData.scanned_count || 0, ""],
["truncated", summaryData.truncated_count || 0, (summaryData.truncated_count || 0) ? "warn" : ""],
].forEach(([label, value, klass]) => {
const pill = document.createElement("div");
pill.className = `pill ${klass}`.trim();
pill.textContent = `${label} ${value}`;
summary.appendChild(pill);
});
const strategyRow = document.createElement("div");
strategyRow.className = "row-card";
strategyRow.innerHTML = `
<strong>Scheduler Strategy</strong>
<div class="muted-note">max_tasks_per_cycle=${escapeHtml(String(strategy.max_tasks_per_cycle ?? "-"))}, candidate_scan_limit=${escapeHtml(String(strategy.candidate_scan_limit ?? "-"))}</div>
<div class="muted-note">prioritize_retry_due=${escapeHtml(String(strategy.prioritize_retry_due ?? "-"))}, oldest_first=${escapeHtml(String(strategy.oldest_first ?? "-"))}</div>
<div class="muted-note">status_priority=${escapeHtml((strategy.status_priority || []).join(" > ") || "-")}</div>
`;
list.appendChild(strategyRow);
const skipped = summaryData.skipped_counts || {};
const skippedRow = document.createElement("div");
skippedRow.className = "row-card";
skippedRow.innerHTML = `
<strong>Unscheduled Reasons</strong>
<div class="muted-note">failed_manual=${escapeHtml(String(skipped.failed_manual || 0))}</div>
<div class="muted-note">no_runnable_step=${escapeHtml(String(skipped.no_runnable_step || 0))}</div>
`;
list.appendChild(skippedRow);
const items = [...(scheduler?.scheduled || []).map((item) => ({ ...item, queue: "scheduled" })), ...(scheduler?.deferred || []).map((item) => ({ ...item, queue: "deferred" }))];
if (!items.length) {
const empty = document.createElement("div");
empty.className = "row-card";
empty.innerHTML = `<strong>当前无排队任务</strong><div class="muted-note">scheduler 本轮没有挑出需要执行或等待重试的任务。</div>`;
list.appendChild(empty);
} else {
items.slice(0, 12).forEach((item) => {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(item.task_id)}</strong>
<span class="pill ${item.queue === "deferred" ? "hot" : "warn"}">${escapeHtml(item.queue)}</span>
${item.step_name ? `<span class="pill">${escapeHtml(item.step_name)}</span>` : ""}
${item.task_status ? `<span class="pill">${escapeHtml(item.task_status)}</span>` : ""}
</div>
<div class="muted-note">${escapeHtml(item.reason || (item.waiting_for_retry ? "waiting_for_retry" : "-"))}</div>
${item.remaining_seconds != null ? `<div class="muted-note">remaining ${escapeHtml(String(item.remaining_seconds))}s</div>` : ""}
`;
list.appendChild(row);
});
}
const scan = scheduler?.stage_scan || { accepted: [], rejected: [], skipped: [] };
[
["accepted", scan.accepted?.length || 0],
["rejected", scan.rejected?.length || 0],
["skipped", scan.skipped?.length || 0],
].forEach(([label, value]) => {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `<strong>${escapeHtml(label)}</strong><div class="muted-note">${escapeHtml(String(value))}</div>`;
stageScan.appendChild(row);
});
}

View File

@ -0,0 +1,162 @@
import { state } from "../state.js";
import {
compareFieldEntries,
escapeHtml,
getGroupOrder,
settingsFieldKey,
syncSettingsEditorFromState,
} from "../utils.js";
export function renderSettingsForm(onFieldChange) {
const wrap = document.getElementById("settingsForm");
wrap.innerHTML = "";
if (!state.currentSettingsSchema?.groups) return;
const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase();
const featuredContainer = document.createElement("div");
featuredContainer.className = "settings-groups";
const advancedDetails = document.createElement("details");
advancedDetails.className = "settings-advanced";
advancedDetails.innerHTML = "<summary>Advanced Settings</summary>";
const advancedContainer = document.createElement("div");
advancedContainer.className = "settings-groups";
const createSettingsField = (groupName, fieldName, fieldSchema) => {
const key = settingsFieldKey(groupName, fieldName);
const row = document.createElement("div");
row.className = "settings-field";
if (state.settingsDirtyFields[key]) row.classList.add("dirty");
if (state.settingsFieldErrors[key]) row.classList.add("error");
const label = document.createElement("label");
label.className = "settings-label";
label.textContent = fieldSchema.title || `${groupName}.${fieldName}`;
if (fieldSchema.ui_widget) {
const badge = document.createElement("span");
badge.className = "settings-badge";
badge.textContent = fieldSchema.ui_widget;
label.appendChild(badge);
}
if (fieldSchema.ui_featured === true) {
const badge = document.createElement("span");
badge.className = "settings-badge";
badge.textContent = "featured";
label.appendChild(badge);
}
row.appendChild(label);
const value = state.currentSettings[groupName]?.[fieldName];
let input;
if (fieldSchema.type === "boolean") {
input = document.createElement("input");
input.type = "checkbox";
input.checked = Boolean(value);
} else if (Array.isArray(fieldSchema.enum)) {
input = document.createElement("select");
fieldSchema.enum.forEach((optionValue) => {
const option = document.createElement("option");
option.value = String(optionValue);
option.textContent = String(optionValue);
if (value === optionValue) option.selected = true;
input.appendChild(option);
});
} else if (fieldSchema.type === "array") {
input = document.createElement("textarea");
input.style.minHeight = "96px";
input.value = JSON.stringify(value ?? [], null, 2);
} else {
input = document.createElement("input");
input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text");
input.value = value ?? "";
if (fieldSchema.type === "integer") {
if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum);
input.step = "1";
}
if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder;
}
input.dataset.group = groupName;
input.dataset.field = fieldName;
input.onchange = onFieldChange;
row.appendChild(input);
const originalValue = state.originalSettings[groupName]?.[fieldName];
const currentValue = state.currentSettings[groupName]?.[fieldName];
const changed = JSON.stringify(originalValue ?? null) !== JSON.stringify(currentValue ?? null);
if (changed) {
const controls = document.createElement("div");
controls.className = "button-row";
const revert = document.createElement("button");
revert.className = "secondary compact";
revert.type = "button";
revert.textContent = "撤销本字段";
revert.dataset.revertGroup = groupName;
revert.dataset.revertField = fieldName;
controls.appendChild(revert);
row.appendChild(controls);
}
if (fieldSchema.description || fieldSchema.sensitive) {
const hint = document.createElement("div");
hint.className = "hint";
let text = fieldSchema.description || "";
if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`;
hint.textContent = text;
row.appendChild(hint);
}
if (state.settingsFieldErrors[key]) {
const error = document.createElement("div");
error.className = "field-error";
error.textContent = state.settingsFieldErrors[key];
row.appendChild(error);
}
return row;
};
const createSettingsGroup = (groupName, fields, featured) => {
const entries = Object.entries(fields);
if (!entries.length) return null;
const group = document.createElement("div");
group.className = `settings-group ${featured ? "featured" : ""}`.trim();
group.innerHTML = `<h3>${escapeHtml(state.currentSettingsSchema.group_ui?.[groupName]?.title || groupName)}</h3>`;
const descText = state.currentSettingsSchema.group_ui?.[groupName]?.description;
if (descText) {
const desc = document.createElement("div");
desc.className = "group-desc";
desc.textContent = descText;
group.appendChild(desc);
}
const fieldWrap = document.createElement("div");
fieldWrap.className = "settings-fields";
entries.forEach(([fieldName, fieldSchema]) => fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema)));
group.appendChild(fieldWrap);
return group;
};
Object.entries(state.currentSettingsSchema.groups)
.sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0]))
.forEach(([groupName, fields]) => {
const featuredFields = {};
const advancedFields = {};
Object.entries(fields).sort((a, b) => compareFieldEntries(a, b)).forEach(([fieldName, fieldSchema]) => {
const key = `${groupName}.${fieldName}`.toLowerCase();
if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) return;
if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema;
else advancedFields[fieldName] = fieldSchema;
});
const featuredGroup = createSettingsGroup(groupName, featuredFields, true);
const advancedGroup = createSettingsGroup(groupName, advancedFields, false);
if (featuredGroup) featuredContainer.appendChild(featuredGroup);
if (advancedGroup) advancedContainer.appendChild(advancedGroup);
});
if (!featuredContainer.children.length && !advancedContainer.children.length) {
wrap.innerHTML = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
return;
}
if (featuredContainer.children.length) wrap.appendChild(featuredContainer);
if (advancedContainer.children.length) {
advancedDetails.appendChild(advancedContainer);
wrap.appendChild(advancedDetails);
}
syncSettingsEditorFromState();
}

View File

@ -0,0 +1,462 @@
import { state, setTaskPage } from "../state.js";
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
import { renderArtifactList } from "../components/artifact-list.js";
import { renderHistoryList } from "../components/history-list.js";
import { renderRetryPanel } from "../components/retry-banner.js";
import { renderStepList } from "../components/step-list.js";
import { renderTaskHero } from "../components/task-hero.js";
import { renderTimelineList } from "../components/timeline-list.js";
const STATUS_LABELS = {
created: "待转录",
transcribed: "待识歌",
songs_detected: "待切歌",
split_done: "待上传",
published: "待收尾",
collection_synced: "已完成",
failed_retryable: "待重试",
failed_manual: "待人工",
running: "处理中",
};
const DELIVERY_LABELS = {
done: "已发送",
pending: "待处理",
legacy_untracked: "历史未追踪",
resolved: "已定位",
unresolved: "未定位",
present: "保留",
removed: "已清理",
};
function displayStatus(status) {
return STATUS_LABELS[status] || status || "-";
}
function displayDelivery(status) {
return DELIVERY_LABELS[status] || status || "-";
}
function cleanupState(deliveryState = {}) {
return deliveryState.source_video_present === false || deliveryState.split_videos_present === false
? "removed"
: "present";
}
function attentionState(task) {
if (task.status === "failed_manual") return "manual_now";
if (task.retry_state?.retry_due) return "retry_now";
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
if (task.status === "running") return "in_progress";
return "stable";
}
function displayAttention(status) {
return {
manual_now: "需人工",
retry_now: "立即重试",
waiting_retry: "等待重试",
in_progress: "处理中",
stable: "正常",
}[status] || status;
}
function attentionClass(status) {
if (status === "manual_now") return "hot";
if (["retry_now", "waiting_retry", "in_progress"].includes(status)) return "warn";
return "good";
}
function compareText(a, b) {
return String(a || "").localeCompare(String(b || ""), "zh-CN");
}
function compareBySort(sort, a, b) {
const deliveryA = a.delivery_state || {};
const deliveryB = b.delivery_state || {};
if (sort === "updated_asc") return compareText(a.updated_at, b.updated_at);
if (sort === "title_asc") return compareText(a.title, b.title);
if (sort === "title_desc") return compareText(b.title, a.title);
if (sort === "next_retry_asc") {
const diff = compareText(a.retry_state?.next_retry_at || "9999", b.retry_state?.next_retry_at || "9999");
return diff || compareText(b.updated_at, a.updated_at);
}
if (sort === "attention_state") {
const diff = compareText(attentionState(a), attentionState(b));
return diff || compareText(b.updated_at, a.updated_at);
}
if (sort === "split_comment_status") {
const diff = compareText(deliveryA.split_comment, deliveryB.split_comment);
return diff || compareText(b.updated_at, a.updated_at);
}
if (sort === "full_comment_status") {
const diff = compareText(deliveryA.full_video_timeline_comment, deliveryB.full_video_timeline_comment);
return diff || compareText(b.updated_at, a.updated_at);
}
if (sort === "cleanup_state") {
const diff = compareText(cleanupState(deliveryA), cleanupState(deliveryB));
return diff || compareText(b.updated_at, a.updated_at);
}
return compareText(b.updated_at, a.updated_at);
}
function headerSortValue(field, currentSort) {
const fieldMap = {
title: ["title_asc", "title_desc"],
status: ["status_group", "updated_desc"],
attention: ["attention_state", "updated_desc"],
split_comment: ["split_comment_status", "updated_desc"],
full_comment: ["full_comment_status", "updated_desc"],
cleanup: ["cleanup_state", "updated_desc"],
next_retry: ["next_retry_asc", "updated_desc"],
updated: ["updated_desc", "updated_asc"],
};
const [primary, secondary] = fieldMap[field] || ["updated_desc", "updated_asc"];
return currentSort === primary ? secondary : primary;
}
function headerLabel(text, field, currentSort) {
const activeSorts = {
title: ["title_asc", "title_desc"],
status: ["status_group"],
attention: ["attention_state"],
split_comment: ["split_comment_status"],
full_comment: ["full_comment_status"],
cleanup: ["cleanup_state"],
next_retry: ["next_retry_asc"],
updated: ["updated_desc", "updated_asc"],
};
const active = activeSorts[field]?.includes(currentSort) ? " active" : "";
const direction = currentSort === "updated_desc" && field === "updated"
? "↓"
: currentSort === "updated_asc" && field === "updated"
? "↑"
: currentSort === "title_asc" && field === "title"
? "↑"
: currentSort === "title_desc" && field === "title"
? "↓"
: currentSort === "status_group" && field === "status"
? "•"
: currentSort === "attention_state" && field === "attention"
? "•"
: currentSort === "split_comment_status" && field === "split_comment"
? "•"
: currentSort === "full_comment_status" && field === "full_comment"
? "•"
: currentSort === "cleanup_state" && field === "cleanup"
? "•"
: currentSort === "next_retry_asc" && field === "next_retry"
? "↑"
: "";
return `<button class="table-sort-btn${active}" data-sort-field="${field}">${escapeHtml(text)}${direction ? `<span>${direction}</span>` : ""}</button>`;
}
export function filteredTasks() {
const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase();
const status = document.getElementById("taskStatusFilter")?.value || "";
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
const delivery = document.getElementById("taskDeliveryFilter")?.value || "";
const attention = document.getElementById("taskAttentionFilter")?.value || "";
let items = state.currentTasks.filter((task) => {
const haystack = `${task.id} ${task.title}`.toLowerCase();
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;
return true;
});
const statusRank = {
failed_manual: 0,
failed_retryable: 1,
running: 2,
created: 3,
transcribed: 4,
songs_detected: 5,
split_done: 6,
published: 7,
collection_synced: 8,
};
items = [...items].sort((a, b) => {
if (sort === "status_group") {
const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99);
if (diff !== 0) return diff;
return compareText(b.updated_at, a.updated_at);
}
return compareBySort(sort, a, b);
});
return items;
}
export function pagedTasks(items = filteredTasks()) {
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / state.taskPageSize));
const safePage = Math.min(Math.max(1, state.taskPage), totalPages);
if (safePage !== state.taskPage) setTaskPage(safePage);
const start = (safePage - 1) * state.taskPageSize;
const end = start + state.taskPageSize;
return {
items: items.slice(start, end),
total,
totalPages,
page: safePage,
pageSize: state.taskPageSize,
start: total ? start + 1 : 0,
end: Math.min(end, total),
};
}
export function renderTaskStatusSummary(items = filteredTasks()) {
const wrap = document.getElementById("taskStatusSummary");
if (!wrap) return;
const counts = new Map();
items.forEach((item) => counts.set(item.status, (counts.get(item.status) || 0) + 1));
const orderedStatuses = ["running", "failed_retryable", "failed_manual", "created", "transcribed", "songs_detected", "split_done", "published", "collection_synced"];
wrap.innerHTML = "";
const totalPill = document.createElement("div");
totalPill.className = "pill";
totalPill.textContent = `filtered ${items.length}`;
wrap.appendChild(totalPill);
orderedStatuses.forEach((status) => {
const count = counts.get(status);
if (!count) return;
const pill = document.createElement("div");
pill.className = `pill ${statusClass(status)}`;
pill.textContent = `${status} ${count}`;
wrap.appendChild(pill);
});
}
export function renderTaskPagination(items = filteredTasks()) {
const meta = pagedTasks(items);
const summary = document.getElementById("taskPaginationSummary");
const prevBtn = document.getElementById("taskPrevPageBtn");
const nextBtn = document.getElementById("taskNextPageBtn");
const sizeSelect = document.getElementById("taskPageSizeSelect");
if (summary) {
summary.textContent = meta.total
? `showing ${meta.start}-${meta.end} of ${meta.total} · page ${meta.page}/${meta.totalPages}`
: "没有可显示的任务";
}
if (prevBtn) prevBtn.disabled = meta.page <= 1;
if (nextBtn) nextBtn.disabled = meta.page >= meta.totalPages;
if (sizeSelect) sizeSelect.value = String(state.taskPageSize);
return meta;
}
export function renderTaskListState(items = filteredTasks()) {
const stateEl = document.getElementById("taskListState");
if (!stateEl) return;
if (state.taskListLoading) {
stateEl.textContent = "正在加载任务列表…";
stateEl.classList.add("show");
return;
}
if (!items.length) {
stateEl.textContent = "没有匹配任务,调整筛选条件后重试。";
stateEl.classList.add("show");
return;
}
stateEl.classList.remove("show");
}
export function renderTasks(onSelect, onRowAction = null) {
const wrap = document.getElementById("taskList");
wrap.innerHTML = "";
const items = filteredTasks();
renderTaskStatusSummary(items);
renderTaskListState(items);
const meta = renderTaskPagination(items);
if (state.taskListLoading) {
wrap.innerHTML = `<div class="task-table-loading">正在加载任务表…</div>`;
return;
}
if (!meta.items.length) {
return;
}
const table = document.createElement("table");
table.className = "task-table";
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
table.innerHTML = `
<thead>
<tr>
<th>${headerLabel("任务", "title", sort)}</th>
<th>${headerLabel("状态", "status", sort)}</th>
<th>${headerLabel("关注", "attention", sort)}</th>
<th>${headerLabel("纯享评论", "split_comment", sort)}</th>
<th>${headerLabel("主视频评论", "full_comment", sort)}</th>
<th>${headerLabel("清理", "cleanup", sort)}</th>
<th>${headerLabel("下次重试", "next_retry", sort)}</th>
<th>${headerLabel("更新时间", "updated", sort)}</th>
<th>快捷操作</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector("tbody");
for (const item of meta.items) {
const delivery = item.delivery_state || {};
const attention = attentionState(item);
const row = document.createElement("tr");
row.className = item.id === state.selectedTaskId ? "active" : "";
row.innerHTML = `
<td>
<div class="task-cell-title">${escapeHtml(item.title)}</div>
<div class="task-cell-subtitle">${escapeHtml(item.id)}</div>
</td>
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayStatus(item.status))}</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>
<td><span class="pill ${statusClass(cleanupState(delivery))}">${escapeHtml(displayDelivery(cleanupState(delivery)))}</span></td>
<td>
${item.retry_state?.next_retry_at ? `<div>${escapeHtml(formatDate(item.retry_state.next_retry_at))}</div>` : `<span class="muted-note">-</span>`}
${item.retry_state?.retry_remaining_seconds != null ? `<div class="muted-note">${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>` : ""}
</td>
<td>
<div>${escapeHtml(formatDate(item.updated_at))}</div>
${item.retry_state?.next_retry_at ? `<div class="muted-note">retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))}</div>` : ""}
</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>
</td>
`;
row.onclick = () => onSelect(item.id);
row.querySelectorAll("[data-task-action]").forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
if (button.dataset.taskAction === "open") return onSelect(item.id);
if (button.dataset.taskAction === "run" && onRowAction) return onRowAction("run", item.id);
};
});
tbody.appendChild(row);
}
table.querySelectorAll("[data-sort-field]").forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
const select = document.getElementById("taskSortSelect");
if (!select) return;
select.value = headerSortValue(button.dataset.sortField, select.value);
select.dispatchEvent(new Event("change"));
};
});
wrap.appendChild(table);
}
export function renderTaskDetail(payload, onStepSelect) {
const { task, steps, artifacts, history, timeline } = payload;
renderTaskHero(task, steps);
renderRetryPanel(task);
const detail = document.getElementById("taskDetail");
detail.innerHTML = "";
[
["Task ID", task.id],
["Status", task.status],
["Created", formatDate(task.created_at)],
["Updated", formatDate(task.updated_at)],
["Source", task.source_path],
["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"],
].forEach(([key, value]) => {
const k = document.createElement("div");
k.className = "detail-key";
k.textContent = key;
const v = document.createElement("div");
v.textContent = value || "-";
detail.appendChild(k);
detail.appendChild(v);
});
let summaryText = "暂无最近结果";
const latestAction = history.items[0];
if (latestAction) {
summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`;
} else {
const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"];
const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status));
const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0];
if (summaryStep) {
summaryText = summaryStep.error_message
? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}`
: `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`;
}
}
const delivery = task.delivery_state || {};
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;">Delivery State</div>
<div class="delivery-grid">
${renderDeliveryState("Split Comment", delivery.split_comment || "-")}
${renderDeliveryState("Full Timeline", delivery.full_video_timeline_comment || "-")}
${renderDeliveryState("Full Video BV", delivery.full_video_bvid_resolved ? "resolved" : "unresolved")}
${renderDeliveryState("Source Video", delivery.source_video_present ? "present" : "removed")}
${renderDeliveryState("Split Videos", delivery.split_videos_present ? "present" : "removed")}
${renderDeliveryState(
"Cleanup Policy",
`source=${delivery.cleanup_enabled?.delete_source_video_after_collection_synced ? "on" : "off"} / split=${delivery.cleanup_enabled?.delete_split_videos_after_collection_synced ? "on" : "off"}`,
""
)}
</div>
`;
renderStepList(steps, onStepSelect);
renderArtifactList(artifacts);
renderHistoryList(history);
renderTimelineList(timeline);
}
function renderDeliveryState(label, value, forcedClass = null) {
const klass = forcedClass === null ? statusClass(value) : forcedClass;
return `
<div class="delivery-card">
<div class="delivery-label">${escapeHtml(label)}</div>
<div class="delivery-value"><span class="pill ${klass}">${escapeHtml(String(value))}</span></div>
</div>
`;
}
export function renderTaskWorkspaceState(mode, message = "") {
const stateEl = document.getElementById("taskWorkspaceState");
const hero = document.getElementById("taskHero");
const retry = document.getElementById("taskRetryPanel");
const detail = document.getElementById("taskDetail");
const summary = document.getElementById("taskSummary");
const stepList = document.getElementById("stepList");
const artifactList = document.getElementById("artifactList");
const historyList = document.getElementById("historyList");
const timelineList = document.getElementById("timelineList");
if (!stateEl) return;
stateEl.className = "task-workspace-state show";
if (mode === "loading") stateEl.classList.add("loading");
if (mode === "error") stateEl.classList.add("error");
stateEl.textContent =
message ||
(mode === "loading"
? "正在加载任务详情…"
: mode === "error"
? "任务详情加载失败。"
: "选择一个任务后,这里会显示当前链路、重试状态和最近动作。");
if (mode === "ready") {
stateEl.className = "task-workspace-state";
return;
}
hero.className = "task-hero empty";
hero.textContent = stateEl.textContent;
retry.className = "retry-banner";
retry.style.display = "none";
retry.textContent = "";
detail.innerHTML = "";
summary.textContent = mode === "error" ? stateEl.textContent : "暂无最近结果";
stepList.innerHTML = "";
artifactList.innerHTML = "";
historyList.innerHTML = "";
timelineList.innerHTML = "";
}

View File

@ -0,0 +1,815 @@
:root {
--bg: #f3efe8;
--paper: rgba(255, 252, 247, 0.92);
--paper-strong: rgba(255, 255, 255, 0.98);
--ink: #1d1a16;
--muted: #6b6159;
--line: rgba(29, 26, 22, 0.12);
--line-strong: rgba(29, 26, 22, 0.2);
--accent: #b24b1a;
--accent-2: #0e6c62;
--warn: #9a690f;
--good-bg: rgba(14, 108, 98, 0.12);
--warn-bg: rgba(154, 105, 15, 0.12);
--hot-bg: rgba(178, 75, 26, 0.12);
--shadow: 0 24px 70px rgba(57, 37, 16, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
background:
radial-gradient(circle at top left, rgba(178, 75, 26, 0.14), transparent 30%),
radial-gradient(circle at top right, rgba(14, 108, 98, 0.14), transparent 28%),
linear-gradient(180deg, #f7f2ea 0%, #efe7dc 100%);
}
button, input, select, textarea { font: inherit; }
.app-shell {
width: min(1680px, calc(100vw - 28px));
margin: 18px auto 32px;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 18px;
}
.sidebar,
.panel,
.topbar {
border: 1px solid var(--line);
border-radius: 26px;
background: var(--paper);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.sidebar {
padding: 22px;
position: sticky;
top: 18px;
align-self: start;
}
.sidebar-brand h1 {
margin: 0;
font-size: 42px;
line-height: 0.92;
letter-spacing: -0.05em;
}
.eyebrow {
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.sidebar-copy {
margin: 12px 0 0;
color: var(--muted);
line-height: 1.55;
}
.sidebar-nav,
.button-stack {
display: grid;
gap: 10px;
}
.sidebar-section {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.sidebar-label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 13px;
}
.sidebar-token {
display: grid;
gap: 10px;
}
.nav-btn,
button {
border: 0;
border-radius: 16px;
padding: 11px 14px;
cursor: pointer;
background: var(--ink);
color: #fff;
text-align: left;
}
.nav-btn {
background: rgba(255,255,255,0.84);
color: var(--ink);
border: 1px solid var(--line);
font-weight: 600;
}
.nav-btn.active {
background: linear-gradient(135deg, rgba(178, 75, 26, 0.12), rgba(255,255,255,0.95));
border-color: rgba(178, 75, 26, 0.28);
color: var(--accent);
}
button.secondary {
background: rgba(255,255,255,0.82);
color: var(--ink);
border: 1px solid var(--line);
}
button.compact {
padding: 8px 12px;
font-size: 13px;
}
.content {
display: grid;
gap: 16px;
}
.topbar {
padding: 18px 22px;
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
}
.topbar h2 {
margin: 0;
font-size: clamp(24px, 3vw, 38px);
letter-spacing: -0.04em;
}
.topbar-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.status-chip,
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 6px 10px;
background: rgba(29, 26, 22, 0.07);
font-size: 12px;
}
.banner,
.retry-banner {
padding: 14px 16px;
border-radius: 18px;
display: none;
}
.banner.show,
.retry-banner.show { display: block; }
.banner.ok { background: var(--good-bg); color: var(--accent-2); }
.banner.warn,
.retry-banner.warn { background: var(--warn-bg); color: var(--warn); }
.banner.err,
.retry-banner.hot { background: var(--hot-bg); color: var(--accent); }
.retry-banner.good { background: var(--good-bg); color: var(--accent-2); }
.view {
display: none;
gap: 16px;
}
.view.active { display: grid; }
.panel {
padding: 20px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 14px;
}
.panel-head h3 {
margin: 0;
font-size: 18px;
letter-spacing: -0.02em;
}
.panel-grid {
display: grid;
gap: 16px;
}
.panel-grid.two-up {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.stat-card,
.row-card,
.task-card,
.service-card,
.summary-card,
.timeline-card {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.74);
}
.stat-card,
.row-card,
.task-card,
.service-card,
.summary-card,
.timeline-card {
padding: 14px;
}
.summary-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.summary-text {
margin-top: 8px;
line-height: 1.6;
}
.delivery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.delivery-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px 12px;
background: rgba(255,255,255,0.72);
}
.delivery-label {
color: var(--muted);
font-size: 12px;
margin-bottom: 8px;
}
.delivery-value {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.stat-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stat-value {
display: block;
margin-top: 8px;
font-size: 30px;
}
.filter-grid,
.field-grid,
.task-filters {
display: grid;
gap: 10px;
}
.filter-grid,
.field-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.task-filters {
grid-template-columns: 1.2fr .8fr .8fr;
margin-bottom: 14px;
}
.task-index-summary {
display: grid;
gap: 12px;
margin-bottom: 14px;
}
.summary-strip {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.task-pagination-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.78);
}
.task-list-state {
display: none;
margin-bottom: 12px;
padding: 12px 14px;
border: 1px dashed var(--line-strong);
border-radius: 16px;
background: rgba(255,255,255,0.6);
color: var(--muted);
}
.task-list-state.show {
display: block;
}
.upload-grid { margin-top: 10px; }
input,
select,
textarea,
pre {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px 14px;
background: rgba(255,255,255,0.85);
color: var(--ink);
}
textarea,
pre {
font: 13px/1.55 "IBM Plex Mono", "SFMono-Regular", monospace;
white-space: pre-wrap;
word-break: break-word;
}
textarea { min-height: 320px; resize: vertical; }
pre { margin: 0; min-height: 240px; overflow: auto; }
.muted-note {
margin: 10px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 14px;
}
.checkbox-row input {
width: auto;
}
.stack-list,
.timeline-list {
display: grid;
gap: 10px;
}
.row-card.active {
border-color: rgba(178, 75, 26, 0.34);
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.16);
}
.service-title {
font-weight: 600;
}
.task-table-wrap {
max-height: calc(100vh - 320px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.78);
}
.task-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
}
.task-table th,
.task-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.task-table th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(243, 239, 232, 0.96);
padding: 0;
}
.table-sort-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.table-sort-btn.active {
color: var(--accent);
}
.table-sort-btn:hover {
background: rgba(178, 75, 26, 0.06);
}
.task-table tbody tr {
cursor: pointer;
transition: background 140ms ease;
}
.task-table tbody tr:hover {
background: rgba(178, 75, 26, 0.06);
}
.task-table tbody tr.active {
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
}
.task-table-loading {
padding: 16px;
color: var(--muted);
}
.task-cell-title {
font-weight: 600;
line-height: 1.35;
}
.task-table .pill {
white-space: nowrap;
}
.task-table-actions {
white-space: nowrap;
}
.inline-action-btn {
margin-right: 6px;
}
.inline-action-btn:last-child {
margin-right: 0;
}
.task-cell-subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
word-break: break-all;
}
.meta-row,
.button-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill.good { background: var(--good-bg); color: var(--accent-2); }
.pill.warn { background: var(--warn-bg); color: var(--warn); }
.pill.hot { background: var(--hot-bg); color: var(--accent); }
.tasks-layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
}
.task-workspace {
display: grid;
gap: 16px;
}
.task-workspace-state {
display: none;
padding: 14px 16px;
border: 1px dashed var(--line-strong);
border-radius: 18px;
background: rgba(255,255,255,0.62);
color: var(--muted);
}
.task-workspace-state.show {
display: block;
}
.task-workspace-state.loading {
color: var(--warn);
background: var(--warn-bg);
border-style: solid;
}
.task-workspace-state.error {
color: var(--accent);
background: var(--hot-bg);
border-style: solid;
}
.task-hero {
border: 1px solid var(--line);
border-radius: 22px;
padding: 18px;
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(249,242,234,0.92));
}
.task-hero.empty { color: var(--muted); }
.task-hero-title {
margin: 0;
font-size: 26px;
letter-spacing: -0.03em;
}
.task-hero-subtitle {
margin: 8px 0 0;
color: var(--muted);
line-height: 1.5;
}
.task-hero-delivery {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
line-height: 1.6;
}
.hero-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.mini-stat {
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
background: rgba(255,255,255,0.8);
}
.mini-stat-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.mini-stat-value {
margin-top: 6px;
font-weight: 600;
}
.detail-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(260px, .85fr);
gap: 12px;
margin-top: 14px;
}
.detail-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.78);
}
.detail-key {
color: var(--muted);
}
.summary-card {
line-height: 1.55;
}
.step-card-title,
.timeline-title {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.step-card-metrics,
.timeline-meta {
display: grid;
gap: 6px;
margin-top: 10px;
}
.step-metric,
.timeline-meta-line {
color: var(--muted);
font-size: 13px;
}
.step-metric strong,
.timeline-meta-line strong {
color: var(--ink);
}
.artifact-path {
margin-top: 8px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 12px;
}
.settings-toolbar,
.settings-groups,
.settings-fields {
display: grid;
gap: 12px;
}
.settings-group {
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px;
background: rgba(255,255,255,0.72);
}
.settings-group.featured {
border-color: rgba(178, 75, 26, 0.24);
background: linear-gradient(180deg, rgba(255,249,243,0.96), rgba(255,255,255,0.76));
}
.settings-group h3 {
margin: 0 0 8px;
font-size: 15px;
}
.group-desc,
.hint {
color: var(--muted);
font-size: 13px;
}
.settings-field {
display: grid;
gap: 8px;
padding: 10px;
border-radius: 14px;
}
.settings-field.dirty {
background: rgba(178, 75, 26, 0.08);
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.16);
}
.settings-field.error {
background: rgba(178, 75, 26, 0.1);
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.3);
}
.field-error {
color: var(--accent);
font-size: 13px;
line-height: 1.4;
}
.settings-field .button-row {
margin-top: 2px;
}
.settings-advanced {
border: 1px dashed var(--line-strong);
border-radius: 18px;
padding: 12px;
background: rgba(255,255,255,0.56);
}
.settings-label {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
color: var(--muted);
font-size: 13px;
}
.settings-badge {
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
background: rgba(29, 26, 22, 0.08);
color: var(--muted);
}
.logs-layout {
align-items: start;
}
.logs-workspace {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
}
.logs-index-panel {
align-self: start;
}
.log-content-stack {
display: grid;
gap: 16px;
}
.log-card.active {
border-color: rgba(14, 108, 98, 0.34);
box-shadow: inset 0 0 0 1px rgba(14, 108, 98, 0.16);
}
@media (max-width: 1320px) {
.app-shell,
.tasks-layout,
.logs-workspace,
.panel-grid.two-up,
.detail-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
}
@media (max-width: 980px) {
.app-shell {
width: min(100vw - 20px, 100%);
margin: 10px;
}
.topbar,
.sidebar,
.panel {
border-radius: 22px;
}
.stats,
.hero-meta-grid,
.filter-grid,
.field-grid,
.task-filters,
.task-pagination-toolbar {
grid-template-columns: 1fr;
}
.task-pagination-toolbar {
display: grid;
}
.topbar {
flex-direction: column;
}
}

View File

@ -0,0 +1,805 @@
let selectedTaskId = null;
let selectedStepName = null;
let currentTasks = [];
let currentSettings = {};
let currentSettingsSchema = null;
let currentView = "overview";
function statusClass(status) {
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
return "";
}
function showBanner(message, kind) {
const el = document.getElementById("banner");
el.textContent = message;
el.className = `banner show ${kind}`;
}
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function formatDuration(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) return "-";
const total = Math.max(0, Number(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function setView(view) {
currentView = view;
document.querySelectorAll(".nav-btn").forEach((button) => {
button.classList.toggle("active", button.dataset.view === view);
});
document.querySelectorAll(".view").forEach((section) => {
section.classList.toggle("active", section.dataset.view === view);
});
const titleMap = {
overview: "Overview",
tasks: "Tasks",
settings: "Settings",
logs: "Logs",
};
document.getElementById("viewTitle").textContent = titleMap[view] || "Control";
}
async function fetchJson(url, options) {
const token = localStorage.getItem("biliup_next_token") || "";
const opts = options ? { ...options } : {};
opts.headers = { ...(opts.headers || {}) };
if (token) opts.headers["X-Biliup-Token"] = token;
const res = await fetch(url, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data));
return data;
}
function buildHistoryUrl() {
const params = new URLSearchParams();
params.set("limit", "20");
const status = document.getElementById("historyStatusFilter")?.value || "";
const actionName = document.getElementById("historyActionFilter")?.value.trim() || "";
const currentOnly = document.getElementById("historyCurrentTask")?.checked;
if (status) params.set("status", status);
if (actionName) params.set("action_name", actionName);
if (currentOnly && selectedTaskId) params.set("task_id", selectedTaskId);
return `/history?${params.toString()}`;
}
function filteredTasks() {
const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase();
const status = document.getElementById("taskStatusFilter")?.value || "";
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
let items = currentTasks.filter((task) => {
const haystack = `${task.id} ${task.title}`.toLowerCase();
if (search && !haystack.includes(search)) return false;
if (status && task.status !== status) return false;
return true;
});
const statusRank = {
failed_manual: 0,
failed_retryable: 1,
running: 2,
created: 3,
transcribed: 4,
songs_detected: 5,
split_done: 6,
published: 7,
collection_synced: 8,
};
items = [...items].sort((a, b) => {
if (sort === "updated_asc") return String(a.updated_at).localeCompare(String(b.updated_at));
if (sort === "title_asc") return String(a.title).localeCompare(String(b.title));
if (sort === "status_group") {
const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99);
if (diff !== 0) return diff;
return String(b.updated_at).localeCompare(String(a.updated_at));
}
return String(b.updated_at).localeCompare(String(a.updated_at));
});
return items;
}
async function loadOverview() {
const historyUrl = buildHistoryUrl();
const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history] = await Promise.all([
fetchJson("/health"),
fetchJson("/doctor"),
fetchJson("/tasks?limit=100"),
fetchJson("/modules"),
fetchJson("/settings"),
fetchJson("/settings/schema"),
fetchJson("/runtime/services"),
fetchJson("/logs"),
fetchJson(historyUrl),
]);
currentTasks = tasks.items;
currentSettings = settings;
currentSettingsSchema = settingsSchema;
document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || "";
document.getElementById("healthValue").textContent = health.ok ? "OK" : "FAIL";
document.getElementById("doctorValue").textContent = doctor.ok ? "OK" : "FAIL";
document.getElementById("tasksValue").textContent = tasks.items.length;
document.getElementById("overviewHealthValue").textContent = health.ok ? "OK" : "FAIL";
document.getElementById("overviewDoctorValue").textContent = doctor.ok ? "OK" : "FAIL";
document.getElementById("overviewTasksValue").textContent = tasks.items.length;
renderSettingsForm();
syncSettingsEditorFromState();
renderTasks();
renderModules(modules.items);
renderDoctor(doctor.checks);
renderServices(services.items);
renderLogsList(logs.items);
renderRecentActions(history.items);
if (!selectedTaskId && currentTasks.length) selectedTaskId = currentTasks[0].id;
if (selectedTaskId) await loadTaskDetail(selectedTaskId);
}
function renderTasks() {
const wrap = document.getElementById("taskList");
wrap.innerHTML = "";
const items = filteredTasks();
if (!items.length) {
wrap.innerHTML = `<div class="row-card"><strong>没有匹配任务</strong><div class="muted-note">调整搜索、状态或排序条件后重试。</div></div>`;
return;
}
for (const item of items) {
const el = document.createElement("div");
el.className = `task-card ${item.id === selectedTaskId ? "active" : ""}`;
const retryText = item.retry_state?.next_retry_at ? `next retry ${formatDate(item.retry_state.next_retry_at)}` : "";
el.innerHTML = `
<div class="task-title">${escapeHtml(item.title)}</div>
<div class="meta-row">
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
<span class="pill">${escapeHtml(item.id)}</span>
</div>
<div class="muted-note">${escapeHtml(formatDate(item.updated_at))}</div>
${retryText ? `<div class="muted-note">${escapeHtml(retryText)}</div>` : ""}
`;
el.onclick = async () => {
selectedTaskId = item.id;
setView("tasks");
renderTasks();
await loadTaskDetail(item.id);
};
wrap.appendChild(el);
}
}
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;
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">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>
`;
}
function renderRetryPanel(task) {
const wrap = document.getElementById("taskRetryPanel");
const retry = task.retry_state;
if (!retry || !retry.next_retry_at) {
wrap.className = "retry-banner";
wrap.style.display = "none";
wrap.textContent = "";
return;
}
wrap.style.display = "block";
wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`;
wrap.innerHTML = `
<strong>${escapeHtml(retry.step_name)}</strong>
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
`;
}
async function loadTaskDetail(taskId) {
const [task, steps, artifacts, history, timeline] = await Promise.all([
fetchJson(`/tasks/${taskId}`),
fetchJson(`/tasks/${taskId}/steps`),
fetchJson(`/tasks/${taskId}/artifacts`),
fetchJson(`/tasks/${taskId}/history`),
fetchJson(`/tasks/${taskId}/timeline`),
]);
renderTaskHero(task, steps);
renderRetryPanel(task);
const detail = document.getElementById("taskDetail");
detail.innerHTML = "";
const pairs = [
["Task ID", task.id],
["Status", task.status],
["Created", formatDate(task.created_at)],
["Updated", formatDate(task.updated_at)],
["Source", task.source_path],
["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"],
];
for (const [key, value] of pairs) {
const k = document.createElement("div");
k.className = "detail-key";
k.textContent = key;
const v = document.createElement("div");
v.textContent = value || "-";
detail.appendChild(k);
detail.appendChild(v);
}
let summaryText = "暂无最近结果";
const latestAction = history.items[0];
if (latestAction) {
summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`;
} else {
const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"];
const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status));
const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0];
if (summaryStep) {
summaryText = summaryStep.error_message
? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}`
: `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`;
}
}
document.getElementById("taskSummary").textContent = summaryText;
const stepWrap = document.getElementById("stepList");
stepWrap.innerHTML = "";
for (const step of steps.items) {
const row = document.createElement("div");
row.className = `row-card ${selectedStepName === step.step_name ? "active" : ""}`;
row.style.cursor = "pointer";
const retryBlock = step.next_retry_at ? `
<div class="step-card-metrics">
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
</div>
` : "";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(step.step_name)}</strong>
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
<span class="pill">retry ${step.retry_count}</span>
</div>
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
<div class="step-card-metrics">
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
</div>
${retryBlock}
`;
row.onclick = () => {
selectedStepName = step.step_name;
loadTaskDetail(taskId).catch((err) => showBanner(`任务详情刷新失败: ${err}`, "err"));
};
stepWrap.appendChild(row);
}
const artifactWrap = document.getElementById("artifactList");
artifactWrap.innerHTML = "";
for (const artifact of artifacts.items) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
`;
artifactWrap.appendChild(row);
}
const historyWrap = document.getElementById("historyList");
historyWrap.innerHTML = "";
for (const item of history.items) {
let details = "";
try {
details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2);
} catch {
details = item.details_json || "";
}
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(item.action_name)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
</div>
<div class="muted-note">${escapeHtml(item.summary)}</div>
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
<pre>${escapeHtml(details)}</pre>
`;
historyWrap.appendChild(row);
}
const timelineWrap = document.getElementById("timelineList");
timelineWrap.innerHTML = "";
for (const item of timeline.items) {
const retryNote = item.retry_state?.next_retry_at
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
: "";
const row = document.createElement("div");
row.className = "timeline-card";
row.innerHTML = `
<div class="timeline-title">
<strong>${escapeHtml(item.title)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
<span class="pill">${escapeHtml(item.kind)}</span>
</div>
<div class="timeline-meta">
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
${retryNote}
</div>
`;
timelineWrap.appendChild(row);
}
}
function syncSettingsEditorFromState() {
document.getElementById("settingsEditor").value = JSON.stringify(currentSettings, null, 2);
}
function getGroupOrder(groupName) {
return Number(currentSettingsSchema.group_ui?.[groupName]?.order || 9999);
}
function compareFieldEntries(a, b) {
const orderA = Number(a[1].ui_order || 9999);
const orderB = Number(b[1].ui_order || 9999);
if (orderA !== orderB) return orderA - orderB;
return String(a[0]).localeCompare(String(b[0]));
}
function createSettingsField(groupName, fieldName, fieldSchema) {
const row = document.createElement("div");
row.className = "settings-field";
const label = document.createElement("label");
label.className = "settings-label";
label.textContent = fieldSchema.title || `${groupName}.${fieldName}`;
if (fieldSchema.ui_widget) {
const badge = document.createElement("span");
badge.className = "settings-badge";
badge.textContent = fieldSchema.ui_widget;
label.appendChild(badge);
}
if (fieldSchema.ui_featured === true) {
const badge = document.createElement("span");
badge.className = "settings-badge";
badge.textContent = "featured";
label.appendChild(badge);
}
row.appendChild(label);
const value = currentSettings[groupName]?.[fieldName];
let input;
if (fieldSchema.type === "boolean") {
input = document.createElement("input");
input.type = "checkbox";
input.checked = Boolean(value);
} else if (Array.isArray(fieldSchema.enum)) {
input = document.createElement("select");
for (const optionValue of fieldSchema.enum) {
const option = document.createElement("option");
option.value = String(optionValue);
option.textContent = String(optionValue);
if (value === optionValue) option.selected = true;
input.appendChild(option);
}
} else if (fieldSchema.type === "array") {
input = document.createElement("textarea");
input.style.minHeight = "96px";
input.value = JSON.stringify(value ?? [], null, 2);
} else {
input = document.createElement("input");
input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text");
input.value = value ?? "";
if (fieldSchema.type === "integer") {
if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum);
input.step = "1";
}
if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder;
}
input.dataset.group = groupName;
input.dataset.field = fieldName;
input.onchange = handleSettingsFieldChange;
row.appendChild(input);
if (fieldSchema.description || fieldSchema.sensitive) {
const hint = document.createElement("div");
hint.className = "hint";
let text = fieldSchema.description || "";
if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`;
hint.textContent = text;
row.appendChild(hint);
}
return row;
}
function createSettingsGroup(groupName, fields, featured) {
const entries = Object.entries(fields);
if (!entries.length) return null;
const group = document.createElement("div");
group.className = `settings-group ${featured ? "featured" : ""}`.trim();
const title = document.createElement("h3");
title.textContent = currentSettingsSchema.group_ui?.[groupName]?.title || groupName;
group.appendChild(title);
const descText = currentSettingsSchema.group_ui?.[groupName]?.description;
if (descText) {
const desc = document.createElement("div");
desc.className = "group-desc";
desc.textContent = descText;
group.appendChild(desc);
}
const fieldWrap = document.createElement("div");
fieldWrap.className = "settings-fields";
for (const [fieldName, fieldSchema] of entries) {
fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema));
}
group.appendChild(fieldWrap);
return group;
}
function renderSettingsForm() {
const wrap = document.getElementById("settingsForm");
wrap.innerHTML = "";
if (!currentSettingsSchema?.groups) return;
const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase();
const featuredContainer = document.createElement("div");
featuredContainer.className = "settings-groups";
const advancedDetails = document.createElement("details");
advancedDetails.className = "settings-advanced";
const advancedSummary = document.createElement("summary");
advancedSummary.textContent = "Advanced Settings";
advancedDetails.appendChild(advancedSummary);
const advancedContainer = document.createElement("div");
advancedContainer.className = "settings-groups";
const groupEntries = Object.entries(currentSettingsSchema.groups).sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0]));
for (const [groupName, fields] of groupEntries) {
const featuredFields = {};
const advancedFields = {};
const fieldEntries = Object.entries(fields).sort((a, b) => compareFieldEntries(a, b));
for (const [fieldName, fieldSchema] of fieldEntries) {
const key = `${groupName}.${fieldName}`.toLowerCase();
if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) continue;
if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema;
else advancedFields[fieldName] = fieldSchema;
}
const featuredGroup = createSettingsGroup(groupName, featuredFields, true);
if (featuredGroup) featuredContainer.appendChild(featuredGroup);
const advancedGroup = createSettingsGroup(groupName, advancedFields, false);
if (advancedGroup) advancedContainer.appendChild(advancedGroup);
}
if (!featuredContainer.children.length && !advancedContainer.children.length) {
wrap.innerHTML = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
return;
}
if (featuredContainer.children.length) wrap.appendChild(featuredContainer);
if (advancedContainer.children.length) {
advancedDetails.appendChild(advancedContainer);
wrap.appendChild(advancedDetails);
}
}
function handleSettingsFieldChange(event) {
const input = event.target;
const group = input.dataset.group;
const field = input.dataset.field;
const fieldSchema = currentSettingsSchema.groups[group][field];
let value;
if (fieldSchema.type === "boolean") value = input.checked;
else if (fieldSchema.type === "integer") value = Number(input.value);
else if (fieldSchema.type === "array") {
try {
value = JSON.parse(input.value || "[]");
if (!Array.isArray(value)) throw new Error("not array");
} catch {
showBanner(`${group}.${field} 必须是 JSON 数组`, "warn");
return;
}
} else value = input.value;
if (!currentSettings[group]) currentSettings[group] = {};
currentSettings[group][field] = value;
syncSettingsEditorFromState();
}
function renderRecentActions(items) {
const wrap = document.getElementById("recentActionList");
wrap.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(item.action_name)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
</div>
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
`;
wrap.appendChild(row);
}
}
function renderModules(items) {
const wrap = document.getElementById("moduleList");
wrap.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
`;
wrap.appendChild(row);
}
}
function renderDoctor(checks) {
const wrap = document.getElementById("doctorChecks");
wrap.innerHTML = "";
for (const check of checks) {
const row = document.createElement("div");
row.className = "row-card";
row.innerHTML = `
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
<div class="muted-note">${escapeHtml(check.detail)}</div>
`;
wrap.appendChild(row);
}
}
function renderServices(items) {
const wrap = document.getElementById("serviceList");
wrap.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "service-card";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(item.id)}</strong>
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
<span class="pill">${escapeHtml(item.sub_state)}</span>
</div>
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
<div class="button-row" style="margin-top:12px;">
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
</div>
`;
wrap.appendChild(row);
}
wrap.querySelectorAll("button[data-service]").forEach((btn) => {
btn.onclick = async () => {
if (["stop", "restart"].includes(btn.dataset.action)) {
const ok = window.confirm(`确认执行 ${btn.dataset.action} ${btn.dataset.service} ?`);
if (!ok) return;
}
try {
const payload = await fetchJson(`/runtime/services/${btn.dataset.service}/${btn.dataset.action}`, { method: "POST" });
await loadOverview();
showBanner(`${payload.id} ${payload.action} 完成`, payload.command_ok ? "ok" : "warn");
} catch (err) {
showBanner(`service 操作失败: ${err}`, "err");
}
};
});
}
function renderLogsList(items) {
const select = document.getElementById("logSelect");
if (!select.options.length) {
for (const item of items) {
const option = document.createElement("option");
option.value = item.name;
option.textContent = item.name;
select.appendChild(option);
}
}
refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
}
async function refreshLog() {
const name = document.getElementById("logSelect").value;
if (!name) return;
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
if (document.getElementById("filterCurrentTask").checked && selectedTaskId) {
const currentTask = currentTasks.find((item) => item.id === selectedTaskId);
if (currentTask?.title) url += `&contains=${encodeURIComponent(currentTask.title)}`;
}
const payload = await fetchJson(url);
document.getElementById("logPath").textContent = payload.path;
document.getElementById("logContent").textContent = payload.content || "";
}
document.querySelectorAll(".nav-btn").forEach((button) => {
button.onclick = () => setView(button.dataset.view);
});
document.getElementById("refreshBtn").onclick = async () => {
await loadOverview();
showBanner("视图已刷新", "ok");
};
document.getElementById("runOnceBtn").onclick = async () => {
try {
const result = await fetchJson("/worker/run-once", { method: "POST" });
await loadOverview();
showBanner(`Worker 已执行一轮processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(String(err), "err");
}
};
document.getElementById("saveSettingsBtn").onclick = async () => {
try {
const payload = JSON.parse(document.getElementById("settingsEditor").value);
await fetchJson("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
await loadOverview();
showBanner("Settings 已保存", "ok");
} catch (err) {
showBanner(`保存失败: ${err}`, "err");
}
};
document.getElementById("syncFormToJsonBtn").onclick = () => {
syncSettingsEditorFromState();
showBanner("表单已同步到 JSON", "ok");
};
document.getElementById("syncJsonToFormBtn").onclick = () => {
try {
currentSettings = JSON.parse(document.getElementById("settingsEditor").value);
renderSettingsForm();
syncSettingsEditorFromState();
showBanner("JSON 已重绘到表单", "ok");
} catch (err) {
showBanner(`JSON 解析失败: ${err}`, "err");
}
};
document.getElementById("settingsSearch").oninput = () => renderSettingsForm();
document.getElementById("taskSearchInput").oninput = () => renderTasks();
document.getElementById("taskStatusFilter").onchange = () => renderTasks();
document.getElementById("taskSortSelect").onchange = () => renderTasks();
document.getElementById("importStageBtn").onclick = async () => {
const sourcePath = document.getElementById("stageSourcePath").value.trim();
if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn");
try {
const result = await fetchJson("/stage/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_path: sourcePath }),
});
document.getElementById("stageSourcePath").value = "";
showBanner(`已导入到 stage: ${result.target_path}`, "ok");
} catch (err) {
showBanner(`导入失败: ${err}`, "err");
}
};
document.getElementById("uploadStageBtn").onclick = async () => {
const input = document.getElementById("stageFileInput");
if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn");
try {
const form = new FormData();
form.append("file", input.files[0]);
const res = await fetch("/stage/upload", { method: "POST", body: form });
const data = await res.json();
if (!res.ok) throw new Error(data.error || JSON.stringify(data));
input.value = "";
showBanner(`已上传到 stage: ${data.target_path}`, "ok");
} catch (err) {
showBanner(`上传失败: ${err}`, "err");
}
};
document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
document.getElementById("logSelect").onchange = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err"));
document.getElementById("saveTokenBtn").onclick = async () => {
const token = document.getElementById("tokenInput").value.trim();
localStorage.setItem("biliup_next_token", token);
try {
await loadOverview();
showBanner("Token 已保存并生效", "ok");
} catch (err) {
showBanner(`Token 验证失败: ${err}`, "err");
}
};
document.getElementById("runTaskBtn").onclick = async () => {
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
try {
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/run`, { method: "POST" });
await loadOverview();
showBanner(`任务已推进processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`任务执行失败: ${err}`, "err");
}
};
document.getElementById("retryStepBtn").onclick = async () => {
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
try {
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/retry-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: selectedStepName }),
});
await loadOverview();
showBanner(`已重试 step=${selectedStepName}processed=${result.processed.length}`, "ok");
} catch (err) {
showBanner(`重试失败: ${err}`, "err");
}
};
document.getElementById("resetStepBtn").onclick = async () => {
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
const ok = window.confirm(`确认重置到 step=${selectedStepName} 并清理其后的产物吗?`);
if (!ok) return;
try {
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/reset-to-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: selectedStepName }),
});
await loadOverview();
showBanner(`已重置并重跑 step=${selectedStepName}processed=${result.run.processed.length}`, "ok");
} catch (err) {
showBanner(`重置失败: ${err}`, "err");
}
};
setView("overview");
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from biliup_next.app.bootstrap import ensure_initialized
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()
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()
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}
record_task_action(state["repo"], task_id, "reset_to_step", "ok", f"reset to step invoked: {step_name}", payload)
return payload

View File

@ -0,0 +1,19 @@
from __future__ import annotations
import json
from biliup_next.core.models import ActionRecord, utc_now_iso
def record_task_action(repo, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None: # type: ignore[no-untyped-def]
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

@ -0,0 +1,129 @@
from __future__ import annotations
from biliup_next.app.retry_meta import retry_meta_for_step
def settings_for(state: dict[str, object], group: str) -> dict[str, object]:
settings = dict(state["settings"][group])
settings.update(state["settings"]["paths"])
if group == "comment" and "collection" in state["settings"]:
collection_settings = state["settings"]["collection"]
if "allow_fuzzy_full_video_match" in collection_settings:
settings.setdefault("allow_fuzzy_full_video_match", collection_settings["allow_fuzzy_full_video_match"])
if group == "collection" and "cleanup" in state["settings"]:
settings.update(state["settings"]["cleanup"])
if "publish" in state["settings"]:
publish_settings = state["settings"]["publish"]
if "biliup_path" in publish_settings:
settings.setdefault("biliup_path", publish_settings["biliup_path"])
if "cookie_file" in publish_settings:
settings.setdefault("cookie_file", publish_settings["cookie_file"])
return settings
def infer_error_step_name(task, steps: dict[str, object]) -> str: # type: ignore[no-untyped-def]
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":
return "song_detect"
if task.status == "songs_detected":
return "split"
if task.status in {"published", "collection_synced"}:
if steps.get("comment") and steps["comment"].status in {"running", "pending", "failed_retryable"}:
return "comment"
if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}:
return "collection_a"
return "collection_b"
if task.status == "commented":
if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}:
return "collection_a"
return "collection_b"
return "publish"
def retry_wait_payload(task_id: str, step, state: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def]
if step.status != "failed_retryable":
return None
meta = retry_meta_for_step(step, {"publish": settings_for(state, "publish")})
if meta is None or meta["retry_due"]:
return None
return {
"task_id": task_id,
"step": step.step_name,
"waiting_for_retry": True,
"remaining_seconds": meta["retry_remaining_seconds"],
"retry_count": step.retry_count,
}
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 task.status == "failed_retryable":
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is None:
return None, None
wait_payload = retry_wait_payload(task.id, failed, state)
if wait_payload is not None:
return None, wait_payload
return failed.step_name, None
if task.status == "created":
step = steps.get("transcribe")
if step and step.status in {"pending", "failed_retryable"}:
return "transcribe", None
if task.status == "transcribed":
step = steps.get("song_detect")
if step and step.status in {"pending", "failed_retryable"}:
return "song_detect", None
if task.status == "songs_detected":
step = steps.get("split")
if step and step.status in {"pending", "failed_retryable"}:
return "split", None
if task.status == "split_done":
step = steps.get("publish")
if step and step.status in {"pending", "failed_retryable"}:
return "publish", None
if task.status in {"published", "collection_synced"}:
if state["settings"]["comment"].get("enabled", True):
step = steps.get("comment")
if step and step.status in {"pending", "failed_retryable"}:
return "comment", None
if state["settings"]["collection"].get("enabled", True):
step = steps.get("collection_a")
if step and step.status in {"pending", "failed_retryable"}:
return "collection_a", None
step = steps.get("collection_b")
if step and step.status in {"pending", "failed_retryable"}:
return "collection_b", None
if task.status == "commented" and state["settings"]["collection"].get("enabled", True):
step = steps.get("collection_a")
if step and step.status in {"pending", "failed_retryable"}:
return "collection_a", None
step = steps.get("collection_b")
if step and step.status in {"pending", "failed_retryable"}:
return "collection_b", None
return None, None
def execute_step(state: dict[str, object], task_id: str, step_name: str) -> dict[str, object]:
if step_name == "transcribe":
artifact = state["transcribe_service"].run(task_id, settings_for(state, "transcribe"))
return {"task_id": task_id, "step": "transcribe", "artifact": artifact.path}
if step_name == "song_detect":
songs_json, songs_txt = state["song_detect_service"].run(task_id, settings_for(state, "song_detect"))
return {"task_id": task_id, "step": "song_detect", "songs_json": songs_json.path, "songs_txt": songs_txt.path}
if step_name == "split":
clips = state["split_service"].run(task_id, settings_for(state, "split"))
return {"task_id": task_id, "step": "split", "clip_count": len(clips)}
if step_name == "publish":
publish_record = state["publish_service"].run(task_id, settings_for(state, "publish"))
return {"task_id": task_id, "step": "publish", "bvid": publish_record.bvid}
if step_name == "comment":
comment_result = state["comment_service"].run(task_id, settings_for(state, "comment"))
return {"task_id": task_id, "step": "comment", "result": comment_result}
if step_name == "collection_a":
collection_result = state["collection_service"].run(task_id, "a", settings_for(state, "collection"))
return {"task_id": task_id, "step": "collection_a", "result": collection_result}
if step_name == "collection_b":
collection_result = state["collection_service"].run(task_id, "b", settings_for(state, "collection"))
return {"task_id": task_id, "step": "collection_b", "result": collection_result}
raise RuntimeError(f"unsupported step: {step_name}")

View File

@ -0,0 +1,67 @@
from __future__ import annotations
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
def settings_for(state: dict[str, object], group: str) -> dict[str, object]:
return task_engine_settings_for(state, group)
def apply_disabled_step_fallbacks(state: dict[str, object], task, repo) -> bool: # type: ignore[no-untyped-def]
if task.status in {"published", "collection_synced"} and not state["settings"]["comment"].get("enabled", True):
repo.update_step_status(task.id, "comment", "succeeded", finished_at=utc_now_iso())
return True
if task.status in {"published", "commented", "collection_synced"} and not state["settings"]["collection"].get("enabled", True):
now = utc_now_iso()
repo.update_step_status(task.id, "collection_a", "succeeded", finished_at=now)
repo.update_step_status(task.id, "collection_b", "succeeded", finished_at=now)
repo.update_task_status(task.id, "collection_synced", now)
return True
return False
def resolve_failure(task, repo, state: dict[str, object], exc) -> dict[str, object]: # type: ignore[no-untyped-def]
current_task = repo.get_task(task.id) or task
current_steps = {step.step_name: step for step in repo.list_steps(task.id)}
step_name = infer_error_step_name(current_task, current_steps)
current_retry = 0
for step in repo.list_steps(task.id):
if step.step_name == step_name:
current_retry = step.retry_count
break
next_retry_count = current_retry + 1
next_status = "failed_retryable" if exc.retryable else "failed_manual"
next_retry_delay_seconds: int | None = None
if exc.retryable and step_name == "publish":
schedule = publish_retry_schedule_seconds(settings_for(state, "publish"))
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,
step_name,
next_status,
error_code=exc.code,
error_message=exc.message,
retry_count=next_retry_count,
finished_at=failed_at,
)
repo.update_task_status(task.id, next_status, failed_at)
payload = {
"task_id": task.id,
"step": step_name,
"error": exc.to_dict(),
"retry_count": next_retry_count,
"retry_status": next_status,
}
if next_retry_delay_seconds is not None:
payload["next_retry_delay_seconds"] = next_retry_delay_seconds
return {
"step_name": step_name,
"payload": payload,
"summary": exc.message,
}

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.task_audit import record_task_action
from biliup_next.app.task_engine import (
execute_step,
next_runnable_step,
)
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
def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]:
state = ensure_initialized()
repo = state["repo"]
task = repo.get_task(task_id)
if task is None:
return {"processed": [], "error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}}
processed: list[dict[str, object]] = []
if include_stage_scan:
ingest_settings = dict(state["settings"]["ingest"])
ingest_settings.update(state["settings"]["paths"])
stage_scan = state["ingest_service"].scan_stage(ingest_settings)
processed.append({"stage_scan": stage_scan})
record_task_action(repo, task_id, "stage_scan", "ok", "stage scan completed", stage_scan)
if reset_step:
step_names = {step.step_name for step in repo.list_steps(task_id)}
if reset_step not in step_names:
return {"processed": processed, "error": {"code": "STEP_NOT_FOUND", "message": f"step not found: {reset_step}"}}
repo.update_step_status(
task_id,
reset_step,
"pending",
error_code=None,
error_message=None,
started_at=None,
finished_at=None,
)
repo.update_task_status(task_id, task.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})
try:
while True:
current_task = repo.get_task(task.id) or task
current_steps = {step.step_name: step for step in repo.list_steps(task.id)}
if apply_disabled_step_fallbacks(state, current_task, repo):
continue
step_name, waiting_payload = next_runnable_step(current_task, current_steps, state)
if waiting_payload is not None:
processed.append(waiting_payload)
return {"processed": processed}
if step_name is None:
break
payload = execute_step(state, task.id, step_name)
if current_task.status == "failed_retryable":
payload["retry"] = True
record_task_action(repo, task_id, step_name, "ok", f"{step_name} retry succeeded", payload)
else:
record_task_action(repo, task_id, step_name, "ok", f"{step_name} succeeded", payload)
processed.append(payload)
except ModuleError as exc:
failure = resolve_failure(task, repo, state, exc)
processed.append(failure["payload"])
record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"])
return {"processed": processed}

View File

@ -0,0 +1,30 @@
from __future__ import annotations
import time
from biliup_next.app.scheduler import (
run_scheduler_cycle,
serialize_scheduled_task,
)
from biliup_next.app.task_runner import process_task
def run_once() -> dict[str, object]:
processed: list[dict[str, object]] = []
cycle = run_scheduler_cycle(include_stage_scan=True, limit=200)
processed.append({"stage_scan": cycle.preview["stage_scan"]})
processed.extend(cycle.deferred)
for scheduled_task in cycle.scheduled:
processed.extend(process_task(scheduled_task.task_id)["processed"])
return {
"processed": processed,
"scheduler": {
**cycle.preview,
"scheduled": [serialize_scheduled_task(scheduled_task) for scheduled_task in cycle.scheduled],
},
}
def run_forever(interval_seconds: int = 5) -> None:
while True:
run_once()
time.sleep(interval_seconds)

View File

@ -0,0 +1,230 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
class ConfigError(RuntimeError):
pass
@dataclass(slots=True)
class SettingsBundle:
schema: dict[str, Any]
settings: dict[str, Any]
class SettingsService:
SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__"
def __init__(self, root_dir: Path):
self.root_dir = root_dir
self.config_dir = self.root_dir / "config"
self.schema_path = self.config_dir / "settings.schema.json"
self.settings_path = self.config_dir / "settings.json"
self.staged_path = self.config_dir / "settings.staged.json"
def load(self) -> SettingsBundle:
schema = self._read_json(self.schema_path)
settings = self._read_json(self.settings_path)
settings = self._apply_schema_defaults(settings, schema)
settings = self._apply_legacy_env_overrides(settings, schema)
settings = self._normalize_paths(settings)
self.validate(settings, schema)
return SettingsBundle(schema=schema, settings=settings)
def validate(self, settings: dict[str, Any], schema: dict[str, Any]) -> None:
groups = schema.get("groups", {})
for group_name, fields in groups.items():
if group_name not in settings:
raise ConfigError(f"缺少配置分组: {group_name}")
group_value = settings[group_name]
if not isinstance(group_value, dict):
raise ConfigError(f"配置分组类型错误: {group_name}")
for field_name, field_schema in fields.items():
if field_name not in group_value:
raise ConfigError(f"缺少配置项: {group_name}.{field_name}")
self._validate_field(group_name, field_name, group_value[field_name], field_schema)
def save_staged(self, settings: dict[str, Any]) -> None:
schema = self._read_json(self.schema_path)
settings = self._apply_schema_defaults(settings, schema)
self.validate(settings, schema)
self._write_json(self.staged_path, settings)
def load_redacted(self) -> SettingsBundle:
bundle = self.load()
return SettingsBundle(schema=bundle.schema, settings=self._redact_sensitive(bundle.settings, bundle.schema))
def save_staged_from_redacted(self, settings: dict[str, Any]) -> None:
bundle = self.load()
schema = bundle.schema
current = bundle.settings
merged = self._restore_sensitive_values(current, settings, schema)
merged = self._apply_schema_defaults(merged, schema)
self.validate(merged, schema)
self._write_json(self.staged_path, merged)
def promote_staged(self) -> None:
staged = self._read_json(self.staged_path)
schema = self._read_json(self.schema_path)
staged = self._apply_schema_defaults(staged, schema)
self.validate(staged, schema)
self._write_json(self.settings_path, staged)
def _validate_field(self, group: str, name: str, value: Any, field_schema: dict[str, Any]) -> None:
expected = field_schema.get("type")
if expected == "string" and not isinstance(value, str):
raise ConfigError(f"{group}.{name} 必须是字符串")
if expected == "integer":
if not isinstance(value, int) or isinstance(value, bool):
raise ConfigError(f"{group}.{name} 必须是整数")
minimum = field_schema.get("minimum")
if minimum is not None and value < minimum:
raise ConfigError(f"{group}.{name} 不能小于 {minimum}")
if expected == "boolean" and not isinstance(value, bool):
raise ConfigError(f"{group}.{name} 必须是布尔值")
if expected == "array":
if not isinstance(value, list):
raise ConfigError(f"{group}.{name} 必须是数组")
item_schema = field_schema.get("items")
if isinstance(item_schema, dict):
for index, item in enumerate(value):
self._validate_field(group, f"{name}[{index}]", item, item_schema)
enum = field_schema.get("enum")
if enum is not None and value not in enum:
raise ConfigError(f"{group}.{name} 必须属于 {enum}")
def _apply_schema_defaults(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
merged = json.loads(json.dumps(settings))
groups = schema.get("groups", {})
for group_name, fields in groups.items():
group_value = merged.setdefault(group_name, {})
if not isinstance(group_value, dict):
raise ConfigError(f"配置分组类型错误: {group_name}")
for field_name, field_schema in fields.items():
if field_name in group_value:
continue
if "default" not in field_schema:
continue
group_value[field_name] = self._clone_default(field_schema["default"])
return merged
@staticmethod
def _clone_default(value: Any) -> Any:
return json.loads(json.dumps(value))
@staticmethod
def _read_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise ConfigError(f"配置文件不存在: {path}")
with path.open("r", encoding="utf-8") as f:
return json.load(f)
@staticmethod
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
def _apply_legacy_env_overrides(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
env_path = self.root_dir.parent / ".env"
if not env_path.exists():
return settings
env_map: dict[str, str] = {}
with env_path.open("r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env_map[key.strip()] = value.strip()
overrides = {
("transcribe", "groq_api_key"): env_map.get("GROQ_API_KEY"),
("song_detect", "codex_cmd"): self._resolve_legacy_path(env_map.get("CODEX_CMD")),
("transcribe", "ffmpeg_bin"): self._resolve_legacy_path(env_map.get("FFMPEG_BIN")),
("split", "ffmpeg_bin"): self._resolve_legacy_path(env_map.get("FFMPEG_BIN")),
("ingest", "ffprobe_bin"): self._resolve_legacy_path(env_map.get("FFPROBE_BIN")),
("publish", "biliup_path"): self._resolve_legacy_path(env_map.get("BILIUP_PATH")),
("publish", "cookie_file"): self._resolve_legacy_path(env_map.get("BILIUP_COOKIE_FILE")),
("paths", "cookies_file"): self._resolve_legacy_path(env_map.get("BILIUP_COOKIE_FILE")),
}
merged = json.loads(json.dumps(settings))
defaults = schema.get("groups", {})
for (group, field), value in overrides.items():
default_value = defaults.get(group, {}).get(field, {}).get("default")
current_value = merged.get(group, {}).get(field)
if value and (current_value in ("", None) or current_value == default_value):
merged[group][field] = value
return merged
def _resolve_legacy_path(self, value: str | None) -> str | None:
if not value:
return value
if value.startswith("./") or value.startswith("../"):
return str((self.root_dir.parent / value).resolve())
return value
def _normalize_paths(self, settings: dict[str, Any]) -> dict[str, Any]:
normalized = json.loads(json.dumps(settings))
for group, field in (
("runtime", "database_path"),
("paths", "stage_dir"),
("paths", "backup_dir"),
("paths", "session_dir"),
("paths", "cookies_file"),
("paths", "upload_config_file"),
("ingest", "ffprobe_bin"),
("transcribe", "ffmpeg_bin"),
("split", "ffmpeg_bin"),
("song_detect", "codex_cmd"),
("publish", "biliup_path"),
("publish", "cookie_file"),
):
value = normalized[group][field]
if isinstance(value, str):
normalized[group][field] = self._resolve_project_path(value)
return normalized
def _resolve_project_path(self, value: str) -> str:
path = Path(value)
if path.is_absolute():
return str(path)
if "/" not in value and "\\" not in value:
return value
return str((self.root_dir / path).resolve())
def _redact_sensitive(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
redacted = json.loads(json.dumps(settings))
for group_name, field_name in self._iter_sensitive_fields(schema):
value = redacted.get(group_name, {}).get(field_name)
if isinstance(value, str) and value:
redacted[group_name][field_name] = self.SECRET_PLACEHOLDER
return redacted
def _restore_sensitive_values(
self,
current: dict[str, Any],
submitted: dict[str, Any],
schema: dict[str, Any],
) -> dict[str, Any]:
merged = json.loads(json.dumps(submitted))
for group_name, field_name in self._iter_sensitive_fields(schema):
submitted_value = merged.get(group_name, {}).get(field_name)
current_value = current.get(group_name, {}).get(field_name, "")
if submitted_value == self.SECRET_PLACEHOLDER and isinstance(current_value, str):
merged[group_name][field_name] = current_value
return merged
@staticmethod
def _iter_sensitive_fields(schema: dict[str, Any]) -> list[tuple[str, str]]:
items: list[tuple[str, str]] = []
for group_name, fields in schema.get("groups", {}).items():
for field_name, field_schema in fields.items():
if field_schema.get("sensitive") is True:
items.append((group_name, field_name))
return items

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
@dataclass(slots=True)
class ModuleError(Exception):
code: str
message: str
retryable: bool
details: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Any
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class Task:
id: str
source_type: str
source_path: str
title: str
status: str
created_at: str
updated_at: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class TaskStep:
id: int | None
task_id: str
step_name: str
status: str
error_code: str | None
error_message: str | None
retry_count: int
started_at: str | None
finished_at: str | None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class Artifact:
id: int | None
task_id: str
artifact_type: str
path: str
metadata_json: str
created_at: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class PublishRecord:
id: int | None
task_id: str
platform: str
aid: str | None
bvid: str | None
title: str
published_at: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(slots=True)
class ActionRecord:
id: int | None
task_id: str | None
action_name: str
status: str
summary: str
details_json: str
created_at: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from biliup_next.core.models import Artifact, PublishRecord, Task
@dataclass(slots=True)
class ProviderManifest:
id: str
name: str
version: str
provider_type: str
entrypoint: str
capabilities: list[str]
config_schema: str | None = None
enabled_by_default: bool = True
class IngestProvider(Protocol):
manifest: ProviderManifest
def validate_source(self, source_path: Path, settings: dict[str, Any]) -> None:
...
class TranscribeProvider(Protocol):
manifest: ProviderManifest
def transcribe(self, task: Task, source_video: Artifact, settings: dict[str, Any]) -> Artifact:
...
class SongDetector(Protocol):
manifest: ProviderManifest
def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]:
...
class PublishProvider(Protocol):
manifest: ProviderManifest
def publish(self, task: Task, clip_videos: list[Artifact], settings: dict[str, Any]) -> PublishRecord:
...
class SplitProvider(Protocol):
manifest: ProviderManifest
def split(self, task: Task, songs_json: Artifact, source_video: Artifact, settings: dict[str, Any]) -> list[Artifact]:
...

View File

@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from biliup_next.core.providers import ProviderManifest
class RegistryError(RuntimeError):
pass
class Registry:
def __init__(self) -> None:
self._providers: dict[str, dict[str, Any]] = {}
self._manifests: dict[str, list[ProviderManifest]] = {}
def register(self, provider_type: str, provider_id: str, provider: Any, manifest: ProviderManifest) -> None:
self._providers.setdefault(provider_type, {})
self._manifests.setdefault(provider_type, [])
if provider_id in self._providers[provider_type]:
raise RegistryError(f"重复注册 provider: {provider_type}.{provider_id}")
self._providers[provider_type][provider_id] = provider
self._manifests[provider_type].append(manifest)
def get(self, provider_type: str, provider_id: str) -> Any:
try:
return self._providers[provider_type][provider_id]
except KeyError as exc:
raise RegistryError(f"未注册 provider: {provider_type}.{provider_id}") from exc
def list_manifests(self) -> list[dict[str, object]]:
items: list[dict[str, object]] = []
for provider_type, manifests in sorted(self._manifests.items()):
for manifest in manifests:
payload = asdict(manifest)
payload["provider_type"] = provider_type
items.append(payload)
return items

View File

@ -0,0 +1,179 @@
from __future__ import annotations
import json
import random
import re
import subprocess
import time
from pathlib import Path
from typing import Any
import requests
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import Task
from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid
class LegacyBilibiliCollectionProvider:
manifest = ProviderManifest(
id="bilibili_collection",
name="Legacy Bilibili Collection Provider",
version="0.1.0",
provider_type="collection_provider",
entrypoint="biliup_next.infra.adapters.bilibili_collection_legacy:LegacyBilibiliCollectionProvider",
capabilities=["collection"],
enabled_by_default=True,
)
def __init__(self) -> None:
self._section_cache: dict[int, int | None] = {}
def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]:
session_dir = Path(str(settings["session_dir"])) / task.title
cookies = self._load_cookies(Path(str(settings["cookies_file"])))
csrf = cookies.get("bili_jct")
if not csrf:
raise ModuleError(code="COOKIE_CSRF_MISSING", message="Cookie 缺少 bili_jct", retryable=False)
session = requests.Session()
session.cookies.update(cookies)
session.headers.update(
{
"User-Agent": "Mozilla/5.0",
"Referer": "https://member.bilibili.com/platform/upload-manager/distribution",
}
)
if target == "a":
season_id = int(settings["season_id_a"])
bvid = resolve_full_video_bvid(task.title, session_dir, settings)
if not bvid:
(session_dir / "collection_a_done.flag").touch()
return {"status": "skipped", "reason": "full_video_bvid_not_found"}
flag_path = session_dir / "collection_a_done.flag"
else:
season_id = int(settings["season_id_b"])
bvid_path = session_dir / "bvid.txt"
if not bvid_path.exists():
raise ModuleError(code="COLLECTION_BVID_MISSING", message=f"缺少 bvid.txt: {session_dir}", retryable=True)
bvid = bvid_path.read_text(encoding="utf-8").strip()
flag_path = session_dir / "collection_b_done.flag"
if season_id <= 0:
flag_path.touch()
return {"status": "skipped", "reason": "season_disabled"}
section_id = self._resolve_section_id(session, season_id)
if not section_id:
raise ModuleError(code="COLLECTION_SECTION_NOT_FOUND", message=f"未找到合集 section: {season_id}", retryable=True)
info = self._get_video_info(session, bvid)
episodes = [info]
add_result = self._add_videos_batch(session, csrf, section_id, episodes)
if add_result["status"] == "failed":
raise ModuleError(
code="COLLECTION_ADD_FAILED",
message=add_result["message"],
retryable=True,
details=add_result,
)
flag_path.touch()
if add_result["status"] == "added":
append_key = "append_collection_a_new_to_end" if target == "a" else "append_collection_b_new_to_end"
if settings.get(append_key, True):
self._move_videos_to_section_end(session, csrf, section_id, [info["aid"]])
return {"status": add_result["status"], "target": target, "bvid": bvid, "season_id": season_id}
@staticmethod
def _load_cookies(path: Path) -> dict[str, str]:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if "cookie_info" in data:
return {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])}
return data
def _resolve_section_id(self, session: requests.Session, season_id: int) -> int | None:
if season_id in self._section_cache:
return self._section_cache[season_id]
result = session.get("https://member.bilibili.com/x2/creative/web/seasons", params={"pn": 1, "ps": 50}, timeout=15).json()
if result.get("code") != 0:
return None
for season in result.get("data", {}).get("seasons", []):
if season.get("season", {}).get("id") == season_id:
sections = season.get("sections", {}).get("sections", [])
section_id = sections[0]["id"] if sections else None
self._section_cache[season_id] = section_id
return section_id
self._section_cache[season_id] = None
return None
@staticmethod
def _get_video_info(session: requests.Session, bvid: str) -> dict[str, object]:
result = session.get("https://api.bilibili.com/x/web-interface/view", params={"bvid": bvid}, timeout=15).json()
if result.get("code") != 0:
raise ModuleError(
code="COLLECTION_VIDEO_INFO_FAILED",
message=f"获取视频信息失败: {result.get('message')}",
retryable=True,
)
data = result["data"]
return {"aid": data["aid"], "cid": data["cid"], "title": data["title"], "charging_pay": 0}
@staticmethod
def _add_videos_batch(session: requests.Session, csrf: str, section_id: int, episodes: list[dict[str, object]]) -> dict[str, object]:
time.sleep(random.uniform(5.0, 10.0))
result = session.post(
"https://member.bilibili.com/x2/creative/web/season/section/episodes/add",
params={"csrf": csrf},
json={"sectionId": section_id, "episodes": episodes},
timeout=20,
).json()
if result.get("code") == 0:
return {"status": "added"}
if result.get("code") == 20080:
return {"status": "already_exists", "message": result.get("message", "")}
return {"status": "failed", "message": result.get("message", "unknown error"), "code": result.get("code")}
@staticmethod
def _move_videos_to_section_end(session: requests.Session, csrf: str, section_id: int, added_aids: list[int]) -> bool:
detail = session.get(
"https://member.bilibili.com/x2/creative/web/season/section",
params={"id": section_id},
timeout=20,
).json()
if detail.get("code") != 0:
return False
section = detail.get("data", {}).get("section", {})
episodes = detail.get("data", {}).get("episodes", []) or []
if not episodes:
return True
target_aids = {int(aid) for aid in added_aids}
existing = []
appended = []
for episode in episodes:
item = {"id": episode.get("id")}
if item["id"] is None:
continue
if episode.get("aid") in target_aids:
appended.append(item)
else:
existing.append(item)
ordered = existing + appended
payload = {
"section": {
"id": section["id"],
"seasonId": section["seasonId"],
"title": section["title"],
"type": section["type"],
},
"sorts": [{"id": item["id"], "sort": idx + 1} for idx, item in enumerate(ordered)],
}
result = session.post(
"https://member.bilibili.com/x2/creative/web/season/section/edit",
params={"csrf": csrf},
json=payload,
timeout=20,
).json()
return result.get("code") == 0

View File

@ -0,0 +1,179 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
import requests
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import Task
from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid
class LegacyBilibiliTopCommentProvider:
manifest = ProviderManifest(
id="bilibili_top_comment",
name="Legacy Bilibili Top Comment Provider",
version="0.1.0",
provider_type="comment_provider",
entrypoint="biliup_next.infra.adapters.bilibili_top_comment_legacy:LegacyBilibiliTopCommentProvider",
capabilities=["comment"],
enabled_by_default=True,
)
def comment(self, task: Task, settings: dict[str, Any]) -> dict[str, object]:
session_dir = Path(str(settings["session_dir"])) / task.title
songs_path = session_dir / "songs.txt"
songs_json_path = session_dir / "songs.json"
bvid_path = session_dir / "bvid.txt"
if not songs_path.exists() or not bvid_path.exists():
raise ModuleError(
code="COMMENT_INPUT_MISSING",
message=f"缺少评论所需文件: {session_dir}",
retryable=True,
)
timeline_content = songs_path.read_text(encoding="utf-8").strip()
split_content = self._build_split_comment_content(songs_json_path, songs_path)
if not timeline_content and not split_content:
self._touch_comment_flags(session_dir, split_done=True, full_done=True)
return {"status": "skipped", "reason": "comment_content_empty"}
cookies = self._load_cookies(Path(str(settings["cookies_file"])))
csrf = cookies.get("bili_jct")
if not csrf:
raise ModuleError(code="COOKIE_CSRF_MISSING", message="Cookie 缺少 bili_jct", retryable=False)
session = requests.Session()
session.cookies.update(cookies)
session.headers.update(
{
"User-Agent": "Mozilla/5.0",
"Referer": "https://www.bilibili.com/",
"Origin": "https://www.bilibili.com",
}
)
split_result = {"status": "skipped", "reason": "disabled"}
full_result = {"status": "skipped", "reason": "disabled"}
split_done = (session_dir / "comment_split_done.flag").exists()
full_done = (session_dir / "comment_full_done.flag").exists()
if settings.get("post_split_comment", True) and not split_done:
split_bvid = bvid_path.read_text(encoding="utf-8").strip()
if split_content:
split_result = self._post_and_top_comment(session, csrf, split_bvid, split_content, "split")
else:
split_result = {"status": "skipped", "reason": "split_comment_empty"}
split_done = True
(session_dir / "comment_split_done.flag").touch()
elif not split_done:
split_done = True
(session_dir / "comment_split_done.flag").touch()
if settings.get("post_full_video_timeline_comment", True) and not full_done:
full_bvid = resolve_full_video_bvid(task.title, session_dir, settings)
if full_bvid and timeline_content:
full_result = self._post_and_top_comment(session, csrf, full_bvid, timeline_content, "full")
else:
full_result = {"status": "skipped", "reason": "full_video_bvid_not_found" if not full_bvid else "timeline_comment_empty"}
full_done = True
(session_dir / "comment_full_done.flag").touch()
elif not full_done:
full_done = True
(session_dir / "comment_full_done.flag").touch()
if split_done and full_done:
(session_dir / "comment_done.flag").touch()
return {"status": "ok", "split": split_result, "full": full_result}
def _post_and_top_comment(
self,
session: requests.Session,
csrf: str,
bvid: str,
content: str,
target: str,
) -> dict[str, object]:
view = session.get("https://api.bilibili.com/x/web-interface/view", params={"bvid": bvid}, timeout=15).json()
if view.get("code") != 0:
raise ModuleError(
code="COMMENT_VIEW_FAILED",
message=f"获取{target}视频信息失败: {view.get('message')}",
retryable=True,
)
aid = view["data"]["aid"]
add_res = session.post(
"https://api.bilibili.com/x/v2/reply/add",
data={"type": 1, "oid": aid, "message": content, "plat": 1, "csrf": csrf},
timeout=15,
).json()
if add_res.get("code") != 0:
raise ModuleError(
code="COMMENT_POST_FAILED",
message=f"发布{target}评论失败: {add_res.get('message')}",
retryable=True,
)
rpid = add_res["data"]["rpid"]
time.sleep(3)
top_res = session.post(
"https://api.bilibili.com/x/v2/reply/top",
data={"type": 1, "oid": aid, "rpid": rpid, "action": 1, "csrf": csrf},
timeout=15,
).json()
if top_res.get("code") != 0:
raise ModuleError(
code="COMMENT_TOP_FAILED",
message=f"置顶{target}评论失败: {top_res.get('message')}",
retryable=True,
)
return {"status": "ok", "bvid": bvid, "aid": aid, "rpid": rpid}
@staticmethod
def _build_split_comment_content(songs_json_path: Path, songs_txt_path: Path) -> str:
if songs_json_path.exists():
try:
data = json.loads(songs_json_path.read_text(encoding="utf-8"))
lines = []
for index, song in enumerate(data.get("songs", []), 1):
title = str(song.get("title", "")).strip()
artist = str(song.get("artist", "")).strip()
if not title:
continue
suffix = f"{artist}" if artist else ""
lines.append(f"{index}. {title}{suffix}")
if lines:
return "\n".join(lines)
except json.JSONDecodeError:
pass
if songs_txt_path.exists():
lines = []
for index, raw in enumerate(songs_txt_path.read_text(encoding="utf-8").splitlines(), 1):
text = raw.strip()
if not text:
continue
parts = text.split(" ", 1)
song_text = parts[1] if len(parts) == 2 and ":" in parts[0] else text
lines.append(f"{index}. {song_text}")
return "\n".join(lines)
return ""
@staticmethod
def _load_cookies(path: Path) -> dict[str, str]:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if "cookie_info" in data:
return {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])}
return data
@staticmethod
def _touch_comment_flags(session_dir: Path, *, split_done: bool, full_done: bool) -> None:
if split_done:
(session_dir / "comment_split_done.flag").touch()
if full_done:
(session_dir / "comment_full_done.flag").touch()
if split_done and full_done:
(session_dir / "comment_done.flag").touch()

View File

@ -0,0 +1,176 @@
from __future__ import annotations
import json
import random
import re
import subprocess
from pathlib import Path
from typing import Any
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import PublishRecord, Task, utc_now_iso
from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.legacy_paths import legacy_project_root
class LegacyBiliupPublishProvider:
manifest = ProviderManifest(
id="biliup_cli",
name="Legacy biliup CLI Publish Provider",
version="0.1.0",
provider_type="publish_provider",
entrypoint="biliup_next.infra.adapters.biliup_publish_legacy:LegacyBiliupPublishProvider",
capabilities=["publish"],
enabled_by_default=True,
)
def __init__(self, next_root: Path):
self.next_root = next_root
self.legacy_root = legacy_project_root(next_root)
def publish(self, task: Task, clip_videos: list, settings: dict[str, Any]) -> PublishRecord:
work_dir = Path(str(settings.get("session_dir", str(self.legacy_root / "session")))) / task.title
bvid_file = work_dir / "bvid.txt"
upload_done = work_dir / "upload_done.flag"
config = self._load_upload_config(Path(str(settings.get("upload_config_file", str(self.legacy_root / "upload_config.json")))))
if bvid_file.exists():
bvid = bvid_file.read_text(encoding="utf-8").strip()
return PublishRecord(
id=None,
task_id=task.id,
platform="bilibili",
aid=None,
bvid=bvid,
title=task.title,
published_at=utc_now_iso(),
)
video_files = [artifact.path for artifact in clip_videos]
if not video_files:
raise ModuleError(
code="PUBLISH_NO_CLIPS",
message=f"没有可上传的切片: {task.id}",
retryable=False,
)
parsed = self._parse_filename(task.title, config)
streamer = parsed.get("streamer", task.title)
date = parsed.get("date", "")
songs_txt = work_dir / "songs.txt"
songs_list = songs_txt.read_text(encoding="utf-8").strip() if songs_txt.exists() else ""
songs_json = work_dir / "songs.json"
song_count = 0
if songs_json.exists():
song_count = len(json.loads(songs_json.read_text(encoding="utf-8")).get("songs", []))
quote = self._get_random_quote(config)
template_vars = {
"streamer": streamer,
"date": date,
"song_count": song_count,
"songs_list": songs_list,
"daily_quote": quote.get("text", ""),
"quote_author": quote.get("author", ""),
}
template = config.get("template", {})
title = template.get("title", "{streamer}_{date}").format(**template_vars)
description = template.get("description", "{songs_list}").format(**template_vars)
dynamic = template.get("dynamic", "").format(**template_vars)
tags = template.get("tag", "翻唱,唱歌,音乐").format(**template_vars)
streamer_cfg = config.get("streamers", {})
if streamer in streamer_cfg:
tags = streamer_cfg[streamer].get("tags", tags)
upload_settings = config.get("upload_settings", {})
tid = upload_settings.get("tid", 31)
biliup_path = str(settings.get("biliup_path", str(self.legacy_root / "biliup")))
cookie_file = str(settings.get("cookie_file", str(self.legacy_root / "cookies.json")))
subprocess.run([biliup_path, "-u", cookie_file, "renew"], capture_output=True, text=True)
first_batch = video_files[:5]
remaining_batches = [video_files[i:i + 5] for i in range(5, len(video_files), 5)]
upload_cmd = [
biliup_path, "-u", cookie_file, "upload",
*first_batch,
"--title", title,
"--tid", str(tid),
"--tag", tags,
"--copyright", str(upload_settings.get("copyright", 2)),
"--source", upload_settings.get("source", "直播回放"),
"--desc", description,
]
if dynamic:
upload_cmd.extend(["--dynamic", dynamic])
bvid = self._run_upload(upload_cmd, "首批上传")
bvid_file.write_text(bvid, encoding="utf-8")
for idx, batch in enumerate(remaining_batches, 2):
append_cmd = [biliup_path, "-u", cookie_file, "append", "--vid", bvid, *batch]
self._run_append(append_cmd, f"追加第 {idx}")
upload_done.touch()
return PublishRecord(
id=None,
task_id=task.id,
platform="bilibili",
aid=None,
bvid=bvid,
title=title,
published_at=utc_now_iso(),
)
def _run_upload(self, cmd: list[str], label: str) -> str:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
match = re.search(r'"bvid":"(BV[A-Za-z0-9]+)"', result.stdout) or re.search(r'(BV[A-Za-z0-9]+)', result.stdout)
if match:
return match.group(1)
raise ModuleError(
code="PUBLISH_UPLOAD_FAILED",
message=f"{label}失败",
retryable=True,
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
)
def _run_append(self, cmd: list[str], label: str) -> None:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return
raise ModuleError(
code="PUBLISH_APPEND_FAILED",
message=f"{label}失败",
retryable=True,
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
)
def _load_upload_config(self, path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def _parse_filename(self, filename: str, config: dict[str, Any] | None = None) -> dict[str, str]:
config = config or {}
patterns = config.get("filename_patterns", {}).get("patterns", [])
for pattern_config in patterns:
regex = pattern_config.get("regex")
if not regex:
continue
match = re.match(regex, filename)
if match:
data = match.groupdict()
date_format = pattern_config.get("date_format", "{date}")
try:
data["date"] = date_format.format(**data)
except KeyError:
pass
return data
return {"streamer": filename, "date": ""}
def _get_random_quote(self, config: dict[str, Any]) -> dict[str, str]:
quotes = config.get("quotes", [])
if not quotes:
return {"text": "", "author": ""}
return random.choice(quotes)

View File

@ -0,0 +1,140 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Any
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import Artifact, Task, utc_now_iso
from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.legacy_paths import legacy_project_root
SONG_SCHEMA = {
"type": "object",
"properties": {
"songs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"start": {"type": "string"},
"end": {"type": "string"},
"title": {"type": "string"},
"artist": {"type": "string"},
"confidence": {"type": "number"},
"evidence": {"type": "string"}
},
"required": ["start", "end", "title", "artist", "confidence", "evidence"],
"additionalProperties": False
}
}
},
"required": ["songs"],
"additionalProperties": False
}
TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件。
任务:
1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。
2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则:
- 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。
- 这样可以尽量保留歌曲可能存在的前奏。
3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌.
4. 忽略纯聊天片段。
5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。
6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。
7. 仔细分析每一句歌词,识别出相关歌曲后, 使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间
8.歌曲标注规则:
- 可以在歌曲名称后使用括号 () 添加补充说明。
- 常见标注示例:
- (片段):歌曲演唱时间较短,例如 < 60 秒
- (清唱):无伴奏演唱
- (副歌):只演唱副歌部分
- 标注应简洁,仅在确有必要时使用。
9. 通过歌曲起始和结束时间自检, 一般歌曲长度在5分钟以内, 1分钟以上, 可疑片段重新联网搜索检查.
最后请严格按照 Schema 生成 JSON 数据。"""
class LegacyCodexSongDetector:
manifest = ProviderManifest(
id="codex",
name="Legacy Codex Song Detector",
version="0.1.0",
provider_type="song_detector",
entrypoint="biliup_next.infra.adapters.codex_legacy:LegacyCodexSongDetector",
capabilities=["song_detect"],
enabled_by_default=True,
)
def __init__(self, next_root: Path):
self.next_root = next_root
self.legacy_root = legacy_project_root(next_root)
def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]:
work_dir = Path(subtitle_srt.path).parent
schema_path = work_dir / "song_schema.json"
schema_path.write_text(json.dumps(SONG_SCHEMA, ensure_ascii=False, indent=2), encoding="utf-8")
env = {
**os.environ,
"CODEX_CMD": str(settings.get("codex_cmd", "codex")),
}
cmd = [
str(settings.get("codex_cmd", "codex")),
"exec",
TASK_PROMPT.replace("\n", " "),
"--full-auto",
"--sandbox", "workspace-write",
"--output-schema", "./song_schema.json",
"-o", "songs.json",
"--skip-git-repo-check",
"--json",
]
result = subprocess.run(
cmd,
cwd=str(work_dir),
capture_output=True,
text=True,
env=env,
)
if result.returncode != 0:
raise ModuleError(
code="SONG_DETECT_FAILED",
message="codex exec 执行失败",
retryable=True,
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
)
songs_json = work_dir / "songs.json"
songs_txt = work_dir / "songs.txt"
if songs_json.exists() and not songs_txt.exists():
data = json.loads(songs_json.read_text(encoding="utf-8"))
with songs_txt.open("w", encoding="utf-8") as f:
for song in data.get("songs", []):
start_time = song["start"].split(",")[0].split(".")[0]
f.write(f"{start_time} {song['title']}{song['artist']}\n")
if not songs_json.exists() or not songs_txt.exists():
raise ModuleError(
code="SONG_DETECT_OUTPUT_MISSING",
message=f"未生成 songs.json/songs.txt: {work_dir}",
retryable=True,
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
)
return (
Artifact(
id=None,
task_id=task.id,
artifact_type="songs_json",
path=str(songs_json),
metadata_json=json.dumps({"provider": "codex_legacy"}),
created_at=utc_now_iso(),
),
Artifact(
id=None,
task_id=task.id,
artifact_type="songs_txt",
path=str(songs_txt),
metadata_json=json.dumps({"provider": "codex_legacy"}),
created_at=utc_now_iso(),
),
)

View File

@ -0,0 +1,92 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import Artifact, Task, utc_now_iso
from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.legacy_paths import legacy_project_root
class LegacyFfmpegSplitProvider:
manifest = ProviderManifest(
id="ffmpeg_copy",
name="Legacy FFmpeg Split Provider",
version="0.1.0",
provider_type="split_provider",
entrypoint="biliup_next.infra.adapters.ffmpeg_split_legacy:LegacyFfmpegSplitProvider",
capabilities=["split"],
enabled_by_default=True,
)
def __init__(self, next_root: Path):
self.next_root = next_root
self.legacy_root = legacy_project_root(next_root)
def split(self, task: Task, songs_json: Artifact, source_video: Artifact, settings: dict[str, Any]) -> list[Artifact]:
work_dir = Path(songs_json.path).parent
split_dir = work_dir / "split_video"
split_done = work_dir / "split_done.flag"
if split_done.exists() and split_dir.exists():
return self._collect_existing_clips(task.id, split_dir)
with Path(songs_json.path).open("r", encoding="utf-8") as f:
data = json.load(f)
songs = data.get("songs", [])
if not songs:
raise ModuleError(
code="SPLIT_SONGS_EMPTY",
message=f"songs.json 中没有歌曲: {songs_json.path}",
retryable=False,
)
split_dir.mkdir(parents=True, exist_ok=True)
ffmpeg_bin = str(settings.get("ffmpeg_bin", "ffmpeg"))
video_path = Path(source_video.path)
for idx, song in enumerate(songs, 1):
start = str(song.get("start", "00:00:00,000")).replace(",", ".")
end = str(song.get("end", "00:00:00,000")).replace(",", ".")
title = str(song.get("title", "UNKNOWN")).replace("/", "_").replace("\\", "_")
output_path = split_dir / f"{idx:02d}_{title}{video_path.suffix}"
if output_path.exists():
continue
cmd = [
ffmpeg_bin,
"-y",
"-ss", start,
"-to", end,
"-i", str(video_path),
"-c", "copy",
"-map_metadata", "0",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise ModuleError(
code="SPLIT_FFMPEG_FAILED",
message=f"ffmpeg 切割失败: {output_path.name}",
retryable=True,
details={"stderr": result.stderr[-2000:]},
)
split_done.touch()
return self._collect_existing_clips(task.id, split_dir)
def _collect_existing_clips(self, task_id: str, split_dir: Path) -> list[Artifact]:
artifacts: list[Artifact] = []
for path in sorted(split_dir.iterdir()):
if path.is_file():
artifacts.append(
Artifact(
id=None,
task_id=task_id,
artifact_type="clip_video",
path=str(path),
metadata_json=json.dumps({"provider": "ffmpeg_copy"}),
created_at=utc_now_iso(),
)
)
return artifacts

View File

@ -0,0 +1,68 @@
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Any
from biliup_next.core.errors import ModuleError
def normalize_title(text: str) -> str:
return re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text).lower()
def fetch_biliup_list(settings: dict[str, Any], *, max_pages: int = 5) -> list[dict[str, str]]:
cmd = [
str(settings["biliup_path"]),
"-u",
str(settings["cookie_file"]),
"list",
"--max-pages",
str(max_pages),
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
except FileNotFoundError as exc:
raise ModuleError(code="BILIUP_NOT_FOUND", message=f"找不到 biliup: {settings['biliup_path']}", retryable=False) from exc
if result.returncode != 0:
raise ModuleError(
code="BILIUP_LIST_FAILED",
message="biliup list 执行失败",
retryable=True,
details={"stderr": (result.stderr or "")[-1000:]},
)
videos: list[dict[str, str]] = []
for line in result.stdout.splitlines():
if not line.startswith("BV"):
continue
parts = line.split("\t")
if len(parts) >= 3 and "开放浏览" not in parts[2]:
continue
if len(parts) >= 2:
videos.append({"bvid": parts[0].strip(), "title": parts[1].strip()})
return videos
def resolve_full_video_bvid(title: str, session_dir: Path, settings: dict[str, Any]) -> str | None:
bvid_file = session_dir / "full_video_bvid.txt"
if bvid_file.exists():
value = bvid_file.read_text(encoding="utf-8").strip()
if value.startswith("BV"):
return value
videos = fetch_biliup_list(settings)
normalized_title = normalize_title(title)
for video in videos:
if normalize_title(video["title"]) == normalized_title:
bvid_file.write_text(video["bvid"], encoding="utf-8")
return video["bvid"]
if settings.get("allow_fuzzy_full_video_match", False):
for video in videos:
normalized_video_title = normalize_title(video["title"])
if normalized_title in normalized_video_title or normalized_video_title in normalized_title:
bvid_file.write_text(video["bvid"], encoding="utf-8")
return video["bvid"]
return None

View File

@ -0,0 +1,79 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Any
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import Artifact, Task, utc_now_iso
from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.legacy_paths import legacy_project_root
class LegacyGroqTranscribeProvider:
manifest = ProviderManifest(
id="groq",
name="Legacy Groq Transcribe Provider",
version="0.1.0",
provider_type="transcribe_provider",
entrypoint="biliup_next.infra.adapters.groq_legacy:LegacyGroqTranscribeProvider",
capabilities=["transcribe"],
enabled_by_default=True,
)
def __init__(self, next_root: Path):
self.next_root = next_root
self.legacy_root = legacy_project_root(next_root)
self.python_bin = self._resolve_python_bin()
def transcribe(self, task: Task, source_video: Artifact, settings: dict[str, Any]) -> Artifact:
session_dir = Path(str(settings.get("session_dir", str(self.legacy_root / "session"))))
work_dir = (session_dir / task.title).resolve()
cmd = [
self.python_bin,
"video2srt.py",
source_video.path,
str(work_dir),
]
env = {
**os.environ,
"GROQ_API_KEY": str(settings.get("groq_api_key", "")),
"FFMPEG_BIN": str(settings.get("ffmpeg_bin", "ffmpeg")),
}
result = subprocess.run(
cmd,
cwd=str(self.legacy_root),
capture_output=True,
text=True,
env=env,
)
if result.returncode != 0:
raise ModuleError(
code="TRANSCRIBE_FAILED",
message="legacy video2srt.py 执行失败",
retryable=True,
details={"stderr": result.stderr[-2000:], "stdout": result.stdout[-2000:]},
)
srt_path = work_dir / f"{task.title}.srt"
if not srt_path.exists():
raise ModuleError(
code="TRANSCRIBE_OUTPUT_MISSING",
message=f"未找到字幕文件: {srt_path}",
retryable=False,
)
return Artifact(
id=None,
task_id=task.id,
artifact_type="subtitle_srt",
path=str(srt_path),
metadata_json=json.dumps({"provider": "groq_legacy"}),
created_at=utc_now_iso(),
)
def _resolve_python_bin(self) -> str:
venv_python = self.legacy_root / ".venv" / "bin" / "python"
if venv_python.exists():
return str(venv_python)
return "python"

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from pathlib import Path
class CommentFlagMigrationService:
def migrate(self, session_dir: Path) -> dict[str, int]:
migrated_split_flags = 0
legacy_untracked_full = 0
if not session_dir.exists():
return {"migrated_split_flags": 0, "legacy_untracked_full": 0}
for folder in sorted(p for p in session_dir.iterdir() if p.is_dir()):
comment_done = folder / "comment_done.flag"
split_done = folder / "comment_split_done.flag"
full_done = folder / "comment_full_done.flag"
if not comment_done.exists():
continue
if not split_done.exists():
split_done.touch()
migrated_split_flags += 1
if not full_done.exists():
legacy_untracked_full += 1
return {
"migrated_split_flags": migrated_split_flags,
"legacy_untracked_full": legacy_untracked_full,
}

View File

@ -0,0 +1,78 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
source_path TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS task_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
step_name TEXT NOT NULL,
status TEXT NOT NULL,
error_code TEXT,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
started_at TEXT,
finished_at TEXT,
FOREIGN KEY(task_id) REFERENCES tasks(id)
);
CREATE TABLE IF NOT EXISTS artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
artifact_type TEXT NOT NULL,
path TEXT NOT NULL,
metadata_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES tasks(id)
);
CREATE TABLE IF NOT EXISTS publish_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
platform TEXT NOT NULL,
aid TEXT,
bvid TEXT,
title TEXT NOT NULL,
published_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES tasks(id)
);
CREATE TABLE IF NOT EXISTS action_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT,
action_name TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES tasks(id)
);
"""
class Database:
def __init__(self, db_path: Path):
self.db_path = db_path
def connect(self) -> sqlite3.Connection:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def initialize(self) -> None:
with self.connect() as conn:
conn.executescript(SCHEMA_SQL)
conn.commit()

View File

@ -0,0 +1,68 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
from biliup_next.core.config import SettingsService
class LegacyAssetSync:
def __init__(self, root_dir: Path):
self.root_dir = root_dir
self.runtime_dir = self.root_dir / "runtime"
self.settings_service = SettingsService(root_dir)
def sync(self) -> dict[str, Any]:
self.runtime_dir.mkdir(parents=True, exist_ok=True)
bundle = self.settings_service.load()
settings = json.loads(json.dumps(bundle.settings))
copied: list[dict[str, str]] = []
missing: list[str] = []
copied_pairs: set[tuple[str, str]] = set()
mapping = [
("paths", "cookies_file", "runtime/cookies.json"),
("paths", "upload_config_file", "runtime/upload_config.json"),
("publish", "cookie_file", "runtime/cookies.json"),
]
for group, field, target_rel in mapping:
current = Path(str(settings[group][field]))
current_abs = current if current.is_absolute() else (self.root_dir / current).resolve()
target_abs = (self.root_dir / target_rel).resolve()
if current_abs == target_abs and target_abs.exists():
continue
if current_abs.exists():
shutil.copy2(current_abs, target_abs)
settings[group][field] = target_rel
pair = (str(current_abs), str(target_abs))
if pair not in copied_pairs:
copied_pairs.add(pair)
copied.append({"from": pair[0], "to": pair[1]})
else:
missing.append(f"{group}.{field}:{current_abs}")
publish_path = Path(str(settings["publish"]["biliup_path"]))
publish_abs = publish_path if publish_path.is_absolute() else (self.root_dir / publish_path).resolve()
local_biliup = self.root_dir / "runtime" / "biliup"
if publish_abs.exists() and publish_abs != local_biliup:
shutil.copy2(publish_abs, local_biliup)
local_biliup.chmod(0o755)
settings["publish"]["biliup_path"] = "runtime/biliup"
pair = (str(publish_abs), str(local_biliup))
if pair not in copied_pairs:
copied_pairs.add(pair)
copied.append({"from": pair[0], "to": pair[1]})
self.settings_service.save_staged(settings)
self.settings_service.promote_staged()
return {
"ok": True,
"runtime_dir": str(self.runtime_dir),
"copied": copied,
"missing": missing,
}

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from pathlib import Path
def legacy_project_root(next_root: Path) -> Path:
return next_root.parent

View File

@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
ALLOWED_LOG_FILES = {
"monitor.log": Path("/home/theshy/biliup/logs/system/monitor.log"),
"monitorSrt.log": Path("/home/theshy/biliup/logs/system/monitorSrt.log"),
"monitorSongs.log": Path("/home/theshy/biliup/logs/system/monitorSongs.log"),
"upload.log": Path("/home/theshy/biliup/logs/system/upload.log"),
"session_top_comment.py.log": Path("/home/theshy/biliup/logs/system/session_top_comment.py.log"),
"add_to_collection.py.log": Path("/home/theshy/biliup/logs/system/add_to_collection.py.log"),
}
class LogReader:
def list_logs(self) -> dict[str, object]:
return {
"items": [
{
"name": name,
"path": str(path),
"exists": path.exists(),
}
for name, path in sorted(ALLOWED_LOG_FILES.items())
]
}
def tail(self, name: str, lines: int = 200, contains: str | None = None) -> dict[str, object]:
if name not in ALLOWED_LOG_FILES:
raise ValueError(f"unsupported log: {name}")
path = ALLOWED_LOG_FILES[name]
if not path.exists():
return {"name": name, "path": str(path), "exists": False, "content": ""}
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
if contains:
content = [line for line in content if contains in line]
return {
"name": name,
"path": str(path),
"exists": True,
"content": "\n".join(content[-max(1, min(lines, 1000)):]),
}

View File

@ -0,0 +1,61 @@
from __future__ import annotations
import importlib
import inspect
import json
from pathlib import Path
from typing import Any
from biliup_next.core.providers import ProviderManifest
class PluginLoader:
def __init__(self, root_dir: Path):
self.root_dir = root_dir
self.manifests_dir = self.root_dir / "src" / "biliup_next" / "plugins" / "manifests"
def load_manifests(self) -> list[ProviderManifest]:
manifests: list[ProviderManifest] = []
if not self.manifests_dir.exists():
return manifests
for path in sorted(self.manifests_dir.glob("*.json")):
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
manifests.append(
ProviderManifest(
id=data["id"],
name=data["name"],
version=data["version"],
provider_type=data["provider_type"],
entrypoint=data["entrypoint"],
capabilities=data["capabilities"],
config_schema=data.get("config_schema"),
enabled_by_default=data.get("enabled_by_default", True),
)
)
return manifests
def instantiate_provider(self, manifest: ProviderManifest) -> Any:
module_name, _, attr_name = manifest.entrypoint.partition(":")
if not module_name or not attr_name:
raise ValueError(f"invalid provider entrypoint: {manifest.entrypoint}")
module = importlib.import_module(module_name)
provider_cls = getattr(module, attr_name)
kwargs = self._build_constructor_kwargs(provider_cls)
return provider_cls(**kwargs)
def _build_constructor_kwargs(self, provider_cls: type[Any]) -> dict[str, Any]:
signature = inspect.signature(provider_cls)
kwargs: dict[str, Any] = {}
for name, parameter in signature.parameters.items():
if name == "self":
continue
if parameter.kind in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}:
continue
if name in {"root", "next_root", "root_dir"}:
kwargs[name] = self.root_dir
continue
if parameter.default is not inspect._empty:
continue
raise ValueError(f"unsupported provider constructor parameter: {provider_cls.__name__}.{name}")
return kwargs

View File

@ -0,0 +1,54 @@
from __future__ import annotations
import shutil
from pathlib import Path
from biliup_next.core.config import SettingsService
class RuntimeDoctor:
def __init__(self, root_dir: Path):
self.root_dir = root_dir
self.settings_service = SettingsService(root_dir)
def run(self) -> dict[str, object]:
bundle = self.settings_service.load()
settings = bundle.settings
checks: list[dict[str, object]] = []
for group, name in (
("paths", "stage_dir"),
("paths", "backup_dir"),
("paths", "session_dir"),
):
path = (self.root_dir / settings[group][name]).resolve()
checks.append({"name": f"{group}.{name}", "ok": path.exists(), "detail": str(path)})
for group, name in (
("paths", "cookies_file"),
("paths", "upload_config_file"),
):
path = (self.root_dir / settings[group][name]).resolve()
detail = str(path)
if path.exists() and not str(path).startswith(str(self.root_dir)):
detail = f"{path} (external)"
checks.append({"name": f"{group}.{name}", "ok": path.exists(), "detail": detail})
for group, name in (
("ingest", "ffprobe_bin"),
("transcribe", "ffmpeg_bin"),
("song_detect", "codex_cmd"),
("publish", "biliup_path"),
):
value = settings[group][name]
found = shutil.which(value) if "/" not in value else str((self.root_dir / value).resolve())
ok = bool(found) and (Path(found).exists() if "/" in str(found) else True)
detail = str(found or value)
if ok and "/" in detail and not detail.startswith(str(self.root_dir)):
detail = f"{detail} (external)"
checks.append({"name": f"{group}.{name}", "ok": ok, "detail": detail})
return {
"ok": all(item["ok"] for item in checks),
"checks": checks,
}

View File

@ -0,0 +1,93 @@
from __future__ import annotations
import shutil
import uuid
from pathlib import Path
from typing import BinaryIO
from biliup_next.core.errors import ModuleError
from biliup_next.infra.storage_guard import ensure_free_space
class StageImporter:
def import_file(self, source_path: Path, stage_dir: Path, *, min_free_bytes: int = 0) -> dict[str, object]:
if not source_path.exists():
raise FileNotFoundError(f"source not found: {source_path}")
if not source_path.is_file():
raise IsADirectoryError(f"source is not a file: {source_path}")
stage_dir.mkdir(parents=True, exist_ok=True)
source_size = source_path.stat().st_size
ensure_free_space(
stage_dir,
source_size + max(0, int(min_free_bytes)),
code="STAGE_IMPORT_NO_SPACE",
message=f"stage 剩余空间不足,无法导入: {source_path.name}",
retryable=False,
details={"source_size_bytes": source_size},
)
target_path = stage_dir / source_path.name
if target_path.exists():
target_path = self._unique_target(stage_dir, source_path.name)
temp_path = self._temp_target(stage_dir, target_path.name)
try:
shutil.copyfile(source_path, temp_path)
shutil.copymode(source_path, temp_path)
temp_path.replace(target_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
return {
"source_path": str(source_path.resolve()),
"target_path": str(target_path.resolve()),
}
def import_upload(self, filename: str, fileobj: BinaryIO, stage_dir: Path, *, min_free_bytes: int = 0) -> dict[str, object]:
if not filename:
raise ValueError("missing filename")
stage_dir.mkdir(parents=True, exist_ok=True)
ensure_free_space(
stage_dir,
max(0, int(min_free_bytes)),
code="STAGE_UPLOAD_NO_SPACE",
message=f"stage 剩余空间不足,无法接收上传: {Path(filename).name}",
retryable=False,
)
target_path = stage_dir / Path(filename).name
if target_path.exists():
target_path = self._unique_target(stage_dir, Path(filename).name)
temp_path = self._temp_target(stage_dir, target_path.name)
try:
with temp_path.open("wb") as f:
shutil.copyfileobj(fileobj, f)
temp_path.replace(target_path)
except OSError as exc:
temp_path.unlink(missing_ok=True)
if getattr(exc, "errno", None) == 28:
raise ModuleError(
code="STAGE_UPLOAD_NO_SPACE",
message=f"stage 剩余空间不足,上传中断: {Path(filename).name}",
retryable=False,
) from exc
raise
except Exception:
temp_path.unlink(missing_ok=True)
raise
return {
"uploaded_filename": Path(filename).name,
"target_path": str(target_path.resolve()),
}
@staticmethod
def _unique_target(stage_dir: Path, filename: str) -> Path:
base = Path(filename).stem
suffix = Path(filename).suffix
index = 1
while True:
candidate = stage_dir / f"{base}.{index}{suffix}"
if not candidate.exists():
return candidate
index += 1
@staticmethod
def _temp_target(stage_dir: Path, filename: str) -> Path:
return stage_dir / f".{filename}.{uuid.uuid4().hex}.part"

View File

@ -0,0 +1,41 @@
from __future__ import annotations
import shutil
from pathlib import Path
from biliup_next.core.errors import ModuleError
def mb_to_bytes(value: object) -> int:
try:
number = int(value or 0)
except (TypeError, ValueError):
number = 0
return max(0, number) * 1024 * 1024
def free_bytes_for_path(path: Path) -> int:
target = path if path.exists() else path.parent
return int(shutil.disk_usage(target).free)
def ensure_free_space(
path: Path,
required_free_bytes: int,
*,
code: str,
message: str,
retryable: bool,
details: dict[str, object] | None = None,
) -> None:
free_bytes = free_bytes_for_path(path)
if free_bytes >= required_free_bytes:
return
payload = {
"path": str(path),
"required_free_bytes": int(required_free_bytes),
"available_free_bytes": int(free_bytes),
}
if details:
payload.update(details)
raise ModuleError(code=code, message=message, retryable=retryable, details=payload)

View File

@ -0,0 +1,83 @@
from __future__ import annotations
import subprocess
ALLOWED_SERVICES = {
"biliup-next-worker.service",
"biliup-next-api.service",
"biliup-python.service",
}
ALLOWED_ACTIONS = {"start", "stop", "restart"}
class SystemdRuntime:
def list_services(self) -> dict[str, object]:
items = []
for service in sorted(ALLOWED_SERVICES):
items.append(self._inspect_service(service))
return {"items": items}
def act(self, service: str, action: str) -> dict[str, object]:
if service not in ALLOWED_SERVICES:
raise ValueError(f"unsupported service: {service}")
if action not in ALLOWED_ACTIONS:
raise ValueError(f"unsupported action: {action}")
result = subprocess.run(
["sudo", "systemctl", action, service],
capture_output=True,
text=True,
check=False,
)
payload = self._inspect_service(service)
payload["action"] = action
payload["command_ok"] = result.returncode == 0
payload["stderr"] = (result.stderr or "").strip()
payload["stdout"] = (result.stdout or "").strip()
return payload
def _inspect_service(self, service: str) -> dict[str, object]:
show = subprocess.run(
[
"systemctl",
"show",
service,
"--property=Id,Description,LoadState,ActiveState,SubState,MainPID,ExecMainStatus,FragmentPath",
],
capture_output=True,
text=True,
check=False,
)
info = {
"id": service,
"description": "",
"load_state": "unknown",
"active_state": "unknown",
"sub_state": "unknown",
"main_pid": 0,
"exec_main_status": None,
"fragment_path": "",
}
for line in (show.stdout or "").splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
if key == "Id":
info["id"] = value
elif key == "Description":
info["description"] = value
elif key == "LoadState":
info["load_state"] = value
elif key == "ActiveState":
info["active_state"] = value
elif key == "SubState":
info["sub_state"] = value
elif key == "MainPID":
try:
info["main_pid"] = int(value)
except ValueError:
info["main_pid"] = 0
elif key == "ExecMainStatus":
info["exec_main_status"] = value
elif key == "FragmentPath":
info["fragment_path"] = value
return info

View File

@ -0,0 +1,458 @@
from __future__ import annotations
import json
from pathlib import Path
from datetime import datetime, timezone
from biliup_next.core.models import ActionRecord, Artifact, PublishRecord, Task, TaskStep
from biliup_next.infra.db import Database
TASK_STATUS_ORDER = {
"created": 0,
"transcribed": 1,
"songs_detected": 2,
"split_done": 3,
"published": 4,
"commented": 5,
"collection_synced": 6,
"failed_retryable": 7,
"failed_manual": 8,
}
class TaskRepository:
def __init__(self, db: Database):
self.db = db
def upsert_task(self, task: Task) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO tasks (id, source_type, source_path, title, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
source_type=excluded.source_type,
source_path=excluded.source_path,
title=excluded.title,
status=excluded.status,
updated_at=excluded.updated_at
""",
(
task.id,
task.source_type,
task.source_path,
task.title,
task.status,
task.created_at,
task.updated_at,
),
)
conn.commit()
def update_task_status(self, task_id: str, status: str, updated_at: str) -> None:
with self.db.connect() as conn:
conn.execute(
"UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?",
(status, updated_at, task_id),
)
conn.commit()
def list_tasks(self, limit: int = 100) -> list[Task]:
with self.db.connect() as conn:
rows = conn.execute(
"SELECT id, source_type, source_path, title, status, created_at, updated_at "
"FROM tasks ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
return [Task(**dict(row)) for row in rows]
def get_task(self, task_id: str) -> Task | None:
with self.db.connect() as conn:
row = conn.execute(
"SELECT id, source_type, source_path, title, status, created_at, updated_at "
"FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
return Task(**dict(row)) if row else None
def delete_task(self, task_id: str) -> None:
with self.db.connect() as conn:
conn.execute("DELETE FROM action_records WHERE task_id = ?", (task_id,))
conn.execute("DELETE FROM publish_records WHERE task_id = ?", (task_id,))
conn.execute("DELETE FROM artifacts WHERE task_id = ?", (task_id,))
conn.execute("DELETE FROM task_steps WHERE task_id = ?", (task_id,))
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
conn.commit()
def replace_steps(self, task_id: str, steps: list[TaskStep]) -> None:
with self.db.connect() as conn:
conn.execute("DELETE FROM task_steps WHERE task_id = ?", (task_id,))
conn.executemany(
"""
INSERT INTO task_steps (
task_id, step_name, status, error_code, error_message,
retry_count, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
step.task_id,
step.step_name,
step.status,
step.error_code,
step.error_message,
step.retry_count,
step.started_at,
step.finished_at,
)
for step in steps
],
)
conn.commit()
def list_steps(self, task_id: str) -> list[TaskStep]:
with self.db.connect() as conn:
rows = conn.execute(
"""
SELECT id, task_id, step_name, status, error_code, error_message,
retry_count, started_at, finished_at
FROM task_steps
WHERE task_id = ?
ORDER BY id ASC
""",
(task_id,),
).fetchall()
return [TaskStep(**dict(row)) for row in rows]
def update_step_status(
self,
task_id: str,
step_name: str,
status: str,
*,
error_code: str | None = None,
error_message: str | None = None,
retry_count: int | None = None,
started_at: str | None = None,
finished_at: str | None = None,
) -> None:
with self.db.connect() as conn:
current = conn.execute(
"""
SELECT retry_count, started_at, finished_at
FROM task_steps
WHERE task_id = ? AND step_name = ?
""",
(task_id, step_name),
).fetchone()
if current is None:
raise RuntimeError(f"step not found: {task_id}.{step_name}")
conn.execute(
"""
UPDATE task_steps
SET status = ?,
error_code = ?,
error_message = ?,
retry_count = ?,
started_at = ?,
finished_at = ?
WHERE task_id = ? AND step_name = ?
""",
(
status,
error_code,
error_message,
retry_count if retry_count is not None else current["retry_count"],
started_at if started_at is not None else current["started_at"],
finished_at if finished_at is not None else current["finished_at"],
task_id,
step_name,
),
)
conn.commit()
def add_artifact(self, artifact: Artifact) -> None:
with self.db.connect() as conn:
existing = conn.execute(
"""
SELECT 1
FROM artifacts
WHERE task_id = ? AND artifact_type = ? AND path = ?
LIMIT 1
""",
(artifact.task_id, artifact.artifact_type, artifact.path),
).fetchone()
if existing:
return
conn.execute(
"""
INSERT INTO artifacts (task_id, artifact_type, path, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
artifact.task_id,
artifact.artifact_type,
artifact.path,
artifact.metadata_json,
artifact.created_at,
),
)
conn.commit()
def list_artifacts(self, task_id: str) -> list[Artifact]:
with self.db.connect() as conn:
rows = conn.execute(
"""
SELECT id, task_id, artifact_type, path, metadata_json, created_at
FROM artifacts
WHERE task_id = ?
ORDER BY id ASC
""",
(task_id,),
).fetchall()
return [Artifact(**dict(row)) for row in rows]
def delete_artifacts(self, task_id: str, artifact_type: str) -> None:
with self.db.connect() as conn:
conn.execute(
"DELETE FROM artifacts WHERE task_id = ? AND artifact_type = ?",
(task_id, artifact_type),
)
conn.commit()
def delete_artifact_by_path(self, task_id: str, path: str) -> None:
with self.db.connect() as conn:
conn.execute(
"DELETE FROM artifacts WHERE task_id = ? AND path = ?",
(task_id, path),
)
conn.commit()
def add_publish_record(self, record: PublishRecord) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO publish_records (task_id, platform, aid, bvid, title, published_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
record.task_id,
record.platform,
record.aid,
record.bvid,
record.title,
record.published_at,
),
)
conn.commit()
def add_action_record(self, record: ActionRecord) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO action_records (task_id, action_name, status, summary, details_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
record.task_id,
record.action_name,
record.status,
record.summary,
record.details_json,
record.created_at,
),
)
conn.commit()
def list_action_records(
self,
task_id: str | None = None,
limit: int = 100,
*,
action_name: str | None = None,
status: str | None = None,
) -> list[ActionRecord]:
with self.db.connect() as conn:
conditions: list[str] = []
params: list[object] = []
if task_id is not None:
conditions.append("task_id = ?")
params.append(task_id)
if action_name:
conditions.append("action_name = ?")
params.append(action_name)
if status:
conditions.append("status = ?")
params.append(status)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
rows = conn.execute(
f"""
SELECT id, task_id, action_name, status, summary, details_json, created_at
FROM action_records
{where}
ORDER BY id DESC
LIMIT ?
""",
(*params, limit),
).fetchall()
return [ActionRecord(**dict(row)) for row in rows]
def bootstrap_from_legacy_sessions(self, session_dir: Path) -> int:
synced = 0
if not session_dir.exists():
return synced
for folder in sorted(p for p in session_dir.iterdir() if p.is_dir()):
task_id = folder.name
existing_task = self.get_task(task_id)
derived_status = "created"
if (folder / "transcribe_done.flag").exists():
derived_status = "transcribed"
if (folder / "songs.json").exists():
derived_status = "songs_detected"
if (folder / "split_done.flag").exists():
derived_status = "split_done"
if (folder / "upload_done.flag").exists():
derived_status = "published"
if (folder / "comment_done.flag").exists():
derived_status = "commented"
if (folder / "collection_a_done.flag").exists() or (folder / "collection_b_done.flag").exists():
derived_status = "collection_synced"
effective_status = self._merge_task_status(existing_task.status if existing_task else None, derived_status)
created_at = (
existing_task.created_at
if existing_task and existing_task.created_at
else self._folder_time_iso(folder)
)
updated_at = (
existing_task.updated_at
if existing_task and existing_task.updated_at
else created_at
)
task = Task(
id=task_id,
source_type=existing_task.source_type if existing_task else "legacy_session",
source_path=existing_task.source_path if existing_task else str(folder),
title=folder.name,
status=effective_status,
created_at=created_at,
updated_at=updated_at,
)
self.upsert_task(task)
steps = self._merge_steps(folder, task_id)
self.replace_steps(task_id, steps)
self._bootstrap_artifacts(folder, task_id)
synced += 1
return synced
def _infer_steps(self, folder: Path, task_id: str) -> list[TaskStep]:
flags = {
"ingest": True,
"transcribe": (folder / "transcribe_done.flag").exists(),
"song_detect": (folder / "songs.json").exists(),
"split": (folder / "split_done.flag").exists(),
"publish": (folder / "upload_done.flag").exists(),
"comment": (folder / "comment_done.flag").exists(),
"collection_a": (folder / "collection_a_done.flag").exists(),
"collection_b": (folder / "collection_b_done.flag").exists(),
}
steps: list[TaskStep] = []
for name, done in flags.items():
steps.append(
TaskStep(
id=None,
task_id=task_id,
step_name=name,
status="succeeded" if done else "pending",
error_code=None,
error_message=None,
retry_count=0,
started_at=None,
finished_at=None,
)
)
return steps
def _merge_steps(self, folder: Path, task_id: str) -> list[TaskStep]:
inferred_steps = {step.step_name: step for step in self._infer_steps(folder, task_id)}
current_steps = {step.step_name: step for step in self.list_steps(task_id)}
merged: list[TaskStep] = []
for step_name, inferred in inferred_steps.items():
current = current_steps.get(step_name)
if current is None:
merged.append(inferred)
continue
if inferred.status == "succeeded":
merged.append(
TaskStep(
id=None,
task_id=task_id,
step_name=step_name,
status="succeeded",
error_code=None,
error_message=None,
retry_count=current.retry_count,
started_at=current.started_at,
finished_at=current.finished_at,
)
)
continue
if current.status != "pending":
merged.append(
TaskStep(
id=None,
task_id=task_id,
step_name=step_name,
status=current.status,
error_code=current.error_code,
error_message=current.error_message,
retry_count=current.retry_count,
started_at=current.started_at,
finished_at=current.finished_at,
)
)
continue
merged.append(inferred)
return merged
@staticmethod
def _merge_task_status(existing_status: str | None, derived_status: str) -> str:
if not existing_status:
return derived_status
existing_rank = TASK_STATUS_ORDER.get(existing_status, -1)
derived_rank = TASK_STATUS_ORDER.get(derived_status, -1)
return existing_status if existing_rank >= derived_rank else derived_status
@staticmethod
def _folder_time_iso(folder: Path) -> str:
return datetime.fromtimestamp(folder.stat().st_mtime, tz=timezone.utc).isoformat()
def _bootstrap_artifacts(self, folder: Path, task_id: str) -> None:
artifacts = []
if any(folder.glob("*.srt")):
for srt in folder.glob("*.srt"):
artifacts.append(("subtitle_srt", srt))
for name in ("songs.json", "songs.txt", "bvid.txt"):
path = folder / name
if path.exists():
artifact_type = {
"songs.json": "songs_json",
"songs.txt": "songs_txt",
"bvid.txt": "publish_bvid",
}[name]
artifacts.append((artifact_type, path))
existing = {(a.artifact_type, a.path) for a in self.list_artifacts(task_id)}
for artifact_type, path in artifacts:
key = (artifact_type, str(path))
if key in existing:
continue
self.add_artifact(
Artifact(
id=None,
task_id=task_id,
artifact_type=artifact_type,
path=str(path),
metadata_json=json.dumps({}),
created_at="",
)
)

View File

@ -0,0 +1,163 @@
from __future__ import annotations
import shutil
from pathlib import Path
from biliup_next.core.models import utc_now_iso
from biliup_next.infra.task_repository import TaskRepository
STEP_ORDER = [
"ingest",
"transcribe",
"song_detect",
"split",
"publish",
"comment",
"collection_a",
"collection_b",
]
STATUS_BEFORE_STEP = {
"transcribe": "created",
"song_detect": "transcribed",
"split": "songs_detected",
"publish": "split_done",
"comment": "published",
"collection_a": "commented",
"collection_b": "commented",
}
class TaskResetService:
def __init__(self, repo: TaskRepository):
self.repo = repo
def reset_to_step(self, task_id: str, step_name: str) -> dict[str, object]:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
if step_name not in STEP_ORDER:
raise RuntimeError(f"unsupported step: {step_name}")
work_dir = self._resolve_work_dir(task)
self._cleanup_files(work_dir, step_name)
self._cleanup_artifacts(task_id, step_name)
self._reset_steps(task_id, step_name)
target_status = STATUS_BEFORE_STEP.get(step_name, "created")
self.repo.update_task_status(task_id, target_status, utc_now_iso())
return {"task_id": task_id, "reset_to": step_name, "work_dir": str(work_dir)}
@staticmethod
def _resolve_work_dir(task) -> Path: # type: ignore[no-untyped-def]
source = Path(task.source_path)
return source.parent if source.is_file() else source
@staticmethod
def _remove_path(path: Path) -> None:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.exists():
path.unlink()
def _cleanup_files(self, work_dir: Path, step_name: str) -> None:
cleanup_map = {
"transcribe": [
work_dir / "transcribe_done.flag",
work_dir / "song_schema.json",
work_dir / "songs.json",
work_dir / "songs.txt",
work_dir / "split_done.flag",
work_dir / "upload_done.flag",
work_dir / "comment_done.flag",
work_dir / "comment_split_done.flag",
work_dir / "comment_full_done.flag",
work_dir / "collection_a_done.flag",
work_dir / "collection_b_done.flag",
work_dir / "bvid.txt",
work_dir / "temp_audio",
work_dir / "split_video",
],
"song_detect": [
work_dir / "song_schema.json",
work_dir / "songs.json",
work_dir / "songs.txt",
work_dir / "split_done.flag",
work_dir / "upload_done.flag",
work_dir / "comment_done.flag",
work_dir / "comment_split_done.flag",
work_dir / "comment_full_done.flag",
work_dir / "collection_a_done.flag",
work_dir / "collection_b_done.flag",
work_dir / "bvid.txt",
work_dir / "split_video",
],
"split": [
work_dir / "split_done.flag",
work_dir / "upload_done.flag",
work_dir / "comment_done.flag",
work_dir / "comment_split_done.flag",
work_dir / "comment_full_done.flag",
work_dir / "collection_a_done.flag",
work_dir / "collection_b_done.flag",
work_dir / "bvid.txt",
work_dir / "split_video",
],
"publish": [
work_dir / "upload_done.flag",
work_dir / "comment_done.flag",
work_dir / "comment_split_done.flag",
work_dir / "comment_full_done.flag",
work_dir / "collection_a_done.flag",
work_dir / "collection_b_done.flag",
work_dir / "bvid.txt",
],
"comment": [
work_dir / "comment_done.flag",
work_dir / "comment_split_done.flag",
work_dir / "comment_full_done.flag",
work_dir / "collection_a_done.flag",
work_dir / "collection_b_done.flag",
],
"collection_a": [
work_dir / "collection_a_done.flag",
],
"collection_b": [
work_dir / "collection_b_done.flag",
],
}
for path in cleanup_map.get(step_name, []):
self._remove_path(path)
if step_name == "transcribe":
for srt_file in work_dir.glob("*.srt"):
self._remove_path(srt_file)
def _cleanup_artifacts(self, task_id: str, step_name: str) -> None:
type_map = {
"transcribe": {"subtitle_srt", "songs_json", "songs_txt", "clip_video", "publish_bvid"},
"song_detect": {"songs_json", "songs_txt", "clip_video", "publish_bvid"},
"split": {"clip_video", "publish_bvid"},
"publish": {"publish_bvid"},
"comment": set(),
"collection_a": set(),
"collection_b": set(),
}
for artifact_type in type_map.get(step_name, set()):
self.repo.delete_artifacts(task_id, artifact_type)
def _reset_steps(self, task_id: str, step_name: str) -> None:
reset_index = STEP_ORDER.index(step_name)
for index, current_step in enumerate(STEP_ORDER):
if current_step == "ingest":
continue
if index < reset_index:
continue
self.repo.update_step_status(
task_id,
current_step,
"pending",
error_code=None,
error_message=None,
retry_count=0,
started_at=None,
finished_at=None,
)

View File

@ -0,0 +1,40 @@
from __future__ import annotations
import shutil
from pathlib import Path
from biliup_next.infra.task_repository import TaskRepository
class WorkspaceCleanupService:
def __init__(self, repo: TaskRepository):
self.repo = repo
def cleanup_task_outputs(self, task_id: str, settings: dict[str, object]) -> dict[str, object]:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
session_dir = Path(str(settings["session_dir"])) / task.title
removed: list[str] = []
skipped: list[str] = []
if settings.get("delete_source_video_after_collection_synced", False):
source_path = Path(task.source_path)
if source_path.exists():
source_path.unlink()
self.repo.delete_artifact_by_path(task_id, str(source_path.resolve()))
removed.append(str(source_path))
else:
skipped.append(str(source_path))
if settings.get("delete_split_videos_after_collection_synced", False):
split_dir = session_dir / "split_video"
if split_dir.exists():
shutil.rmtree(split_dir, ignore_errors=True)
self.repo.delete_artifacts(task_id, "clip_video")
removed.append(str(split_dir))
else:
skipped.append(str(split_dir))
return {"removed": removed, "skipped": skipped}

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from biliup_next.core.models import utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.workspace_cleanup import WorkspaceCleanupService
from biliup_next.infra.task_repository import TaskRepository
class CollectionService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
self.cleanup = WorkspaceCleanupService(repo)
def run(self, task_id: str, target: str, settings: dict[str, object]) -> dict[str, object]:
if target not in {"a", "b"}:
raise RuntimeError(f"unsupported collection target: {target}")
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
step_name = f"collection_{target}"
provider = self.registry.get("collection_provider", str(settings.get("provider", "bilibili_collection")))
started_at = utc_now_iso()
self.repo.update_step_status(task_id, step_name, "running", started_at=started_at)
result = provider.sync(task, target, settings)
finished_at = utc_now_iso()
self.repo.update_step_status(task_id, step_name, "succeeded", finished_at=finished_at)
steps = {step.step_name: step for step in self.repo.list_steps(task_id)}
if steps.get("collection_a") and steps["collection_a"].status == "succeeded" and steps.get("collection_b") and steps["collection_b"].status == "succeeded":
self.repo.update_task_status(task_id, "collection_synced", finished_at)
cleanup_result = self.cleanup.cleanup_task_outputs(task_id, settings)
return {**result, "cleanup": cleanup_result}
return result

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from biliup_next.core.models import utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.task_repository import TaskRepository
class CommentService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
def run(self, task_id: str, settings: dict[str, object]) -> dict[str, object]:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
provider = self.registry.get("comment_provider", str(settings.get("provider", "bilibili_top_comment")))
started_at = utc_now_iso()
self.repo.update_step_status(task_id, "comment", "running", started_at=started_at)
result = provider.comment(task, settings)
finished_at = utc_now_iso()
self.repo.update_step_status(task_id, "comment", "succeeded", finished_at=finished_at)
self.repo.update_task_status(task_id, "commented", finished_at)
return result

View File

@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from biliup_next.core.errors import ModuleError
from biliup_next.core.providers import ProviderManifest
class LocalFileIngestProvider:
manifest = ProviderManifest(
id="local_file",
name="Local File Ingest",
version="0.1.0",
provider_type="ingest_provider",
entrypoint="biliup_next.modules.ingest.providers.local_file:LocalFileIngestProvider",
capabilities=["ingest"],
enabled_by_default=True,
)
def validate_source(self, source_path: Path, settings: dict[str, Any]) -> None:
if not source_path.exists():
raise ModuleError(
code="SOURCE_NOT_FOUND",
message=f"源文件不存在: {source_path}",
retryable=False,
)
if not source_path.is_file():
raise ModuleError(
code="SOURCE_NOT_FILE",
message=f"源路径不是文件: {source_path}",
retryable=False,
)
suffix = source_path.suffix.lower()
allowed = [str(item).lower() for item in settings.get("allowed_extensions", [])]
if suffix not in allowed:
raise ModuleError(
code="SOURCE_EXTENSION_NOT_ALLOWED",
message=f"文件扩展名不受支持: {suffix}",
retryable=False,
details={"allowed_extensions": allowed},
)

View File

@ -0,0 +1,201 @@
from __future__ import annotations
import json
import shutil
import subprocess
import time
from pathlib import Path
from biliup_next.core.errors import ModuleError
from biliup_next.core.models import Artifact, Task, TaskStep, utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.task_repository import TaskRepository
class IngestService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
def create_task_from_file(self, source_path: Path, settings: dict[str, object]) -> Task:
provider_id = str(settings.get("provider", "local_file"))
provider = self.registry.get("ingest_provider", provider_id)
provider.validate_source(source_path, settings)
task_id = source_path.stem
if self.repo.get_task(task_id):
raise ModuleError(
code="TASK_ALREADY_EXISTS",
message=f"任务已存在: {task_id}",
retryable=False,
)
now = utc_now_iso()
task = Task(
id=task_id,
source_type="local_file",
source_path=str(source_path.resolve()),
title=source_path.stem,
status="created",
created_at=now,
updated_at=now,
)
self.repo.upsert_task(task)
self.repo.replace_steps(
task_id,
[
TaskStep(None, task_id, "ingest", "succeeded", None, None, 0, now, now),
TaskStep(None, task_id, "transcribe", "pending", None, None, 0, None, None),
TaskStep(None, task_id, "song_detect", "pending", None, None, 0, None, None),
TaskStep(None, task_id, "split", "pending", None, None, 0, None, None),
TaskStep(None, task_id, "publish", "pending", None, None, 0, None, None),
TaskStep(None, task_id, "comment", "pending", None, None, 0, None, None),
TaskStep(None, task_id, "collection_a", "pending", None, None, 0, None, None),
TaskStep(None, task_id, "collection_b", "pending", None, None, 0, None, None),
],
)
self.repo.add_artifact(
Artifact(
id=None,
task_id=task_id,
artifact_type="source_video",
path=str(source_path.resolve()),
metadata_json=json.dumps({"provider": provider_id}),
created_at=now,
)
)
return task
def scan_stage(self, settings: dict[str, object]) -> dict[str, object]:
stage_dir = Path(str(settings["stage_dir"])).resolve()
backup_dir = Path(str(settings["backup_dir"])).resolve()
session_dir = Path(str(settings["session_dir"])).resolve()
ffprobe_bin = str(settings.get("ffprobe_bin", "ffprobe"))
min_duration = int(settings.get("min_duration_seconds", 0))
stability_wait_seconds = int(settings.get("stability_wait_seconds", 30))
stage_dir.mkdir(parents=True, exist_ok=True)
backup_dir.mkdir(parents=True, exist_ok=True)
session_dir.mkdir(parents=True, exist_ok=True)
accepted: list[dict[str, object]] = []
rejected: list[dict[str, object]] = []
skipped: list[dict[str, object]] = []
allowed = {str(item).lower() for item in settings.get("allowed_extensions", [])}
for source_path in sorted(p for p in stage_dir.iterdir() if p.is_file()):
if source_path.name.startswith(".") or source_path.name.endswith(".part"):
continue
if source_path.suffix.lower() not in allowed:
continue
if not self._is_stable_enough(source_path, stability_wait_seconds):
skipped.append(
{
"source_path": str(source_path),
"reason": "file_not_stable_yet",
"stability_wait_seconds": stability_wait_seconds,
}
)
continue
task_id = source_path.stem
if self.repo.get_task(task_id):
target = self._move_to_directory(source_path, backup_dir)
skipped.append(
{
"source_path": str(source_path),
"reason": "task_exists",
"moved_to": str(target),
}
)
continue
duration_seconds = self._probe_duration_seconds(source_path, ffprobe_bin)
if duration_seconds < min_duration:
target = self._move_to_directory(source_path, backup_dir)
rejected.append(
{
"source_path": str(source_path),
"reason": "duration_too_short",
"duration_seconds": duration_seconds,
"min_duration_seconds": min_duration,
"moved_to": str(target),
}
)
continue
task_dir = session_dir / task_id
task_dir.mkdir(parents=True, exist_ok=True)
target_source = self._move_to_directory(source_path, task_dir)
task = self.create_task_from_file(target_source, settings)
accepted.append(
{
"task_id": task.id,
"title": task.title,
"source_path": str(target_source),
"duration_seconds": duration_seconds,
}
)
return {"accepted": accepted, "rejected": rejected, "skipped": skipped}
@staticmethod
def _is_stable_enough(source_path: Path, stability_wait_seconds: int) -> bool:
if stability_wait_seconds <= 0:
return True
age_seconds = time.time() - source_path.stat().st_mtime
return age_seconds >= stability_wait_seconds
def _probe_duration_seconds(self, source_path: Path, ffprobe_bin: str) -> float:
cmd = [
ffprobe_bin,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(source_path),
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
except FileNotFoundError as exc:
raise ModuleError(
code="FFPROBE_NOT_FOUND",
message=f"找不到 ffprobe: {ffprobe_bin}",
retryable=False,
) from exc
except subprocess.CalledProcessError as exc:
raise ModuleError(
code="FFPROBE_FAILED",
message=f"ffprobe 获取时长失败: {source_path.name}",
retryable=False,
details={"stderr": exc.stderr.strip()},
) from exc
try:
return float(result.stdout.strip())
except ValueError as exc:
raise ModuleError(
code="FFPROBE_INVALID_DURATION",
message=f"ffprobe 返回非法时长: {source_path.name}",
retryable=False,
details={"stdout": result.stdout.strip()},
) from exc
def _move_to_directory(self, source_path: Path, target_dir: Path) -> Path:
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / source_path.name
if target_path.exists():
target_path = self._unique_target_path(target_dir, source_path.name)
shutil.move(str(source_path), str(target_path))
return target_path.resolve()
@staticmethod
def _unique_target_path(target_dir: Path, filename: str) -> Path:
base = Path(filename).stem
suffix = Path(filename).suffix
index = 1
while True:
candidate = target_dir / f"{base}.{index}{suffix}"
if not candidate.exists():
return candidate
index += 1

View File

@ -0,0 +1,43 @@
from __future__ import annotations
import json
from pathlib import Path
from biliup_next.core.models import Artifact, PublishRecord, utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.task_repository import TaskRepository
class PublishService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
def run(self, task_id: str, settings: dict[str, object]) -> PublishRecord:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
artifacts = self.repo.list_artifacts(task_id)
clip_videos = [a for a in artifacts if a.artifact_type == "clip_video"]
provider = self.registry.get("publish_provider", str(settings.get("provider", "biliup_cli")))
started_at = utc_now_iso()
self.repo.update_step_status(task_id, "publish", "running", started_at=started_at)
record = provider.publish(task, clip_videos, settings)
self.repo.add_publish_record(record)
if record.bvid:
session_dir = Path(str(settings.get("session_dir", "session"))) / task.title
bvid_path = str((session_dir / "bvid.txt").resolve())
self.repo.add_artifact(
Artifact(
id=None,
task_id=task_id,
artifact_type="publish_bvid",
path=bvid_path,
metadata_json=json.dumps({}),
created_at=utc_now_iso(),
)
)
finished_at = utc_now_iso()
self.repo.update_step_status(task_id, "publish", "succeeded", finished_at=finished_at)
self.repo.update_task_status(task_id, "published", finished_at)
return record

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from biliup_next.core.models import Artifact, utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.task_repository import TaskRepository
class SongDetectService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
def run(self, task_id: str, settings: dict[str, object]) -> tuple[Artifact, Artifact]:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
artifacts = self.repo.list_artifacts(task_id)
subtitle_srt = next(a for a in artifacts if a.artifact_type == "subtitle_srt")
provider = self.registry.get("song_detector", str(settings.get("provider", "codex")))
started_at = utc_now_iso()
self.repo.update_step_status(task_id, "song_detect", "running", started_at=started_at)
songs_json, songs_txt = provider.detect(task, subtitle_srt, settings)
self.repo.add_artifact(songs_json)
self.repo.add_artifact(songs_txt)
finished_at = utc_now_iso()
self.repo.update_step_status(task_id, "song_detect", "succeeded", finished_at=finished_at)
self.repo.update_task_status(task_id, "songs_detected", finished_at)
return songs_json, songs_txt

View File

@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
from biliup_next.core.models import Artifact, utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.storage_guard import ensure_free_space, mb_to_bytes
from biliup_next.infra.task_repository import TaskRepository
class SplitService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
def run(self, task_id: str, settings: dict[str, object]) -> list[Artifact]:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
artifacts = self.repo.list_artifacts(task_id)
songs_json = next(a for a in artifacts if a.artifact_type == "songs_json")
source_video = next(a for a in artifacts if a.artifact_type == "source_video")
source_path = Path(source_video.path)
source_size = source_path.stat().st_size
reserve_bytes = mb_to_bytes(settings.get("min_free_space_mb", 0))
ensure_free_space(
source_path.parent,
source_size + reserve_bytes,
code="SPLIT_NO_SPACE",
message=f"剩余空间不足,无法开始切歌: {source_path.name}",
retryable=True,
details={"source_size_bytes": source_size, "reserve_bytes": reserve_bytes},
)
provider = self.registry.get("split_provider", str(settings.get("provider", "ffmpeg_copy")))
started_at = utc_now_iso()
self.repo.update_step_status(task_id, "split", "running", started_at=started_at)
clip_artifacts = provider.split(task, songs_json, source_video, settings)
existing = {(a.artifact_type, a.path) for a in artifacts}
for artifact in clip_artifacts:
if (artifact.artifact_type, artifact.path) not in existing:
self.repo.add_artifact(artifact)
finished_at = utc_now_iso()
self.repo.update_step_status(task_id, "split", "succeeded", finished_at=finished_at)
self.repo.update_task_status(task_id, "split_done", finished_at)
return clip_artifacts

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from biliup_next.core.models import Artifact, utc_now_iso
from biliup_next.core.registry import Registry
from biliup_next.infra.task_repository import TaskRepository
class TranscribeService:
def __init__(self, registry: Registry, repo: TaskRepository):
self.registry = registry
self.repo = repo
def run(self, task_id: str, settings: dict[str, object]) -> Artifact:
task = self.repo.get_task(task_id)
if task is None:
raise RuntimeError(f"task not found: {task_id}")
artifacts = self.repo.list_artifacts(task_id)
source_video = next(a for a in artifacts if a.artifact_type == "source_video")
provider = self.registry.get("transcribe_provider", str(settings.get("provider", "groq")))
started_at = utc_now_iso()
self.repo.update_step_status(task_id, "transcribe", "running", started_at=started_at)
artifact = provider.transcribe(task, source_video, settings)
self.repo.add_artifact(artifact)
finished_at = utc_now_iso()
self.repo.update_step_status(task_id, "transcribe", "succeeded", finished_at=finished_at)
self.repo.update_task_status(task_id, "transcribed", finished_at)
return artifact

View File

@ -0,0 +1,9 @@
{
"id": "bilibili_collection",
"name": "Legacy Bilibili Collection Provider",
"version": "0.1.0",
"provider_type": "collection_provider",
"entrypoint": "biliup_next.infra.adapters.bilibili_collection_legacy:LegacyBilibiliCollectionProvider",
"capabilities": ["collection"],
"enabled_by_default": true
}

View File

@ -0,0 +1,9 @@
{
"id": "bilibili_top_comment",
"name": "Legacy Bilibili Top Comment Provider",
"version": "0.1.0",
"provider_type": "comment_provider",
"entrypoint": "biliup_next.infra.adapters.bilibili_top_comment_legacy:LegacyBilibiliTopCommentProvider",
"capabilities": ["comment"],
"enabled_by_default": true
}

View File

@ -0,0 +1,9 @@
{
"id": "local_file",
"name": "Local File Ingest",
"version": "0.1.0",
"provider_type": "ingest_provider",
"entrypoint": "biliup_next.modules.ingest.providers.local_file:LocalFileIngestProvider",
"capabilities": ["ingest"],
"enabled_by_default": true
}

View File

@ -0,0 +1,9 @@
{
"id": "biliup_cli",
"name": "biliup CLI Publish Provider",
"version": "0.1.0",
"provider_type": "publish_provider",
"entrypoint": "biliup_next.infra.adapters.biliup_publish_legacy:LegacyBiliupPublishProvider",
"capabilities": ["publish"],
"enabled_by_default": true
}

View File

@ -0,0 +1,9 @@
{
"id": "codex",
"name": "Codex Song Detector",
"version": "0.1.0",
"provider_type": "song_detector",
"entrypoint": "biliup_next.infra.adapters.codex_legacy:LegacyCodexSongDetector",
"capabilities": ["song_detect"],
"enabled_by_default": true
}

View File

@ -0,0 +1,9 @@
{
"id": "ffmpeg_copy",
"name": "FFmpeg Copy Split Provider",
"version": "0.1.0",
"provider_type": "split_provider",
"entrypoint": "biliup_next.infra.adapters.ffmpeg_split_legacy:LegacyFfmpegSplitProvider",
"capabilities": ["split"],
"enabled_by_default": true
}

View File

@ -0,0 +1,9 @@
{
"id": "groq",
"name": "Groq Transcribe Provider",
"version": "0.1.0",
"provider_type": "transcribe_provider",
"entrypoint": "biliup_next.infra.adapters.groq_legacy:LegacyGroqTranscribeProvider",
"capabilities": ["transcribe"],
"enabled_by_default": true
}