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()