Files
biliup-next/src/biliup_next/app/api_server.py

451 lines
20 KiB
Python

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 bind_full_video_action
from biliup_next.app.task_actions import merge_session_action
from biliup_next.app.task_actions import receive_full_video_webhook
from biliup_next.app.task_actions import rebind_session_full_video_action
from biliup_next.app.task_actions import reset_to_step_action
from biliup_next.app.task_actions import retry_step_action
from biliup_next.app.task_actions import run_task_action
from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.bootstrap import reset_initialized_state
from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher
from biliup_next.app.dashboard import render_dashboard_html
from biliup_next.app.control_plane_post_dispatcher import ControlPlanePostDispatcher
from biliup_next.app.retry_meta import retry_meta_for_step
from biliup_next.app.scheduler import build_scheduler_preview
from biliup_next.app.serializers import ControlPlaneSerializer
from biliup_next.app.worker import run_once
from biliup_next.core.config import SettingsService
from biliup_next.core.models import ActionRecord, utc_now_iso
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"
@staticmethod
def _attention_state(task_payload: dict[str, object]) -> str:
if task_payload.get("status") == "failed_manual":
return "manual_now"
retry_state = task_payload.get("retry_state")
if isinstance(retry_state, dict) and retry_state.get("retry_due"):
return "retry_now"
if task_payload.get("status") == "failed_retryable" and isinstance(retry_state, dict) and retry_state.get("next_retry_at"):
return "waiting_retry"
if task_payload.get("status") == "running":
return "running"
return "stable"
@staticmethod
def _delivery_state_label(task_payload: dict[str, object]) -> str:
delivery_state = task_payload.get("delivery_state")
if not isinstance(delivery_state, dict):
return "stable"
if delivery_state.get("split_comment") == "pending" or delivery_state.get("full_video_timeline_comment") == "pending":
return "pending_comment"
if delivery_state.get("source_video_present") is False or delivery_state.get("split_videos_present") is False:
return "cleanup_removed"
return "stable"
def _step_payload(self, step, state: dict[str, object]) -> dict[str, object]: # type: ignore[no-untyped-def]
return ControlPlaneSerializer(state).step_payload(step)
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 parsed_path.startswith("/assets/"):
relative = parsed_path.removeprefix("/")
asset_path = dist / relative
if asset_path.exists() and asset_path.is_file():
body = asset_path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", self._guess_content_type(asset_path))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return True
if not parsed_path.startswith("/ui/"):
return False
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 == "/" or parsed.path.startswith("/ui") or parsed.path.startswith("/assets/")) and self._serve_frontend_dist(parsed.path):
return
if not self._check_auth(parsed.path):
return
if parsed.path.startswith("/assets/"):
self._serve_asset(parsed.path.removeprefix("/assets/"))
return
if parsed.path == "/classic":
self._html(render_dashboard_html())
return
if parsed.path == "/":
self._html(render_dashboard_html())
return
if parsed.path == "/health":
self._json({"ok": True})
return
state = ensure_initialized()
get_dispatcher = ControlPlaneGetDispatcher(
state,
attention_state_fn=self._attention_state,
delivery_state_label_fn=self._delivery_state_label,
build_scheduler_preview_fn=build_scheduler_preview,
settings_service_factory=SettingsService,
)
if parsed.path == "/settings":
body, status = get_dispatcher.handle_settings()
self._json(body, status=status)
return
if parsed.path == "/settings/schema":
body, status = get_dispatcher.handle_settings_schema()
self._json(body, status=status)
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":
body, status = get_dispatcher.handle_scheduler_preview()
self._json(body, status=status)
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":
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]
body, http_status = get_dispatcher.handle_history(
limit=limit,
task_id=task_id,
action_name=action_name,
status=status,
)
self._json(body, status=http_status)
return
if parsed.path == "/modules":
body, status = get_dispatcher.handle_modules()
self._json(body, status=status)
return
if parsed.path == "/tasks":
query = parse_qs(parsed.query)
limit = int(query.get("limit", ["100"])[0])
offset = int(query.get("offset", ["0"])[0])
status = query.get("status", [None])[0]
search = query.get("search", [None])[0]
sort = query.get("sort", ["updated_desc"])[0]
attention = query.get("attention", [None])[0]
delivery = query.get("delivery", [None])[0]
body, http_status = get_dispatcher.handle_tasks(
limit=limit,
offset=offset,
status=status,
search=search,
sort=sort,
attention=attention,
delivery=delivery,
)
self._json(body, status=http_status)
return
if parsed.path.startswith("/sessions/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 2:
body, status = get_dispatcher.handle_session(parts[1])
self._json(body, status=status)
return
if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 2:
body, status = get_dispatcher.handle_task(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "steps":
body, status = get_dispatcher.handle_task_steps(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "context":
body, status = get_dispatcher.handle_task_context(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "artifacts":
body, status = get_dispatcher.handle_task_artifacts(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "history":
body, status = get_dispatcher.handle_task_history(parts[1])
self._json(body, status=status)
return
if len(parts) == 3 and parts[2] == "timeline":
body, status = get_dispatcher.handle_task_timeline(parts[1])
self._json(body, status=status)
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()
reset_initialized_state()
ensure_initialized()
self._json({"ok": True})
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if not self._check_auth(parsed.path):
return
state = ensure_initialized()
dispatcher = ControlPlanePostDispatcher(
state,
bind_full_video_action=bind_full_video_action,
merge_session_action=merge_session_action,
receive_full_video_webhook=receive_full_video_webhook,
rebind_session_full_video_action=rebind_session_full_video_action,
reset_to_step_action=reset_to_step_action,
retry_step_action=retry_step_action,
run_task_action=run_task_action,
run_once=run_once,
stage_importer_factory=StageImporter,
systemd_runtime_factory=SystemdRuntime,
)
if parsed.path == "/webhooks/full-video-uploaded":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_webhook_full_video(payload)
self._json(body, status=status)
return
if parsed.path != "/tasks":
if parsed.path.startswith("/sessions/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "merge":
session_key = parts[1]
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_session_merge(session_key, payload)
self._json(body, status=status)
return
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "rebind":
session_key = parts[1]
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_session_rebind(session_key, payload)
self._json(body, status=status)
return
if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "bind-full-video":
task_id = parts[1]
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_bind_full_video(task_id, payload)
self._json(body, status=status)
return
if len(parts) == 4 and parts[0] == "tasks" and parts[2] == "actions":
task_id = parts[1]
action = parts[3]
if action in {"run", "retry-step", "reset-to-step"}:
payload = {}
if action != "run":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_task_action(task_id, action, payload)
self._json(body, status=status)
return
if parsed.path == "/worker/run-once":
body, status = dispatcher.handle_worker_run_once()
self._json(body, status=status)
return
if parsed.path.startswith("/runtime/services/"):
parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 4 and parts[0] == "runtime" and parts[1] == "services":
body, status = dispatcher.handle_runtime_service_action(parts[2], parts[3])
self._json(body, status=status)
return
if parsed.path == "/stage/import":
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_stage_import(payload)
self._json(body, status=status)
return
if parsed.path == "/stage/upload":
content_type = self.headers.get("Content-Type", "")
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
body, status = dispatcher.handle_stage_upload(file_item)
self._json(body, status=status)
return
if parsed.path == "/scheduler/run-once":
body, status = dispatcher.handle_scheduler_run_once()
self._json(body, status=status)
return
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_create_task(payload)
self._json(body, status=status)
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/", "/classic"} 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()