feat: package docker deployment and publish flow

This commit is contained in:
theshy
2026-04-22 16:20:03 +08:00
parent 055474360e
commit 2146687dc6
178 changed files with 24318 additions and 20855 deletions

19
.dockerignore Normal file
View File

@ -0,0 +1,19 @@
.git
.venv
.pytest_cache
__pycache__
*.pyc
data/
runtime/cookies.json
runtime/upload_config.json
runtime/biliup
runtime/codex/
runtime/logs/
frontend/node_modules/
frontend/dist/
.env
config/settings.json
config/settings.staged.json

49
.env.example Normal file
View File

@ -0,0 +1,49 @@
# Web/API port exposed on the host.
BILIUP_NEXT_PORT=8000
# Image used by both api and worker. Override this when using a versioned tag
# or a private registry image, for example 192.168.1.100:25490/biliup-next:20260420.
BILIUP_NEXT_IMAGE=biliup-next:local
# Worker polling interval in seconds.
WORKER_INTERVAL=5
# Container timezone.
TZ=Asia/Shanghai
# Optional container outbound proxy. In Docker Desktop/WSL, host.docker.internal
# points to the Windows host; set this to your local proxy port.
# These values are also passed as Docker build args for apt/pip/npm.
# HTTP_PROXY=http://host.docker.internal:7897
# HTTPS_PROXY=http://host.docker.internal:7897
# ALL_PROXY=http://host.docker.internal:7897
# NO_PROXY=localhost,127.0.0.1,api,worker
#
# Docker build-time proxy. Separate names avoid being overridden by host
# HTTP_PROXY/HTTPS_PROXY when Compose interpolates build args.
# DOCKER_BUILD_HTTP_PROXY=http://host.docker.internal:7897
# DOCKER_BUILD_HTTPS_PROXY=http://host.docker.internal:7897
# DOCKER_BUILD_ALL_PROXY=http://host.docker.internal:7897
# DOCKER_BUILD_NO_PROXY=localhost,127.0.0.1,api,worker
# Required for Groq transcription. Prefer this env var over writing the key
# directly into config/settings.json.
GROQ_API_KEY=
# Optional key pool. Use a JSON array; keys here are tried before GROQ_API_KEY.
# GROQ_API_KEYS=["gsk_xxx","gsk_yyy"]
# Optional for the Codex song detector when you do not mount an existing
# Codex login state into runtime/codex.
OPENAI_API_KEY=
# Bilibili collection IDs.
# A: live full-video collection
# B: live split/pure-song collection
COLLECTION_SEASON_ID_A=7196643
COLLECTION_SEASON_ID_B=7196624
# Optional explicit config overrides. The generic format is:
# BILIUP_NEXT__GROUP__FIELD=value
#
# BILIUP_NEXT__PUBLISH__RETRY_SCHEDULE_MINUTES=[15,5,5,5,5]
# BILIUP_NEXT__PUBLISH__RATE_LIMIT_RETRY_SCHEDULE_MINUTES=[15,30,60]

31
.gitignore vendored
View File

@ -1,22 +1,27 @@
.venv/
.codex
.codex/
.env
.tmp-tests/
__pycache__/
*.pyc
*.pyo
*.pyd
data/
config/settings.staged.json
systemd/rendered/
data/
config/settings.staged.json
systemd/rendered/
runtime/cookies.json
runtime/upload_config.json
runtime/biliup
runtime/codex/
runtime/logs/
frontend/node_modules/
frontend/dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/
frontend/node_modules/
frontend/dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@ -1,113 +1,113 @@
# biliup-next Delivery Checklist
## Scope
`biliup-next` 当前已经是一个可独立安装、可运行、可通过控制台运维的本地项目。
当前交付范围包括:
- 独立 Python 包
- 本地 `.venv` 初始化
- SQLite 状态存储
- 隔离 workspace
- `worker` / `api` 运行脚本
- `systemd` 安装脚本
- Web 控制台
- 项目内日志落盘
- 主链路:
- `stage`
- `ingest`
- `transcribe`
- `song_detect`
- `split`
- `publish`
- `comment`
- `collection`
## Preflight
- Python 3.11+
- `ffmpeg`
- `ffprobe`
- `codex`
- `biliup`
- `biliup-next/runtime/cookies.json`
- `biliup-next/runtime/upload_config.json`
- `biliup-next/runtime/biliup`
## Install
```bash
cd /home/theshy/biliup/biliup-next
bash setup.sh
```
如需把当前机器上已有运行资产复制到本地:
```bash
cd /home/theshy/biliup/biliup-next
./.venv/bin/biliup-next sync-legacy-assets
```
## Verify
```bash
cd /home/theshy/biliup/biliup-next
./.venv/bin/biliup-next doctor
./.venv/bin/biliup-next init-workspace
bash smoke-test.sh
```
预期:
- `doctor.ok = true`
- `data/workspace/stage`
- `data/workspace/backup`
- `data/workspace/session`
## Run
手动方式:
```bash
cd /home/theshy/biliup/biliup-next
bash run-worker.sh
bash run-api.sh
```
默认会写入:
- `runtime/logs/worker.log`
- `runtime/logs/api.log`
默认按大小轮转:
- 单文件 `20 MiB`
- 保留 `5` 份历史日志
systemd 方式:
```bash
cd /home/theshy/biliup/biliup-next
bash install-systemd.sh
```
## Control Plane
- URL: `http://127.0.0.1:8787/`
- 健康检查:`/health`
- 可选认证:`runtime.control_token`
## Repository Hygiene
这些内容不应提交:
- `.venv/`
- `data/`
- `systemd/rendered/`
- `config/settings.staged.json`
## Known Limits
- 当前控制台认证是单 token本地可用但不等于完整权限系统
- `sync-legacy-assets` 仍是一次性导入工具,方便把已有资产复制到 `runtime/`
# biliup-next Delivery Checklist
## Scope
`biliup-next` 当前已经是一个可独立安装、可运行、可通过控制台运维的本地项目。
当前交付范围包括:
- 独立 Python 包
- 本地 `.venv` 初始化
- SQLite 状态存储
- 隔离 workspace
- `worker` / `api` 运行脚本
- `systemd` 安装脚本
- Web 控制台
- 项目内日志落盘
- 主链路:
- `stage`
- `ingest`
- `transcribe`
- `song_detect`
- `split`
- `publish`
- `comment`
- `collection`
## Preflight
- Python 3.11+
- `ffmpeg`
- `ffprobe`
- `codex`
- `biliup`
- `biliup-next/runtime/cookies.json`
- `biliup-next/runtime/upload_config.json`
- `biliup-next/runtime/biliup`
## Install
```bash
cd /home/theshy/biliup/biliup-next
bash setup.sh
```
如需把当前机器上已有运行资产复制到本地:
```bash
cd /home/theshy/biliup/biliup-next
./.venv/bin/biliup-next sync-legacy-assets
```
## Verify
```bash
cd /home/theshy/biliup/biliup-next
./.venv/bin/biliup-next doctor
./.venv/bin/biliup-next init-workspace
bash smoke-test.sh
```
预期:
- `doctor.ok = true`
- `data/workspace/stage`
- `data/workspace/backup`
- `data/workspace/session`
## Run
手动方式:
```bash
cd /home/theshy/biliup/biliup-next
bash run-worker.sh
bash run-api.sh
```
默认会写入:
- `runtime/logs/worker.log`
- `runtime/logs/api.log`
默认按大小轮转:
- 单文件 `20 MiB`
- 保留 `5` 份历史日志
systemd 方式:
```bash
cd /home/theshy/biliup/biliup-next
bash install-systemd.sh
```
## Control Plane
- URL: `http://127.0.0.1:8787/`
- 健康检查:`/health`
- 可选认证:`runtime.control_token`
## Repository Hygiene
这些内容不应提交:
- `.venv/`
- `data/`
- `systemd/rendered/`
- `config/settings.staged.json`
## Known Limits
- 当前控制台认证是单 token本地可用但不等于完整权限系统
- `sync-legacy-assets` 仍是一次性导入工具,方便把已有资产复制到 `runtime/`

61
Dockerfile Normal file
View File

@ -0,0 +1,61 @@
FROM node:24-bookworm-slim AS frontend-builder
ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG ALL_PROXY
ARG NO_PROXY
ARG http_proxy
ARG https_proxy
ARG all_proxy
ARG no_proxy
WORKDIR /build/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim AS app
ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG ALL_PROXY
ARG NO_PROXY
ARG http_proxy
ARG https_proxy
ARG all_proxy
ARG no_proxy
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
BILIUP_NEXT_CONTAINER=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md ./
COPY src ./src
COPY config ./config
COPY runtime/README.md runtime/cookies.example.json runtime/upload_config.example.json ./runtime/
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend-builder /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN pip install --editable . \
&& pip install yt-dlp \
&& ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& npm install -g @openai/codex
RUN mkdir -p /app/data/workspace/stage /app/data/workspace/session /app/data/workspace/backup /app/runtime/logs /root/.codex
EXPOSE 8000
CMD ["biliup-next", "serve", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -59,6 +59,10 @@ bash setup.sh
- `docs/cold-start-checklist.md`
发布流程、输出文案和评论示例见:
- `docs/publish-output-examples.md`
浏览器访问:
```text
@ -192,6 +196,29 @@ cd /home/theshy/biliup/biliup-next
- 内容按 `P1/P2/P3` 分组
- 依赖 `full_video_bvid.txt` 或通过标题匹配解析到完整版 BV
评论格式和投稿文案一样,优先从 `runtime/upload_config.json` 读取。可编辑字段:
```json
"comment_template": {
"split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享{previous_pure_video_link} (上一场歌曲纯享版)",
"full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版{current_pure_video_link} (只听歌曲看这里)\n上次完整版{previous_full_video_link} (上一场完整录播)",
"split_part_header": "P{part_index}:",
"full_part_header": "P{part_index}:",
"split_song_line": "{song_index}. {title}{artist_suffix}",
"split_text_song_line": "{song_index}. {song_text}",
"full_timeline_line": "{song_index}. {line_text}"
}
```
常用变量:
- 链接:`{current_full_video_link}``{current_pure_video_link}``{previous_full_video_link}``{previous_pure_video_link}`
- 分段与序号:`{part_index}``{song_index}`
- 纯享歌单:`{title}``{artist}``{artist_suffix}``{song_text}`
- 完整版时间轴:`{line_text}`
如果某一行包含空链接变量,例如 `{previous_full_video_link}` 为空,这一整行会自动跳过。
清理默认关闭:
- `cleanup.delete_source_video_after_collection_synced = false`
@ -201,11 +228,14 @@ cd /home/theshy/biliup/biliup-next
## Full Video BV Input
完整版 `BV` 目前支持 3 种来源:
完整版 `BV` 目前支持 4 种来源:
- `stage/*.meta.json` 中的 `full_video_bvid`
- 前端 / API 手工绑定
- webhook`POST /webhooks/full-video-uploaded`
- `biliup list` 标题匹配,包含 `开放浏览``审核中` 状态
只要完整版上传后已经生成 BV即使仍在审核中也可以被用于纯享版简介、动态和评论互链。
推荐 webhook 负载:
@ -320,3 +350,14 @@ curl -X POST http://127.0.0.1:8787/tasks \
- `ingest.provider = bilibili_url`
- `ingest.yt_dlp_cmd = yt-dlp`
## Docker Compose Deployment
如果希望用容器方式一键运行 API 和 worker请参考 [README_DEPLOY.md](README_DEPLOY.md)。
快速入口:
```bash
./scripts/init-docker-config.sh
docker compose up -d --build
```

176
README_DEPLOY.md Normal file
View File

@ -0,0 +1,176 @@
# Docker Compose Deployment
This deployment runs the API and worker as two services from the same image.
Runtime state, credentials, staged videos, generated sessions, and the SQLite
database stay on the host through bind mounts.
## 1. Initialize Local Files
```bash
chmod +x scripts/init-docker-config.sh
./scripts/init-docker-config.sh
```
This creates these files if they do not already exist:
```text
.env
config/settings.json
runtime/cookies.json
runtime/upload_config.json
data/workspace/
```
## 2. Edit Required Secrets And IDs
Edit `.env`:
```env
GROQ_API_KEY=your_groq_key
OPENAI_API_KEY=your_openai_key_if_using_codex
COLLECTION_SEASON_ID_A=7196643
COLLECTION_SEASON_ID_B=7196624
```
Edit `runtime/cookies.json` and `runtime/upload_config.json` with real Bilibili
credentials and upload metadata.
`runtime/upload_config.json` also controls pure-video title, description,
dynamic text, and top-comment formatting. Existing deployments mount
`./runtime` from the host, so updating the image does not overwrite this file.
When you want to change output text, edit the host file directly.
Common output templates:
```json
{
"template": {
"title": "【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌",
"description": "{streamer} {date} 歌曲纯享版。\n\n完整歌单与时间轴见置顶评论。\n直播完整版{current_full_video_link}\n上次直播{previous_full_video_link}\n\n本视频为歌曲纯享切片适合只听歌曲。",
"dynamic": "{streamer} {date} 歌曲纯享版已发布。完整歌单见置顶评论。\n直播完整版{current_full_video_link}\n上次直播{previous_full_video_link}"
},
"comment_template": {
"split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次直播{previous_full_video_link} (上一场完整录播)",
"full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版{current_pure_video_link} (只听歌曲看这里)\n上次直播{previous_full_video_link} (上一场完整录播)",
"split_part_header": "P{part_index}:",
"full_part_header": "P{part_index}:",
"split_song_line": "{song_index}. {title}{artist_suffix}",
"split_text_song_line": "{song_index}. {song_text}",
"full_timeline_line": "{song_index}. {line_text}"
}
}
```
Supported comment variables:
- `{current_full_video_link}` / `{current_pure_video_link}`
- `{previous_full_video_link}` / `{previous_pure_video_link}`
- `{part_index}` / `{song_index}`
- `{title}` / `{artist}` / `{artist_suffix}` / `{song_text}` / `{line_text}`
If a comment header line contains an empty link variable, that whole line is
omitted. This prevents comments from showing blank `上次直播:` lines when the
previous live video cannot be found.
Provide the `biliup` binary at:
```text
runtime/biliup
```
It must be executable inside the container:
```bash
chmod +x runtime/biliup
```
The image installs the `codex` CLI for `song_detect.provider=codex`. Provide
Codex auth in one of these ways:
```text
OPENAI_API_KEY in .env
runtime/codex mounted to /root/.codex
```
## 3. Start
```bash
docker compose up -d --build
```
Open:
```text
http://127.0.0.1:8000
```
Drop videos into:
```text
data/workspace/stage/
```
## Common Commands
```bash
docker compose logs -f api
docker compose logs -f worker
docker compose restart worker
docker compose down
```
Run one scheduler cycle manually:
```bash
docker compose run --rm worker biliup-next run-once
```
Run doctor:
```bash
docker compose run --rm api biliup-next doctor
```
## Environment Overrides
`config/settings.json` is still the base configuration. Environment variables
override selected values at runtime.
The Compose file already forces container-safe paths such as
`/app/data/workspace` and `/app/runtime/cookies.json`, so an existing local
`config/settings.json` with host paths can still be mounted safely.
Generic format:
```text
BILIUP_NEXT__GROUP__FIELD=value
```
Examples:
```env
BILIUP_NEXT__PATHS__STAGE_DIR=/app/data/workspace/stage
BILIUP_NEXT__PUBLISH__BILIUP_PATH=/app/runtime/biliup
BILIUP_NEXT__PUBLISH__RETRY_SCHEDULE_MINUTES=[15,5,5,5,5]
```
Convenience aliases:
```env
GROQ_API_KEY=...
COLLECTION_SEASON_ID_A=7196643
COLLECTION_SEASON_ID_B=7196624
```
## Data Persistence
These host paths are mounted into the containers:
```text
./config -> /app/config
./runtime -> /app/runtime
./data/workspace -> /app/data/workspace
```
Do not store `cookies.json`, Groq keys, or generated workspace data in the image.
They should stay in the mounted host directories.

View File

@ -0,0 +1,127 @@
{
"runtime": {
"database_path": "/app/data/workspace/biliup_next.db",
"control_token": "",
"log_level": "INFO"
},
"paths": {
"stage_dir": "/app/data/workspace/stage",
"backup_dir": "/app/data/workspace/backup",
"session_dir": "/app/data/workspace/session",
"cookies_file": "/app/runtime/cookies.json",
"upload_config_file": "/app/runtime/upload_config.json"
},
"scheduler": {
"candidate_scan_limit": 500,
"max_tasks_per_cycle": 50,
"prioritize_retry_due": true,
"oldest_first": true,
"status_priority": [
"failed_retryable",
"created",
"transcribed",
"songs_detected",
"split_done",
"published",
"commented",
"collection_synced"
]
},
"ingest": {
"provider": "local_file",
"min_duration_seconds": 900,
"ffprobe_bin": "ffprobe",
"yt_dlp_cmd": "yt-dlp",
"yt_dlp_format": "",
"allowed_extensions": [
".mp4",
".flv",
".mkv",
".mov"
],
"stage_min_free_space_mb": 1024,
"stability_wait_seconds": 30,
"session_gap_minutes": 60,
"meta_sidecar_enabled": true,
"meta_sidecar_suffix": ".meta.json"
},
"transcribe": {
"provider": "groq",
"groq_api_key": "",
"groq_api_keys": [],
"ffmpeg_bin": "ffmpeg",
"max_file_size_mb": 12,
"request_timeout_seconds": 180,
"request_max_retries": 1,
"request_retry_backoff_seconds": 30,
"serialize_groq_requests": true,
"retry_count": 3,
"retry_backoff_seconds": 300,
"retry_schedule_minutes": [
5,
10,
15
]
},
"song_detect": {
"provider": "codex",
"codex_cmd": "codex",
"qwen_cmd": "qwen",
"poll_interval_seconds": 2,
"retry_count": 3,
"retry_backoff_seconds": 300,
"retry_schedule_minutes": [
5,
10,
15
]
},
"split": {
"provider": "ffmpeg_copy",
"ffmpeg_bin": "ffmpeg",
"poll_interval_seconds": 2,
"min_free_space_mb": 2048
},
"publish": {
"provider": "biliup_cli",
"biliup_path": "/app/runtime/biliup",
"cookie_file": "/app/runtime/cookies.json",
"retry_count": 5,
"retry_schedule_minutes": [
15,
5,
5,
5,
5
],
"retry_backoff_seconds": 300,
"command_timeout_seconds": 1800,
"rate_limit_retry_schedule_minutes": [
15,
30,
60
]
},
"comment": {
"provider": "bilibili_top_comment",
"enabled": true,
"max_retries": 5,
"base_delay_seconds": 180,
"poll_interval_seconds": 10,
"post_split_comment": true,
"post_full_video_timeline_comment": true
},
"collection": {
"provider": "bilibili_collection",
"enabled": true,
"season_id_a": 7196643,
"season_id_b": 7196624,
"allow_fuzzy_full_video_match": false,
"append_collection_a_new_to_end": true,
"append_collection_b_new_to_end": true
},
"cleanup": {
"delete_source_video_after_collection_synced": false,
"delete_split_videos_after_collection_synced": false
}
}

View File

@ -1,15 +1,15 @@
{
"runtime": {
"database_path": "data/workspace/biliup_next.db",
"database_path": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/biliup_next.db",
"control_token": "",
"log_level": "INFO"
},
"paths": {
"stage_dir": "data/workspace/stage",
"backup_dir": "data/workspace/backup",
"session_dir": "data/workspace/session",
"cookies_file": "runtime/cookies.json",
"upload_config_file": "runtime/upload_config.json"
"stage_dir": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/stage",
"backup_dir": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/backup",
"session_dir": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/session",
"cookies_file": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/cookies.json",
"upload_config_file": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/upload_config.json"
},
"scheduler": {
"candidate_scan_limit": 500,
@ -31,7 +31,7 @@
"provider": "local_file",
"min_duration_seconds": 900,
"ffprobe_bin": "ffprobe",
"yt_dlp_cmd": "yt-dlp",
"yt_dlp_cmd": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/.venv/bin/yt-dlp",
"yt_dlp_format": "",
"allowed_extensions": [
".mp4",
@ -47,15 +47,34 @@
},
"transcribe": {
"provider": "groq",
"groq_api_key": "",
"groq_api_key": "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",
"groq_api_keys": [],
"ffmpeg_bin": "ffmpeg",
"max_file_size_mb": 23
"max_file_size_mb": 12,
"request_timeout_seconds": 180,
"request_max_retries": 1,
"request_retry_backoff_seconds": 30,
"serialize_groq_requests": true,
"retry_count": 3,
"retry_backoff_seconds": 300,
"retry_schedule_minutes": [
5,
10,
15
]
},
"song_detect": {
"provider": "qwen_cli",
"provider": "codex",
"codex_cmd": "codex",
"qwen_cmd": "qwen",
"poll_interval_seconds": 2
"poll_interval_seconds": 2,
"retry_count": 3,
"retry_backoff_seconds": 300,
"retry_schedule_minutes": [
5,
10,
15
]
},
"split": {
"provider": "ffmpeg_copy",
@ -65,8 +84,8 @@
},
"publish": {
"provider": "biliup_cli",
"biliup_path": "runtime/biliup",
"cookie_file": "runtime/cookies.json",
"biliup_path": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/biliup",
"cookie_file": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/cookies.json",
"retry_count": 5,
"retry_schedule_minutes": [
15,
@ -78,9 +97,9 @@
"retry_backoff_seconds": 300,
"command_timeout_seconds": 1800,
"rate_limit_retry_schedule_minutes": [
15,
30,
60,
120
60
]
},
"comment": {
@ -95,8 +114,8 @@
"collection": {
"provider": "bilibili_collection",
"enabled": true,
"season_id_a": 0,
"season_id_b": 0,
"season_id_a": 7196643,
"season_id_b": 7196624,
"allow_fuzzy_full_video_match": false,
"append_collection_a_new_to_end": true,
"append_collection_b_new_to_end": true

View File

@ -229,6 +229,16 @@
"description": "用于调用 Groq 转录 API。",
"sensitive": true
},
"groq_api_keys": {
"type": "array",
"default": [],
"title": "Groq API Keys",
"ui_order": 12,
"ui_widget": "secret_list",
"items": { "type": "string" },
"description": "可选 Groq API Key 池。遇到单个 key 限流时会自动切换下一个 key为空时使用 groq_api_key。",
"sensitive": true
},
"ffmpeg_bin": {
"type": "string",
"default": "ffmpeg",
@ -238,10 +248,66 @@
},
"max_file_size_mb": {
"type": "integer",
"default": 23,
"default": 12,
"title": "Max File Size MB",
"ui_order": 40,
"minimum": 1
"minimum": 1,
"description": "Groq 音频分片目标上限。实际切分会额外保留安全余量,避免贴近上传限制。"
},
"request_timeout_seconds": {
"type": "integer",
"default": 180,
"title": "Request Timeout Seconds",
"ui_order": 50,
"minimum": 1,
"description": "单个 Groq 转录请求的超时时间。"
},
"request_max_retries": {
"type": "integer",
"default": 1,
"title": "Request Max Retries",
"ui_order": 60,
"minimum": 0,
"description": "单个音频分片在超时、限流或连接错误时的请求级重试次数。"
},
"request_retry_backoff_seconds": {
"type": "integer",
"default": 30,
"title": "Request Retry Backoff Seconds",
"ui_order": 70,
"minimum": 0,
"description": "Groq 请求级重试之间的等待时间。"
},
"serialize_groq_requests": {
"type": "boolean",
"default": true,
"title": "Serialize Groq Requests",
"ui_order": 75,
"description": "是否串行化 Groq 分片上传请求,避免多个 worker 或多个任务同时上传导致超时。"
},
"retry_count": {
"type": "integer",
"default": 3,
"title": "Task Retry Count",
"ui_order": 80,
"minimum": 0,
"description": "transcribe 步骤允许的任务级失败重试次数。"
},
"retry_backoff_seconds": {
"type": "integer",
"default": 300,
"title": "Task Retry Backoff Seconds",
"ui_order": 90,
"minimum": 0,
"description": "未配置 retry_schedule_minutes 时transcribe 任务级重试的等待时间。"
},
"retry_schedule_minutes": {
"type": "array",
"default": [5, 10, 15],
"title": "Task Retry Schedule Minutes",
"ui_order": 100,
"items": { "type": "integer", "minimum": 0 },
"description": "transcribe 任务级失败后的自动重试等待时间。"
}
},
"song_detect": {
@ -275,6 +341,30 @@
"title": "Poll Interval Seconds",
"ui_order": 30,
"minimum": 1
},
"retry_count": {
"type": "integer",
"default": 3,
"title": "Task Retry Count",
"ui_order": 40,
"minimum": 0,
"description": "song_detect 步骤允许的任务级失败重试次数。认证失败会直接进入人工失败,不会重试。"
},
"retry_backoff_seconds": {
"type": "integer",
"default": 300,
"title": "Task Retry Backoff Seconds",
"ui_order": 50,
"minimum": 0,
"description": "未配置 retry_schedule_minutes 时song_detect 任务级重试的等待时间。"
},
"retry_schedule_minutes": {
"type": "array",
"default": [5, 10, 15],
"title": "Task Retry Schedule Minutes",
"ui_order": 60,
"items": { "type": "integer", "minimum": 0 },
"description": "song_detect 任务级失败后的自动重试等待时间。"
}
},
"split": {
@ -375,9 +465,9 @@
"rate_limit_retry_schedule_minutes": {
"type": "array",
"default": [
15,
30,
60,
120
60
],
"title": "Rate Limit Retry Schedule Minutes",
"ui_order": 70,

View File

@ -1,69 +1,70 @@
{
"runtime": {
"database_path": "data/workspace/biliup_next.db",
"control_token": "",
"log_level": "INFO"
},
"paths": {
"stage_dir": "data/workspace/stage",
"backup_dir": "data/workspace/backup",
"session_dir": "data/workspace/session",
"cookies_file": "runtime/cookies.json",
"upload_config_file": "runtime/upload_config.json"
},
"ingest": {
"provider": "local_file",
"min_duration_seconds": 900,
"ffprobe_bin": "ffprobe",
"yt_dlp_cmd": "yt-dlp",
"yt_dlp_format": "",
"allowed_extensions": [".mp4", ".flv", ".mkv", ".mov"],
"stage_min_free_space_mb": 2048,
"stability_wait_seconds": 30,
"session_gap_minutes": 60,
"meta_sidecar_enabled": true,
"meta_sidecar_suffix": ".meta.json"
},
"transcribe": {
{
"runtime": {
"database_path": "data/workspace/biliup_next.db",
"control_token": "",
"log_level": "INFO"
},
"paths": {
"stage_dir": "data/workspace/stage",
"backup_dir": "data/workspace/backup",
"session_dir": "data/workspace/session",
"cookies_file": "runtime/cookies.json",
"upload_config_file": "runtime/upload_config.json"
},
"ingest": {
"provider": "local_file",
"min_duration_seconds": 900,
"ffprobe_bin": "ffprobe",
"yt_dlp_cmd": "yt-dlp",
"yt_dlp_format": "",
"allowed_extensions": [".mp4", ".flv", ".mkv", ".mov"],
"stage_min_free_space_mb": 2048,
"stability_wait_seconds": 30,
"session_gap_minutes": 60,
"meta_sidecar_enabled": true,
"meta_sidecar_suffix": ".meta.json"
},
"transcribe": {
"provider": "groq",
"groq_api_key": "",
"groq_api_keys": [],
"ffmpeg_bin": "ffmpeg",
"max_file_size_mb": 23
},
"song_detect": {
"provider": "codex",
"codex_cmd": "codex",
"qwen_cmd": "qwen",
"poll_interval_seconds": 2
},
"split": {
"provider": "ffmpeg_copy",
"ffmpeg_bin": "ffmpeg",
"poll_interval_seconds": 2
},
"publish": {
"provider": "biliup_cli",
"biliup_path": "runtime/biliup",
"cookie_file": "runtime/cookies.json",
"retry_count": 5,
"retry_backoff_seconds": 300,
"command_timeout_seconds": 1800,
"rate_limit_retry_schedule_minutes": [30, 60, 120]
},
"comment": {
"provider": "bilibili_top_comment",
"enabled": true,
"max_retries": 5,
"base_delay_seconds": 180,
"poll_interval_seconds": 10
},
"collection": {
"provider": "bilibili_collection",
"enabled": true,
"season_id_a": 7196643,
"season_id_b": 7196624,
"allow_fuzzy_full_video_match": false,
"append_collection_a_new_to_end": true,
"append_collection_b_new_to_end": true
}
}
"max_file_size_mb": 23
},
"song_detect": {
"provider": "codex",
"codex_cmd": "codex",
"qwen_cmd": "qwen",
"poll_interval_seconds": 2
},
"split": {
"provider": "ffmpeg_copy",
"ffmpeg_bin": "ffmpeg",
"poll_interval_seconds": 2
},
"publish": {
"provider": "biliup_cli",
"biliup_path": "runtime/biliup",
"cookie_file": "runtime/cookies.json",
"retry_count": 5,
"retry_backoff_seconds": 300,
"command_timeout_seconds": 1800,
"rate_limit_retry_schedule_minutes": [30, 60, 120]
},
"comment": {
"provider": "bilibili_top_comment",
"enabled": true,
"max_retries": 5,
"base_delay_seconds": 180,
"poll_interval_seconds": 10
},
"collection": {
"provider": "bilibili_collection",
"enabled": true,
"season_id_a": 7196643,
"season_id_b": 7196624,
"allow_fuzzy_full_video_match": false,
"append_collection_a_new_to_end": true,
"append_collection_b_new_to_end": true
}
}

74
docker-compose.yml Normal file
View File

@ -0,0 +1,74 @@
services:
api:
build:
context: .
args:
HTTP_PROXY: ${DOCKER_BUILD_HTTP_PROXY:-}
HTTPS_PROXY: ${DOCKER_BUILD_HTTPS_PROXY:-}
ALL_PROXY: ${DOCKER_BUILD_ALL_PROXY:-}
NO_PROXY: ${DOCKER_BUILD_NO_PROXY:-}
http_proxy: ${DOCKER_BUILD_HTTP_PROXY:-}
https_proxy: ${DOCKER_BUILD_HTTPS_PROXY:-}
all_proxy: ${DOCKER_BUILD_ALL_PROXY:-}
no_proxy: ${DOCKER_BUILD_NO_PROXY:-}
image: ${BILIUP_NEXT_IMAGE:-biliup-next:local}
command: ["biliup-next", "serve", "--host", "0.0.0.0", "--port", "8000"]
env_file:
- path: .env
required: false
environment:
TZ: ${TZ:-Asia/Shanghai}
BILIUP_NEXT__RUNTIME__DATABASE_PATH: /app/data/workspace/biliup_next.db
BILIUP_NEXT__PATHS__STAGE_DIR: /app/data/workspace/stage
BILIUP_NEXT__PATHS__BACKUP_DIR: /app/data/workspace/backup
BILIUP_NEXT__PATHS__SESSION_DIR: /app/data/workspace/session
BILIUP_NEXT__PATHS__COOKIES_FILE: /app/runtime/cookies.json
BILIUP_NEXT__PATHS__UPLOAD_CONFIG_FILE: /app/runtime/upload_config.json
BILIUP_NEXT__INGEST__YT_DLP_CMD: yt-dlp
BILIUP_NEXT__PUBLISH__BILIUP_PATH: /app/runtime/biliup
BILIUP_NEXT__PUBLISH__COOKIE_FILE: /app/runtime/cookies.json
ports:
- "${BILIUP_NEXT_PORT:-8000}:8000"
volumes:
- ./config:/app/config
- ./runtime:/app/runtime
- ./data/workspace:/app/data/workspace
- ./runtime/codex:/root/.codex
restart: unless-stopped
worker:
image: ${BILIUP_NEXT_IMAGE:-biliup-next:local}
build:
context: .
args:
HTTP_PROXY: ${DOCKER_BUILD_HTTP_PROXY:-}
HTTPS_PROXY: ${DOCKER_BUILD_HTTPS_PROXY:-}
ALL_PROXY: ${DOCKER_BUILD_ALL_PROXY:-}
NO_PROXY: ${DOCKER_BUILD_NO_PROXY:-}
http_proxy: ${DOCKER_BUILD_HTTP_PROXY:-}
https_proxy: ${DOCKER_BUILD_HTTPS_PROXY:-}
all_proxy: ${DOCKER_BUILD_ALL_PROXY:-}
no_proxy: ${DOCKER_BUILD_NO_PROXY:-}
command: ["sh", "-c", "biliup-next worker --interval ${WORKER_INTERVAL:-5}"]
env_file:
- path: .env
required: false
environment:
TZ: ${TZ:-Asia/Shanghai}
BILIUP_NEXT__RUNTIME__DATABASE_PATH: /app/data/workspace/biliup_next.db
BILIUP_NEXT__PATHS__STAGE_DIR: /app/data/workspace/stage
BILIUP_NEXT__PATHS__BACKUP_DIR: /app/data/workspace/backup
BILIUP_NEXT__PATHS__SESSION_DIR: /app/data/workspace/session
BILIUP_NEXT__PATHS__COOKIES_FILE: /app/runtime/cookies.json
BILIUP_NEXT__PATHS__UPLOAD_CONFIG_FILE: /app/runtime/upload_config.json
BILIUP_NEXT__INGEST__YT_DLP_CMD: yt-dlp
BILIUP_NEXT__PUBLISH__BILIUP_PATH: /app/runtime/biliup
BILIUP_NEXT__PUBLISH__COOKIE_FILE: /app/runtime/cookies.json
volumes:
- ./config:/app/config
- ./runtime:/app/runtime
- ./data/workspace:/app/data/workspace
- ./runtime/codex:/root/.codex
restart: unless-stopped
depends_on:
- api

View File

@ -1,64 +1,64 @@
# ADR 0001: Use A Modular Monolith As The Target Architecture
## Status
Accepted
## Context
当前项目由多个 Python 脚本、目录扫描逻辑、flag 文件和外部命令拼接而成。
主要问题:
- 状态不统一
- 配置不统一
- 重复逻辑多
- 扩展新功能需要继续增加脚本
- 运维和业务边界不清晰
重构目标要求:
- 可扩展
- 可配置
- 可观测
- 易部署
- 易文档化
## Decision
新系统采用模块化单体架构,而不是:
- 继续维护脚本集合
- 直接拆成微服务
## Rationale
选择模块化单体的原因:
- 当前系统规模和团队协作模式不需要微服务
- 单机部署和本地运维是核心需求
- 统一数据库、配置和日志对当前问题最直接有效
- 模块化单体足以提供清晰边界和未来插件扩展能力
## Consequences
正面影响:
- 部署简单
- 重构成本可控
- 便于引入统一状态机和管理 API
- 后续可以逐步插件化
负面影响:
- 需要严格维持模块边界,避免重新长成“大脚本”
- 单进程内错误隔离不如微服务天然
## Follow-Up Decisions
后续还需要补充的 ADR
- 是否使用 SQLite 作为主状态存储
- 是否引入事件总线
- 插件机制如何注册
- 管理台采用什么技术栈
# ADR 0001: Use A Modular Monolith As The Target Architecture
## Status
Accepted
## Context
当前项目由多个 Python 脚本、目录扫描逻辑、flag 文件和外部命令拼接而成。
主要问题:
- 状态不统一
- 配置不统一
- 重复逻辑多
- 扩展新功能需要继续增加脚本
- 运维和业务边界不清晰
重构目标要求:
- 可扩展
- 可配置
- 可观测
- 易部署
- 易文档化
## Decision
新系统采用模块化单体架构,而不是:
- 继续维护脚本集合
- 直接拆成微服务
## Rationale
选择模块化单体的原因:
- 当前系统规模和团队协作模式不需要微服务
- 单机部署和本地运维是核心需求
- 统一数据库、配置和日志对当前问题最直接有效
- 模块化单体足以提供清晰边界和未来插件扩展能力
## Consequences
正面影响:
- 部署简单
- 重构成本可控
- 便于引入统一状态机和管理 API
- 后续可以逐步插件化
负面影响:
- 需要严格维持模块边界,避免重新长成“大脚本”
- 单进程内错误隔离不如微服务天然
## Follow-Up Decisions
后续还需要补充的 ADR
- 是否使用 SQLite 作为主状态存储
- 是否引入事件总线
- 插件机制如何注册
- 管理台采用什么技术栈

View File

@ -1,251 +1,251 @@
openapi: 3.1.0
info:
title: biliup-next Control API
version: 0.1.0
summary: 本地 worker、任务和控制台 API
servers:
- url: http://127.0.0.1:8787
paths:
/:
get:
summary: 控制台首页
responses:
"200":
description: HTML dashboard
/health:
get:
summary: 健康检查
responses:
"200":
description: OK
/settings:
get:
summary: 获取当前设置
responses:
"200":
description: 当前配置,敏感字段已掩码
put:
summary: 更新设置
responses:
"200":
description: 保存成功
/settings/schema:
get:
summary: 获取 settings schema
responses:
"200":
description: schema-first UI 元数据
/doctor:
get:
summary: 运行时依赖检查
responses:
"200":
description: doctor result
/modules:
get:
summary: 查询已注册模块与 manifest
responses:
"200":
description: module list
/runtime/services:
get:
summary: 查询 systemd 服务状态
responses:
"200":
description: service list
/runtime/services/{serviceId}/{action}:
post:
summary: 执行 service start/stop/restart
parameters:
- in: path
name: serviceId
required: true
schema:
type: string
- in: path
name: action
required: true
schema:
type: string
enum: [start, stop, restart]
responses:
"202":
description: action accepted
/logs:
get:
summary: 查询日志列表或日志内容
parameters:
- in: query
name: name
schema:
type: string
- in: query
name: lines
schema:
type: integer
- in: query
name: contains
schema:
type: string
responses:
"200":
description: log list or log content
/history:
get:
summary: 查询全局动作流
parameters:
- in: query
name: task_id
schema:
type: string
- in: query
name: action_name
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
responses:
"200":
description: action records
/tasks:
get:
summary: 查询任务列表
parameters:
- in: query
name: limit
schema:
type: integer
responses:
"200":
description: task list
post:
summary: 从 source_path 手动创建任务
responses:
"201":
description: task created
/webhooks/full-video-uploaded:
post:
summary: 接收原视频上传成功后的完整版 BV webhook
responses:
"202":
description: accepted
/tasks/{taskId}:
get:
summary: 查询任务详情
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task detail
/tasks/{taskId}/steps:
get:
summary: 查询任务步骤
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task steps
/tasks/{taskId}/artifacts:
get:
summary: 查询任务产物
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task artifacts
/tasks/{taskId}/history:
get:
summary: 查询单任务动作历史
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task action history
/tasks/{taskId}/timeline:
get:
summary: 查询单任务时间线
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task timeline
/tasks/{taskId}/actions/run:
post:
summary: 推进单个任务
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"202":
description: accepted
/tasks/{taskId}/actions/retry-step:
post:
summary: 从指定 step 重试
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"202":
description: accepted
/tasks/{taskId}/actions/reset-to-step:
post:
summary: 重置到指定 step 并重跑
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"202":
description: accepted
/worker/run-once:
post:
summary: 执行一轮 worker
responses:
"202":
description: accepted
/stage/import:
post:
summary: 从本机已有绝对路径复制到隔离 stage
responses:
"201":
description: imported
/stage/upload:
post:
summary: 上传文件到隔离 stage
responses:
"201":
description: uploaded
openapi: 3.1.0
info:
title: biliup-next Control API
version: 0.1.0
summary: 本地 worker、任务和控制台 API
servers:
- url: http://127.0.0.1:8787
paths:
/:
get:
summary: 控制台首页
responses:
"200":
description: HTML dashboard
/health:
get:
summary: 健康检查
responses:
"200":
description: OK
/settings:
get:
summary: 获取当前设置
responses:
"200":
description: 当前配置,敏感字段已掩码
put:
summary: 更新设置
responses:
"200":
description: 保存成功
/settings/schema:
get:
summary: 获取 settings schema
responses:
"200":
description: schema-first UI 元数据
/doctor:
get:
summary: 运行时依赖检查
responses:
"200":
description: doctor result
/modules:
get:
summary: 查询已注册模块与 manifest
responses:
"200":
description: module list
/runtime/services:
get:
summary: 查询 systemd 服务状态
responses:
"200":
description: service list
/runtime/services/{serviceId}/{action}:
post:
summary: 执行 service start/stop/restart
parameters:
- in: path
name: serviceId
required: true
schema:
type: string
- in: path
name: action
required: true
schema:
type: string
enum: [start, stop, restart]
responses:
"202":
description: action accepted
/logs:
get:
summary: 查询日志列表或日志内容
parameters:
- in: query
name: name
schema:
type: string
- in: query
name: lines
schema:
type: integer
- in: query
name: contains
schema:
type: string
responses:
"200":
description: log list or log content
/history:
get:
summary: 查询全局动作流
parameters:
- in: query
name: task_id
schema:
type: string
- in: query
name: action_name
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
responses:
"200":
description: action records
/tasks:
get:
summary: 查询任务列表
parameters:
- in: query
name: limit
schema:
type: integer
responses:
"200":
description: task list
post:
summary: 从 source_path 手动创建任务
responses:
"201":
description: task created
/webhooks/full-video-uploaded:
post:
summary: 接收原视频上传成功后的完整版 BV webhook
responses:
"202":
description: accepted
/tasks/{taskId}:
get:
summary: 查询任务详情
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task detail
/tasks/{taskId}/steps:
get:
summary: 查询任务步骤
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task steps
/tasks/{taskId}/artifacts:
get:
summary: 查询任务产物
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task artifacts
/tasks/{taskId}/history:
get:
summary: 查询单任务动作历史
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task action history
/tasks/{taskId}/timeline:
get:
summary: 查询单任务时间线
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"200":
description: task timeline
/tasks/{taskId}/actions/run:
post:
summary: 推进单个任务
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"202":
description: accepted
/tasks/{taskId}/actions/retry-step:
post:
summary: 从指定 step 重试
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"202":
description: accepted
/tasks/{taskId}/actions/reset-to-step:
post:
summary: 重置到指定 step 并重跑
parameters:
- in: path
name: taskId
required: true
schema:
type: string
responses:
"202":
description: accepted
/worker/run-once:
post:
summary: 执行一轮 worker
responses:
"202":
description: accepted
/stage/import:
post:
summary: 从本机已有绝对路径复制到隔离 stage
responses:
"201":
description: imported
/stage/upload:
post:
summary: 上传文件到隔离 stage
responses:
"201":
description: uploaded

View File

@ -1,203 +1,203 @@
# Architecture
## Architecture Style
采用模块化单体架构。
原因:
- 当前规模不需要微服务
- 需要统一配置、状态和任务模型
- 需要较低部署复杂度
- 需要明确模块边界和未来插件扩展能力
## High-Level Layers
### 1. Core
核心领域层,不依赖具体外部服务。
- 领域模型
- 状态机
- 任务编排接口
- 事件定义
- 配置模型
### 2. Modules
业务模块层,每个模块只关心自己的一步能力。
- `ingest`
- `transcribe`
- `song_detect`
- `split`
- `publish`
- `comment`
- `collection`
### 3. Infra
基础设施层,对外部依赖做适配。
- 文件系统
- SQLite 存储
- Groq adapter
- Codex adapter
- FFmpeg adapter
- Bili API adapter
- biliup adapter
- 日志与审计
### 4. App
应用层,对外暴露统一运行入口。
- API Server
- Worker
- Scheduler
- CLI
- Admin Web
## Proposed Directory Layout
```text
biliup-next/
src/
app/
api/
worker/
scheduler/
cli/
core/
models/
services/
events/
state_machine/
config/
modules/
ingest/
transcribe/
song_detect/
split/
publish/
comment/
collection/
infra/
db/
fs/
adapters/
groq/
codex/
ffmpeg/
bili/
biliup/
logging/
plugins/
docs/
tests/
```
## Runtime Components
### API Server
负责:
- 配置管理
- 任务查询
- 手动操作
- 日志聚合查询
- 模块与插件可见性展示
### Worker
负责:
- 消费任务
- 推进状态机
- 执行模块步骤
### Scheduler
负责:
- 定时扫描待补偿任务
- 定时同步外部状态
- 触发重试
## Control Plane
新系统应明确区分控制面和数据面。
### Control Plane
负责:
- 配置管理
- 模块/插件注册
- 任务可视化
- 手动操作入口
- 日志与诊断
### Data Plane
负责:
- 实际执行转录
- 实际执行识歌
- 实际执行切歌
- 实际执行上传
- 实际执行评论和合集归档
## Registry
系统内部建立统一 registry用于注册和查找模块能力。
例如:
- 当前转录 provider
- 当前识歌 provider
- 当前上传 provider
- 当前合集策略
核心模块只依赖抽象接口和 registry不直接依赖具体实现。
## Task Lifecycle
```text
created
-> running
-> transcribed
-> running
-> songs_detected
-> running
-> split_done
-> running
-> published
-> running
-> commented
-> running
-> collection_synced
```
失败状态不结束任务,而是转入:
- `failed_retryable`
- `failed_manual`
## Data Ownership
- SQLite任务、步骤、产物索引、配置、审计记录
- 文件系统视频、字幕、切片、AI 输出、日志
- 外部平台B 站稿件、评论、合集
## Key Design Rules
- 所有状态变更必须落库
- 模块间只通过领域对象和事件通信
- 外部依赖不可直接在业务模块中调用 shell 或 HTTP
- 配置统一由 `core.config` 读取
- 管理端展示的数据优先来自数据库,不直接从日志推断
- 工作区 flag 只表达交付副作用和产物标记,不作为 task 主状态事实源
- 配置系统必须 schema-first
- 插件系统必须 manifest-first
# Architecture
## Architecture Style
采用模块化单体架构。
原因:
- 当前规模不需要微服务
- 需要统一配置、状态和任务模型
- 需要较低部署复杂度
- 需要明确模块边界和未来插件扩展能力
## High-Level Layers
### 1. Core
核心领域层,不依赖具体外部服务。
- 领域模型
- 状态机
- 任务编排接口
- 事件定义
- 配置模型
### 2. Modules
业务模块层,每个模块只关心自己的一步能力。
- `ingest`
- `transcribe`
- `song_detect`
- `split`
- `publish`
- `comment`
- `collection`
### 3. Infra
基础设施层,对外部依赖做适配。
- 文件系统
- SQLite 存储
- Groq adapter
- Codex adapter
- FFmpeg adapter
- Bili API adapter
- biliup adapter
- 日志与审计
### 4. App
应用层,对外暴露统一运行入口。
- API Server
- Worker
- Scheduler
- CLI
- Admin Web
## Proposed Directory Layout
```text
biliup-next/
src/
app/
api/
worker/
scheduler/
cli/
core/
models/
services/
events/
state_machine/
config/
modules/
ingest/
transcribe/
song_detect/
split/
publish/
comment/
collection/
infra/
db/
fs/
adapters/
groq/
codex/
ffmpeg/
bili/
biliup/
logging/
plugins/
docs/
tests/
```
## Runtime Components
### API Server
负责:
- 配置管理
- 任务查询
- 手动操作
- 日志聚合查询
- 模块与插件可见性展示
### Worker
负责:
- 消费任务
- 推进状态机
- 执行模块步骤
### Scheduler
负责:
- 定时扫描待补偿任务
- 定时同步外部状态
- 触发重试
## Control Plane
新系统应明确区分控制面和数据面。
### Control Plane
负责:
- 配置管理
- 模块/插件注册
- 任务可视化
- 手动操作入口
- 日志与诊断
### Data Plane
负责:
- 实际执行转录
- 实际执行识歌
- 实际执行切歌
- 实际执行上传
- 实际执行评论和合集归档
## Registry
系统内部建立统一 registry用于注册和查找模块能力。
例如:
- 当前转录 provider
- 当前识歌 provider
- 当前上传 provider
- 当前合集策略
核心模块只依赖抽象接口和 registry不直接依赖具体实现。
## Task Lifecycle
```text
created
-> running
-> transcribed
-> running
-> songs_detected
-> running
-> split_done
-> running
-> published
-> running
-> commented
-> running
-> collection_synced
```
失败状态不结束任务,而是转入:
- `failed_retryable`
- `failed_manual`
## Data Ownership
- SQLite任务、步骤、产物索引、配置、审计记录
- 文件系统视频、字幕、切片、AI 输出、日志
- 外部平台B 站稿件、评论、合集
## Key Design Rules
- 所有状态变更必须落库
- 模块间只通过领域对象和事件通信
- 外部依赖不可直接在业务模块中调用 shell 或 HTTP
- 配置统一由 `core.config` 读取
- 管理端展示的数据优先来自数据库,不直接从日志推断
- 工作区 flag 只表达交付副作用和产物标记,不作为 task 主状态事实源
- 配置系统必须 schema-first
- 插件系统必须 manifest-first

View File

@ -1,82 +1,82 @@
# biliup-next Cold Start Checklist
目标:在一台没有旧环境残留的新机器上,把 `biliup-next` 启动到“可配置、可 doctor、可进入控制面”的状态。
## 1. 基础环境
- 安装 `python3`
- 安装 `ffmpeg``ffprobe`
- 如需完整歌曲识别,安装 `codex`
- 如需完整上传链路,准备 `biliup` 可执行文件
## 2. 获取项目
```bash
git clone <your-repo> biliup
cd biliup/biliup-next
```
## 3. 一键初始化
```bash
bash setup.sh
```
初始化完成后,项目会自动生成:
- `config/settings.json`
- `config/settings.staged.json`
- `runtime/cookies.json`
- `runtime/upload_config.json`
- `data/workspace/*`
注意:
- 这些文件默认都是模板或占位内容
- 此时项目应当已经能执行 `doctor`,但不代表上传链路已经可用
## 4. 填写真实运行资产
- 编辑 `runtime/cookies.json`
- 编辑 `runtime/upload_config.json`
-`biliup` 放到 `runtime/biliup`,或在 `settings.json` 里改成系统路径
- 填写 `transcribe.groq_api_key`
- 按机器实际情况调整 `song_detect.provider`
- 如果用 `codex`,调整 `song_detect.codex_cmd`
- 如果用 `qwen_cli`,调整 `song_detect.qwen_cmd`
- 按需要填写 `collection.season_id_a` / `collection.season_id_b`
## 5. 验收
```bash
./.venv/bin/biliup-next doctor
./.venv/bin/biliup-next init-workspace
./.venv/bin/biliup-next serve --host 127.0.0.1 --port 8787
bash cold-start-smoke.sh
```
浏览器打开:
```text
http://127.0.0.1:8787/
```
验收通过标准:
- `doctor` 输出可读,缺失项只剩你尚未填写的外部依赖
- 控制面可以打开
- `Settings` 页可正常保存
- `stage` 目录可导入或上传文件
- `cold-start-smoke.sh` 能完整通过
## 6. 完整链路前检查
在开始真实处理前,确认以下项目已经真实可用:
- `runtime/cookies.json`
- `runtime/upload_config.json`
- `publish.biliup_path`
- `song_detect.provider`
- `song_detect.codex_cmd``song_detect.qwen_cmd`
- `transcribe.groq_api_key`
- `collection.season_id_a` / `collection.season_id_b`
# biliup-next Cold Start Checklist
目标:在一台没有旧环境残留的新机器上,把 `biliup-next` 启动到“可配置、可 doctor、可进入控制面”的状态。
## 1. 基础环境
- 安装 `python3`
- 安装 `ffmpeg``ffprobe`
- 如需完整歌曲识别,安装 `codex`
- 如需完整上传链路,准备 `biliup` 可执行文件
## 2. 获取项目
```bash
git clone <your-repo> biliup
cd biliup/biliup-next
```
## 3. 一键初始化
```bash
bash setup.sh
```
初始化完成后,项目会自动生成:
- `config/settings.json`
- `config/settings.staged.json`
- `runtime/cookies.json`
- `runtime/upload_config.json`
- `data/workspace/*`
注意:
- 这些文件默认都是模板或占位内容
- 此时项目应当已经能执行 `doctor`,但不代表上传链路已经可用
## 4. 填写真实运行资产
- 编辑 `runtime/cookies.json`
- 编辑 `runtime/upload_config.json`
-`biliup` 放到 `runtime/biliup`,或在 `settings.json` 里改成系统路径
- 填写 `transcribe.groq_api_key`
- 按机器实际情况调整 `song_detect.provider`
- 如果用 `codex`,调整 `song_detect.codex_cmd`
- 如果用 `qwen_cli`,调整 `song_detect.qwen_cmd`
- 按需要填写 `collection.season_id_a` / `collection.season_id_b`
## 5. 验收
```bash
./.venv/bin/biliup-next doctor
./.venv/bin/biliup-next init-workspace
./.venv/bin/biliup-next serve --host 127.0.0.1 --port 8787
bash cold-start-smoke.sh
```
浏览器打开:
```text
http://127.0.0.1:8787/
```
验收通过标准:
- `doctor` 输出可读,缺失项只剩你尚未填写的外部依赖
- 控制面可以打开
- `Settings` 页可正常保存
- `stage` 目录可导入或上传文件
- `cold-start-smoke.sh` 能完整通过
## 6. 完整链路前检查
在开始真实处理前,确认以下项目已经真实可用:
- `runtime/cookies.json`
- `runtime/upload_config.json`
- `publish.biliup_path`
- `song_detect.provider`
- `song_detect.codex_cmd``song_detect.qwen_cmd`
- `transcribe.groq_api_key`
- `collection.season_id_a` / `collection.season_id_b`

View File

@ -155,6 +155,60 @@ User edits config
- `base_delay_seconds`
- `poll_interval_seconds`
## Upload And Comment Templates
`paths.upload_config_file` 指向 `runtime/upload_config.json`。这个文件不只控制 `biliup upload` 的标题、简介、动态和标签,也控制 B 站置顶评论格式。
投稿字段在 `template` 中:
```json
{
"template": {
"title": "【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌",
"description": "{streamer} {date} 歌曲纯享版。\n\n完整歌单与时间轴见置顶评论。\n直播完整版{current_full_video_link}\n上次直播{previous_full_video_link}",
"tag": "可爱,王海颖,唱歌,音乐",
"dynamic": "{streamer} {date} 歌曲纯享版已发布。\n直播完整版{current_full_video_link}"
}
}
```
评论字段在 `comment_template` 中:
```json
{
"comment_template": {
"split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享{previous_pure_video_link} (上一场歌曲纯享版)",
"full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版{current_pure_video_link} (只听歌曲看这里)\n上次完整版{previous_full_video_link} (上一场完整录播)",
"split_part_header": "P{part_index}:",
"full_part_header": "P{part_index}:",
"split_song_line": "{song_index}. {title}{artist_suffix}",
"split_text_song_line": "{song_index}. {song_text}",
"full_timeline_line": "{song_index}. {line_text}"
}
}
```
可用变量:
- `streamer`:主播名。
- `date`:从文件名解析出来的日期和时间。
- `song_count`:识别到的歌曲数量。
- `songs_list``songs.txt` 原始歌单内容。
- `daily_quote` / `quote_author`:随机引用文本。
- `current_full_video_bvid` / `current_full_video_link`:本场直播完整版 BV 和链接。
- `current_pure_video_bvid` / `current_pure_video_link`:本场歌曲纯享版 BV 和链接。
- `previous_full_video_bvid` / `previous_full_video_link`:上一场直播完整版 BV 和链接。
- `previous_pure_video_bvid` / `previous_pure_video_link`:上一场歌曲纯享版 BV 和链接。
- `part_index`:评论中的 `P1/P2/P3` 分段序号。
- `song_index`:全局歌曲序号。
- `title` / `artist` / `artist_suffix`:从 `songs.json` 生成纯享歌单时使用。
- `song_text`:从 `songs.txt` 兜底生成纯享歌单时使用,通常不含时间戳。
- `line_text`:完整版时间轴的原始行,通常包含时间戳。
评论头部模板有一条额外规则:如果某一行包含空链接变量,例如 `{previous_full_video_link}` 为空,这一整行会自动跳过,避免发出空链接提示。
Docker 部署时 `./runtime` 是宿主机挂载目录。镜像更新不会覆盖已有 `runtime/upload_config.json`,因此调整文案或评论格式时应修改宿主机上的这个文件,然后重启容器。
### collection
- `enabled`

View File

@ -1,491 +1,491 @@
# Control Plane Guide
本文档面向 `biliup-next` 控制台的日常使用。
默认地址:
```text
http://127.0.0.1:8787/
```
如果当前机器已经开放公网访问,也可以使用服务器 IP + `8787` 端口访问。
## 页面分区
控制台主要分成两列:
- 左侧全局状态、服务、动作流、导入入口、任务列表、Settings
- 右侧:当前任务详情、步骤、产物、历史、时间线、模块、日志
建议的使用顺序是:
1. 先看 `Runtime`
2. 再看 `Tasks`
3. 选中一个任务后,看右侧详情
4. 如果任务异常,再看 `Logs``History`
## Runtime
这里可以看 3 个汇总指标:
- `Health`
- `Doctor`
- `Tasks`
含义:
- `Health = OK`
- API 服务本身还活着
- `Doctor = OK`
- 关键路径、二进制和依赖文件都存在
- `Tasks`
- 当前数据库里的任务数
如果 `Doctor` 不是 `OK`,优先不要继续点任务操作,先修运行环境。
## Services
这里可以查看并控制:
- `biliup-next-worker.service`
- `biliup-next-api.service`
- 如果还保留旧服务,也可能看到 `biliup-python.service`
可执行操作:
- `start`
- `restart`
- `stop`
建议:
- 页面打不开时,先看 `biliup-next-api.service`
- 任务不推进时,先看 `biliup-next-worker.service`
- 不要随便再启动旧 `biliup-python.service`,除非你明确知道自己要同时跑旧链路
## Recent Actions
这里显示最近的控制面动作流,例如:
- `worker_run_once`
- `task_run`
- `retry_step`
- `reset_to_step`
- `stage_import`
- `stage_upload`
- `service_action`
可过滤:
- 仅当前任务
- `status`
- `action_name`
用途:
- 判断最近有没有人工操作过任务
- 判断任务是不是被重试过
- 判断服务是不是被重启过
## Import To Stage
这里有两种入口。
### 1. 复制本机已有文件到隔离 stage
输入服务器上的绝对路径,例如:
```text
/home/theshy/video/test.mp4
```
点击:
```text
复制到隔离 Stage
```
适合:
- 服务器本地已有文件
- 想快速把已有文件丢进新系统测试
### 2. 浏览器直接上传文件
选择本地文件后点击:
```text
上传到隔离 Stage
```
适合:
- 本地电脑上的测试视频
- 不想先手动传到服务器
上传成功后,`worker` 会自动扫描 `stage` 并开始建任务。
## Tasks
这里列出任务列表。
每个任务会显示:
- 标题
- 当前状态
- 任务 ID
常见状态:
- `created`
- `transcribed`
- `songs_detected`
- `split_done`
- `published`
- `commented`
- `collection_synced`
- `failed_retryable`
- `failed_manual`
建议:
- 先选中你关心的任务
- 再看右侧 `Task Detail / Steps / Logs`
## Task Detail
显示当前选中任务的核心信息:
- `Task ID`
- `Status`
- `Title`
- `Source`
- `Created`
- `Updated`
上方有 3 个操作按钮:
- `执行当前任务`
- `重试选中 Step`
- `重置到选中 Step`
### 执行当前任务
作用:
- 手动推进当前任务一次
适合:
- 你刚改完配置
- 你不想等下一轮 worker 轮询
### 重试选中 Step
作用:
- 从当前选中的 step 重新尝试
适合:
- 某一步是临时失败
- 不需要删除后续产物
### 重置到选中 Step
作用:
- 清理该 step 之后的产物和标记
- 把任务回拨到该 step
- 然后重新执行
适合:
- 后续结果已经不可信
- 需要从某一步重新跑整段链路
注意:
- 这是破坏性动作
- 页面会要求确认
## Steps
这里显示任务步骤列表,例如:
- `ingest`
- `transcribe`
- `song_detect`
- `split`
- `publish`
- `comment`
- `collection_a`
- `collection_b`
点击某个 step 之后:
- 这个 step 会成为“当前选中 step”
- 然后你就可以点:
- `重试选中 Step`
- `重置到选中 Step`
排查原则:
- `transcribe` 失败:先看 `Groq API Key``ffmpeg`
- `song_detect` 失败:先看 `song_detect.provider`,再看 `codex_cmd``qwen_cmd`
- `publish` 失败:先看 `cookies.json``biliup`
- `collection_*` 失败:再看任务历史和日志
评论规则补充:
- `comment`
- 纯享版视频下默认发 session 级聚合“编号歌单”
- 内容按 `P1/P2/P3` 分组
- 同一 session 只由 anchor task 发一次
- 完整版主视频下默认才发 session 级“带时间轴评论”
- 内容按 `P1/P2/P3` 分组
- 同一 session 只由 anchor task 发一次
- 如果当前任务找不到 `full_video_bvid.txt`,也没能从最近发布列表解析出完整版 BV主视频评论会跳过
session 规则补充:
- 同主播、文件名时间 `3` 小时内的任务会自动归到同一 session
- session 的 `session_key` 使用最早片段标题
- 同一 session 内:
- 只有 anchor task 真正执行纯享版上传
- 纯享版上传会聚合整组 `split_video`
- 整组 task 共用同一个 `bvid.txt`
- split/full 评论都只发一次
## Artifacts
这里显示任务当前已经产出的文件,例如:
- `source_video`
- `subtitle_srt`
- `songs_json`
- `songs_txt`
- `clip_video`
- `publish_bvid`
用途:
- 判断任务跑到了哪一步
- 判断关键输出是否已经落盘
如果开启了 cleanup 配置,任务在 `collection_synced` 后:
- `source_video` 对应的原始视频可能会被删除
- `clip_video` 对应的 `split_video/` 目录也可能被清理
这是正常收尾行为,不代表任务失败。
## History
这里是单任务动作历史。
`Recent Actions` 的区别:
- `Recent Actions` 看全局
- `History` 只看当前任务
可以看到:
- 动作名
- 状态
- 摘要
- 时间
- 结构化 `details_json`
用途:
- 判断某次 `retry``reset` 的结果
- 判断 worker 最近对这个任务做了什么
## Timeline
这里把任务事件串成一条时间线:
- `Task Created`
- step started / finished
- artifact created
- action records
适合:
- 回放任务完整过程
- 查清楚任务到底卡在什么时候
## Modules
这里显示当前注册的模块 / provider。
用途:
- 确认当前用的是哪套 provider
- 确认模块有没有注册成功
如果将来切 provider这里会很有用。
## Doctor Checks
这里比顶部的 `Doctor = OK/FAIL` 更详细。
会列出:
- workspace 目录
- `cookies_file`
- `upload_config_file`
- `ffprobe`
- `ffmpeg`
- `codex_cmd``qwen_cmd`
- `biliup_path`
如果某个依赖显示 `(external)`,表示它还在用系统或父项目路径,不是 `biliup-next` 自己目录内的副本。
## Logs
这里可以看日志文件。
支持:
- 切换日志文件
- 刷新日志
- 按当前任务标题过滤
使用建议:
- 任务异常时,先选中任务
- 再勾选“按当前任务标题过滤”
- 然后查看相关日志
这样比直接翻整份日志快很多。
## Settings
Settings 分成两层:
- 上半部分schema 驱动表单
- 下半部分:`Advanced JSON Editor`
### 表单区
这里适合日常参数调整,例如:
- `min_duration_seconds`
- `groq_api_key`
- `codex_cmd`
- `qwen_cmd`
- `retry_count`
- `season_id_a`
- `season_id_b`
- `post_split_comment`
- `post_full_video_timeline_comment`
- `delete_source_video_after_collection_synced`
- `delete_split_videos_after_collection_synced`
支持:
- 搜索配置项
- 高频参数优先展示
- 低频参数收纳到 `Advanced Settings`
### Advanced JSON Editor
适合:
- 批量调整
- 一次改多个字段
- 需要直接编辑原始 JSON
页面上有两个同步按钮:
- `表单同步到 JSON`
- `JSON 重绘表单`
### 敏感字段规则
敏感字段会显示为:
```text
__BILIUP_NEXT_SECRET__
```
规则:
- 保留占位符:不改原值
- 改成空字符串:清空原值
- 改成新的字符串:更新为新值
## 推荐操作流
### 新上传一个测试视频
1. 打开控制台
2.`Import To Stage` 上传视频
3.`Tasks` 是否出现新任务
4. 选中该任务
5. 观察 `Steps / Artifacts / Timeline`
6. 如需加速,点击 `执行当前任务`
### 某个任务失败
1. 选中任务
2.`Task Detail` 当前状态
3.`Steps` 哪一步失败
4.`Logs`
5. 若只是临时失败:
- 选中 step
-`重试选中 Step`
6. 若后续产物也需要重建:
- 选中 step
-`重置到选中 Step`
### 修改合集或上传参数
1. 打开 `Settings`
2. 搜索目标参数,例如 `season` / `retry`
3. 修改表单
4. 点击 `保存 Settings`
5. 对目标任务执行:
- `执行当前任务`
-`重置到相关 step`
### 控制评论与清理行为
1. 打开 `Settings`
2. 搜索:
- `post_split_comment`
- `post_full_video_timeline_comment`
- `delete_source_video_after_collection_synced`
- `delete_split_videos_after_collection_synced`
3. 修改后保存
4. 新任务会按新规则执行
建议:
- 如果你希望纯享版评论以 session 级聚合歌单展示,保持 `post_split_comment = true`
- 如果你不希望尝试给完整版主视频发时间轴评论,可以关闭 `post_full_video_timeline_comment`
- 如果磁盘紧张,再开启 cleanup默认建议先关闭等确认流程稳定后再开
### 服务异常
1.`Services`
2. 优先 `restart biliup-next-worker.service`
3. 如果页面自身异常,再 `restart biliup-next-api.service`
4. 重启后看:
- `Health`
- `Doctor`
- `Recent Actions`
## 安全建议
当前控制台已经对公网开放时,建议立刻设置:
- `runtime.control_token`
设置后:
-`/``/health` 外,其余 API 都要求 `X-Biliup-Token`
如果你通过公网访问控制台,不建议长期保持空 token。
# Control Plane Guide
本文档面向 `biliup-next` 控制台的日常使用。
默认地址:
```text
http://127.0.0.1:8787/
```
如果当前机器已经开放公网访问,也可以使用服务器 IP + `8787` 端口访问。
## 页面分区
控制台主要分成两列:
- 左侧全局状态、服务、动作流、导入入口、任务列表、Settings
- 右侧:当前任务详情、步骤、产物、历史、时间线、模块、日志
建议的使用顺序是:
1. 先看 `Runtime`
2. 再看 `Tasks`
3. 选中一个任务后,看右侧详情
4. 如果任务异常,再看 `Logs``History`
## Runtime
这里可以看 3 个汇总指标:
- `Health`
- `Doctor`
- `Tasks`
含义:
- `Health = OK`
- API 服务本身还活着
- `Doctor = OK`
- 关键路径、二进制和依赖文件都存在
- `Tasks`
- 当前数据库里的任务数
如果 `Doctor` 不是 `OK`,优先不要继续点任务操作,先修运行环境。
## Services
这里可以查看并控制:
- `biliup-next-worker.service`
- `biliup-next-api.service`
- 如果还保留旧服务,也可能看到 `biliup-python.service`
可执行操作:
- `start`
- `restart`
- `stop`
建议:
- 页面打不开时,先看 `biliup-next-api.service`
- 任务不推进时,先看 `biliup-next-worker.service`
- 不要随便再启动旧 `biliup-python.service`,除非你明确知道自己要同时跑旧链路
## Recent Actions
这里显示最近的控制面动作流,例如:
- `worker_run_once`
- `task_run`
- `retry_step`
- `reset_to_step`
- `stage_import`
- `stage_upload`
- `service_action`
可过滤:
- 仅当前任务
- `status`
- `action_name`
用途:
- 判断最近有没有人工操作过任务
- 判断任务是不是被重试过
- 判断服务是不是被重启过
## Import To Stage
这里有两种入口。
### 1. 复制本机已有文件到隔离 stage
输入服务器上的绝对路径,例如:
```text
/home/theshy/video/test.mp4
```
点击:
```text
复制到隔离 Stage
```
适合:
- 服务器本地已有文件
- 想快速把已有文件丢进新系统测试
### 2. 浏览器直接上传文件
选择本地文件后点击:
```text
上传到隔离 Stage
```
适合:
- 本地电脑上的测试视频
- 不想先手动传到服务器
上传成功后,`worker` 会自动扫描 `stage` 并开始建任务。
## Tasks
这里列出任务列表。
每个任务会显示:
- 标题
- 当前状态
- 任务 ID
常见状态:
- `created`
- `transcribed`
- `songs_detected`
- `split_done`
- `published`
- `commented`
- `collection_synced`
- `failed_retryable`
- `failed_manual`
建议:
- 先选中你关心的任务
- 再看右侧 `Task Detail / Steps / Logs`
## Task Detail
显示当前选中任务的核心信息:
- `Task ID`
- `Status`
- `Title`
- `Source`
- `Created`
- `Updated`
上方有 3 个操作按钮:
- `执行当前任务`
- `重试选中 Step`
- `重置到选中 Step`
### 执行当前任务
作用:
- 手动推进当前任务一次
适合:
- 你刚改完配置
- 你不想等下一轮 worker 轮询
### 重试选中 Step
作用:
- 从当前选中的 step 重新尝试
适合:
- 某一步是临时失败
- 不需要删除后续产物
### 重置到选中 Step
作用:
- 清理该 step 之后的产物和标记
- 把任务回拨到该 step
- 然后重新执行
适合:
- 后续结果已经不可信
- 需要从某一步重新跑整段链路
注意:
- 这是破坏性动作
- 页面会要求确认
## Steps
这里显示任务步骤列表,例如:
- `ingest`
- `transcribe`
- `song_detect`
- `split`
- `publish`
- `comment`
- `collection_a`
- `collection_b`
点击某个 step 之后:
- 这个 step 会成为“当前选中 step”
- 然后你就可以点:
- `重试选中 Step`
- `重置到选中 Step`
排查原则:
- `transcribe` 失败:先看 `Groq API Key``ffmpeg`
- `song_detect` 失败:先看 `song_detect.provider`,再看 `codex_cmd``qwen_cmd`
- `publish` 失败:先看 `cookies.json``biliup`
- `collection_*` 失败:再看任务历史和日志
评论规则补充:
- `comment`
- 纯享版视频下默认发 session 级聚合“编号歌单”
- 内容按 `P1/P2/P3` 分组
- 同一 session 只由 anchor task 发一次
- 完整版主视频下默认才发 session 级“带时间轴评论”
- 内容按 `P1/P2/P3` 分组
- 同一 session 只由 anchor task 发一次
- 如果当前任务找不到 `full_video_bvid.txt`,也没能从最近发布列表解析出完整版 BV主视频评论会跳过
session 规则补充:
- 同主播、文件名时间 `3` 小时内的任务会自动归到同一 session
- session 的 `session_key` 使用最早片段标题
- 同一 session 内:
- 只有 anchor task 真正执行纯享版上传
- 纯享版上传会聚合整组 `split_video`
- 整组 task 共用同一个 `bvid.txt`
- split/full 评论都只发一次
## Artifacts
这里显示任务当前已经产出的文件,例如:
- `source_video`
- `subtitle_srt`
- `songs_json`
- `songs_txt`
- `clip_video`
- `publish_bvid`
用途:
- 判断任务跑到了哪一步
- 判断关键输出是否已经落盘
如果开启了 cleanup 配置,任务在 `collection_synced` 后:
- `source_video` 对应的原始视频可能会被删除
- `clip_video` 对应的 `split_video/` 目录也可能被清理
这是正常收尾行为,不代表任务失败。
## History
这里是单任务动作历史。
`Recent Actions` 的区别:
- `Recent Actions` 看全局
- `History` 只看当前任务
可以看到:
- 动作名
- 状态
- 摘要
- 时间
- 结构化 `details_json`
用途:
- 判断某次 `retry``reset` 的结果
- 判断 worker 最近对这个任务做了什么
## Timeline
这里把任务事件串成一条时间线:
- `Task Created`
- step started / finished
- artifact created
- action records
适合:
- 回放任务完整过程
- 查清楚任务到底卡在什么时候
## Modules
这里显示当前注册的模块 / provider。
用途:
- 确认当前用的是哪套 provider
- 确认模块有没有注册成功
如果将来切 provider这里会很有用。
## Doctor Checks
这里比顶部的 `Doctor = OK/FAIL` 更详细。
会列出:
- workspace 目录
- `cookies_file`
- `upload_config_file`
- `ffprobe`
- `ffmpeg`
- `codex_cmd``qwen_cmd`
- `biliup_path`
如果某个依赖显示 `(external)`,表示它还在用系统或父项目路径,不是 `biliup-next` 自己目录内的副本。
## Logs
这里可以看日志文件。
支持:
- 切换日志文件
- 刷新日志
- 按当前任务标题过滤
使用建议:
- 任务异常时,先选中任务
- 再勾选“按当前任务标题过滤”
- 然后查看相关日志
这样比直接翻整份日志快很多。
## Settings
Settings 分成两层:
- 上半部分schema 驱动表单
- 下半部分:`Advanced JSON Editor`
### 表单区
这里适合日常参数调整,例如:
- `min_duration_seconds`
- `groq_api_key`
- `codex_cmd`
- `qwen_cmd`
- `retry_count`
- `season_id_a`
- `season_id_b`
- `post_split_comment`
- `post_full_video_timeline_comment`
- `delete_source_video_after_collection_synced`
- `delete_split_videos_after_collection_synced`
支持:
- 搜索配置项
- 高频参数优先展示
- 低频参数收纳到 `Advanced Settings`
### Advanced JSON Editor
适合:
- 批量调整
- 一次改多个字段
- 需要直接编辑原始 JSON
页面上有两个同步按钮:
- `表单同步到 JSON`
- `JSON 重绘表单`
### 敏感字段规则
敏感字段会显示为:
```text
__BILIUP_NEXT_SECRET__
```
规则:
- 保留占位符:不改原值
- 改成空字符串:清空原值
- 改成新的字符串:更新为新值
## 推荐操作流
### 新上传一个测试视频
1. 打开控制台
2.`Import To Stage` 上传视频
3.`Tasks` 是否出现新任务
4. 选中该任务
5. 观察 `Steps / Artifacts / Timeline`
6. 如需加速,点击 `执行当前任务`
### 某个任务失败
1. 选中任务
2.`Task Detail` 当前状态
3.`Steps` 哪一步失败
4.`Logs`
5. 若只是临时失败:
- 选中 step
-`重试选中 Step`
6. 若后续产物也需要重建:
- 选中 step
-`重置到选中 Step`
### 修改合集或上传参数
1. 打开 `Settings`
2. 搜索目标参数,例如 `season` / `retry`
3. 修改表单
4. 点击 `保存 Settings`
5. 对目标任务执行:
- `执行当前任务`
-`重置到相关 step`
### 控制评论与清理行为
1. 打开 `Settings`
2. 搜索:
- `post_split_comment`
- `post_full_video_timeline_comment`
- `delete_source_video_after_collection_synced`
- `delete_split_videos_after_collection_synced`
3. 修改后保存
4. 新任务会按新规则执行
建议:
- 如果你希望纯享版评论以 session 级聚合歌单展示,保持 `post_split_comment = true`
- 如果你不希望尝试给完整版主视频发时间轴评论,可以关闭 `post_full_video_timeline_comment`
- 如果磁盘紧张,再开启 cleanup默认建议先关闭等确认流程稳定后再开
### 服务异常
1.`Services`
2. 优先 `restart biliup-next-worker.service`
3. 如果页面自身异常,再 `restart biliup-next-api.service`
4. 重启后看:
- `Health`
- `Doctor`
- `Recent Actions`
## 安全建议
当前控制台已经对公网开放时,建议立刻设置:
- `runtime.control_token`
设置后:
-`/``/health` 外,其余 API 都要求 `X-Biliup-Token`
如果你通过公网访问控制台,不建议长期保持空 token。

View File

@ -1,243 +1,243 @@
# Design Principles
## Positioning
`biliup-next` 以 OpenClaw 的设计哲学为指引,但不复制它的产品形态。
本项目的核心目标不是做聊天代理系统,而是构建一个面向本地视频流水线的控制面驱动系统。
因此我们借鉴的是方法论:
- 单体优先
- 控制面优先
- 配置与扩展元数据优先
- 严格校验
- 本地优先
- 可读、可审计、可替换
## Principle 1: Modular Monolith First
系统优先采用模块化单体架构,而不是微服务。
原因:
- 当前问题主要来自边界混乱,而不是部署扩展性不足
- 单机部署和 systemd 管理仍然是核心场景
- 统一配置、任务状态和日志比进程拆分更重要
约束:
- 所有模块运行在同一系统边界内
- 模块之间通过抽象接口和统一模型交互
- 不得通过随意脚本调用形成隐式耦合
## Principle 2: Control Plane First
功能模块不是系统中心,控制面才是系统中心。
控制面负责:
- 配置管理
- 任务状态管理
- 模块与插件注册
- 手动操作入口
- 日志与诊断
数据面负责:
- 执行转录
- 执行识歌
- 执行切歌
- 执行上传
- 执行评论和合集归档
任何新增功能,都必须先回答:
- 它如何进入控制面
- 它的状态如何呈现
- 它的配置如何管理
- 它失败后如何恢复
## Principle 3: Schema-First Configuration
配置必须先有 schema再有实现和 UI。
要求:
- 所有配置项先定义在 schema
- 所有配置项有默认值、校验规则和说明
- UI 基于 schema 生成
- CLI 和 API 使用同一套字段定义
禁止:
- 在模块里私自增加隐藏配置常量
- UI 和代码维护不同字段名
- 配置错误仍然带病启动
## Principle 4: Manifest-First Extensibility
扩展能力先注册元数据,再执行运行时代码。
manifest 负责描述:
- 插件是谁
- 提供什么能力
- 需要什么配置
- 入口在哪
- 是否可启用
这样控制面可以在不执行插件代码时完成:
- 能力发现
- 配置渲染
- 可用性检查
- 兼容性检查
## Principle 5: Registry Over Direct Coupling
模块和插件必须通过统一 registry 接入。
例如:
- transcriber registry
- song detector registry
- publisher registry
- collection strategy registry
核心模块只依赖接口,不依赖具体 provider。
这意味着:
- 更换 Groq 为其他转录器不影响任务引擎
- 更换 Codex 为其他识歌器不影响控制面
- 更换 biliup 为其他上传方式不影响领域模型
## Principle 6: Local-First And Human-Readable
系统优先本地运行,本地保存,本地可读。
要求:
- 主状态存储可本地访问
- 日志保存在本地
- 配置保存在本地
- 关键元数据可被开发者直接理解
这不意味着只靠文件系统。
建议做法:
- SQLite 保存结构化状态
- 文件系统保存产物
- JSON / YAML 保存配置
- 文本日志保存审计和错误
## Principle 7: Strict Validation
系统不能接受“差不多能跑”的配置和模块状态。
启动或配置变更前,应验证:
- schema 是否合法
- 可执行依赖是否存在
- 必要凭证是否存在
- provider 是否可初始化
- 插件 manifest 是否正确
失败时:
- 保留旧运行态
- 返回明确错误
- 不进入半失效状态
## Principle 8: Single Source Of Truth
任务状态必须有统一来源。
不得同时依赖:
- flag 文件推断状态
- 日志推断状态
- 目录结构推断状态
正确做法:
- 数据库记录任务状态
- 文件系统存放任务产物
- 日志记录过程和诊断
三者职责分离,不互相替代。
补充:
- 工作区 flag 可以保留,用于表示某些外部动作已经发生,例如评论、合集、上传等副作用完成。
- 但这些 flag 不应被提升为 task 主状态本身。
## Principle 9: Replaceability With Stable Core
可替换的是 provider不可随意漂移的是核心模型。
稳定核心包括:
- Task
- TaskStep
- Artifact
- PublishRecord
- CollectionBinding
- Settings
这些模型一旦定义,应保持长期稳定,避免每新增一个模块就改核心语义。
## Principle 10: Observability Is A First-Class Feature
可观测性不是补丁,而是正式能力。
管理台必须能够回答:
- 当前有哪些任务
- 每个任务在哪一步
- 最近一次失败是什么
- 当前启用的 provider 是谁
- 当前配置是否有效
- 哪个模块健康异常
如果系统不能快速回答这些问题,说明设计不完整。
## Principle 11: Backward-Compatible Migration
重构必须与旧系统并行推进。
要求:
- 原项目继续运行
- 新项目只在 `./biliup-next` 演进
- 先搭文档和骨架
- 再逐步迁移模块
- 最后切换生产入口
禁止:
- 直接在旧系统里边修边重构
- 在未定义状态模型前大规模搬代码
## Principle 12: Documentation Before Expansion
任何新的模块、插件、控制面能力,在动手实现前都要先回答:
- 它属于哪个层
- 它的输入输出是什么
- 它如何配置
- 它如何注册
- 它如何被 UI 展示
- 它如何失败和恢复
如果这些问题没有写清楚,就不应进入实现阶段。
## Summary
`biliup-next` 的核心方向不是“把旧脚本改漂亮”,而是:
- 建立一个本地优先、控制面驱动、模块边界清晰的系统
- 让配置、模块、状态、操作和诊断都回到统一模型之下
- 让未来扩展建立在 schema、manifest、registry 和稳定领域模型之上
# Design Principles
## Positioning
`biliup-next` 以 OpenClaw 的设计哲学为指引,但不复制它的产品形态。
本项目的核心目标不是做聊天代理系统,而是构建一个面向本地视频流水线的控制面驱动系统。
因此我们借鉴的是方法论:
- 单体优先
- 控制面优先
- 配置与扩展元数据优先
- 严格校验
- 本地优先
- 可读、可审计、可替换
## Principle 1: Modular Monolith First
系统优先采用模块化单体架构,而不是微服务。
原因:
- 当前问题主要来自边界混乱,而不是部署扩展性不足
- 单机部署和 systemd 管理仍然是核心场景
- 统一配置、任务状态和日志比进程拆分更重要
约束:
- 所有模块运行在同一系统边界内
- 模块之间通过抽象接口和统一模型交互
- 不得通过随意脚本调用形成隐式耦合
## Principle 2: Control Plane First
功能模块不是系统中心,控制面才是系统中心。
控制面负责:
- 配置管理
- 任务状态管理
- 模块与插件注册
- 手动操作入口
- 日志与诊断
数据面负责:
- 执行转录
- 执行识歌
- 执行切歌
- 执行上传
- 执行评论和合集归档
任何新增功能,都必须先回答:
- 它如何进入控制面
- 它的状态如何呈现
- 它的配置如何管理
- 它失败后如何恢复
## Principle 3: Schema-First Configuration
配置必须先有 schema再有实现和 UI。
要求:
- 所有配置项先定义在 schema
- 所有配置项有默认值、校验规则和说明
- UI 基于 schema 生成
- CLI 和 API 使用同一套字段定义
禁止:
- 在模块里私自增加隐藏配置常量
- UI 和代码维护不同字段名
- 配置错误仍然带病启动
## Principle 4: Manifest-First Extensibility
扩展能力先注册元数据,再执行运行时代码。
manifest 负责描述:
- 插件是谁
- 提供什么能力
- 需要什么配置
- 入口在哪
- 是否可启用
这样控制面可以在不执行插件代码时完成:
- 能力发现
- 配置渲染
- 可用性检查
- 兼容性检查
## Principle 5: Registry Over Direct Coupling
模块和插件必须通过统一 registry 接入。
例如:
- transcriber registry
- song detector registry
- publisher registry
- collection strategy registry
核心模块只依赖接口,不依赖具体 provider。
这意味着:
- 更换 Groq 为其他转录器不影响任务引擎
- 更换 Codex 为其他识歌器不影响控制面
- 更换 biliup 为其他上传方式不影响领域模型
## Principle 6: Local-First And Human-Readable
系统优先本地运行,本地保存,本地可读。
要求:
- 主状态存储可本地访问
- 日志保存在本地
- 配置保存在本地
- 关键元数据可被开发者直接理解
这不意味着只靠文件系统。
建议做法:
- SQLite 保存结构化状态
- 文件系统保存产物
- JSON / YAML 保存配置
- 文本日志保存审计和错误
## Principle 7: Strict Validation
系统不能接受“差不多能跑”的配置和模块状态。
启动或配置变更前,应验证:
- schema 是否合法
- 可执行依赖是否存在
- 必要凭证是否存在
- provider 是否可初始化
- 插件 manifest 是否正确
失败时:
- 保留旧运行态
- 返回明确错误
- 不进入半失效状态
## Principle 8: Single Source Of Truth
任务状态必须有统一来源。
不得同时依赖:
- flag 文件推断状态
- 日志推断状态
- 目录结构推断状态
正确做法:
- 数据库记录任务状态
- 文件系统存放任务产物
- 日志记录过程和诊断
三者职责分离,不互相替代。
补充:
- 工作区 flag 可以保留,用于表示某些外部动作已经发生,例如评论、合集、上传等副作用完成。
- 但这些 flag 不应被提升为 task 主状态本身。
## Principle 9: Replaceability With Stable Core
可替换的是 provider不可随意漂移的是核心模型。
稳定核心包括:
- Task
- TaskStep
- Artifact
- PublishRecord
- CollectionBinding
- Settings
这些模型一旦定义,应保持长期稳定,避免每新增一个模块就改核心语义。
## Principle 10: Observability Is A First-Class Feature
可观测性不是补丁,而是正式能力。
管理台必须能够回答:
- 当前有哪些任务
- 每个任务在哪一步
- 最近一次失败是什么
- 当前启用的 provider 是谁
- 当前配置是否有效
- 哪个模块健康异常
如果系统不能快速回答这些问题,说明设计不完整。
## Principle 11: Backward-Compatible Migration
重构必须与旧系统并行推进。
要求:
- 原项目继续运行
- 新项目只在 `./biliup-next` 演进
- 先搭文档和骨架
- 再逐步迁移模块
- 最后切换生产入口
禁止:
- 直接在旧系统里边修边重构
- 在未定义状态模型前大规模搬代码
## Principle 12: Documentation Before Expansion
任何新的模块、插件、控制面能力,在动手实现前都要先回答:
- 它属于哪个层
- 它的输入输出是什么
- 它如何配置
- 它如何注册
- 它如何被 UI 展示
- 它如何失败和恢复
如果这些问题没有写清楚,就不应进入实现阶段。
## Summary
`biliup-next` 的核心方向不是“把旧脚本改漂亮”,而是:
- 建立一个本地优先、控制面驱动、模块边界清晰的系统
- 让配置、模块、状态、操作和诊断都回到统一模型之下
- 让未来扩展建立在 schema、manifest、registry 和稳定领域模型之上

View File

@ -75,7 +75,7 @@
"platform": "bilibili",
"aid": 123456,
"bvid": "BV1xxxx",
"title": "【王海颖 (歌曲纯享版)】_03月29日 22时02分 共18首歌",
"title": "【王海颖 (歌曲纯享版)】 03月29日 22时02分 共18首歌",
"published_at": "2026-03-30T07:56:13+08:00"
}
```

View File

@ -1,335 +1,335 @@
# Frontend Implementation Checklist
## Goal
把当前 `biliup-next` 已有的后端能力,整理成前端可直接开发的任务清单。
这份清单面向前端开发,不讨论后端架构,只回答 3 个问题:
1. 先做哪些页面最值钱
2. 每个页面要拆哪些组件
3. 每个组件依赖哪些接口和字段
## Priority
建议按这个顺序推进:
1. 任务列表页状态升级
2. 任务详情页
3. 手工绑定完整版 BV
4. Session 合并 / 重绑
5. 设置页常用配置强化
## Milestone 1: 任务列表页状态升级
目标:
- 用户一眼看懂任务是在运行、等待、失败还是完成
- 不需要理解内部状态机字段
### 页面任务
- 把当前任务列表中的内部状态替换成用户态状态
- 在任务列表中增加“当前步骤”列
- 在任务列表中增加“下次重试时间”列
- 在任务列表中增加“分P BV / 完整版 BV”列
- 在任务列表中增加“评论 / 合集 / 清理”状态列
### 组件任务
- `TaskStatusBadge`
- 输入:`task.status`, `task.retry_state`, `steps`
- 输出:`已接收 / 上传中 / 等待B站可见 / 需人工处理 / 已完成`
- `TaskStepBadge`
- 输入:`steps`
- 输出当前步骤文案
- `TaskDeliverySummary`
- 输入:`delivery_state`, `session_context`
- 输出:
- 分P BV
- 完整版 BV
- 评论状态
- 合集状态
- 清理状态
### 接口依赖
- `GET /tasks`
### 建议后端字段
- 现有可直接使用:
- `status`
- `retry_state`
- `delivery_state`
- `session_context`
- 建议前端先本地派生:
- `display_status`
- `current_step`
## Milestone 2: 任务详情页
目标:
- 用户不看日志也能知道这个任务发生了什么
- 用户能在单任务页完成最常见修复动作
### 页面任务
- 新建任务详情页 Hero 区
- 新建步骤时间线
- 新建交付结果卡片
- 新建 Session 信息卡片
- 新建产物列表卡片
- 新建历史动作卡片
- 新建错误说明卡片
### 组件任务
- `TaskHero`
- 标题
- 用户态状态
- 当前步骤
- 下次重试时间
- `TaskTimeline`
- ingest -> collection_b 全步骤
- `TaskDeliveryPanel`
- 分P `BV`
- 完整版 `BV`
- 分P链接
- 完整版链接
- 合集状态
- `TaskSessionPanel`
- `session_key`
- `streamer`
- `room_id`
- `segment_started_at`
- `segment_duration_seconds`
- `context_source`
- `TaskArtifactsPanel`
- source_video
- subtitle_srt
- songs.json
- songs.txt
- clip_video
- `TaskActionsPanel`
- 运行
- 重试
- 重置
- 绑定完整版 BV
### 接口依赖
- `GET /tasks/<id>`
- `GET /tasks/<id>/steps`
- `GET /tasks/<id>/artifacts`
- `GET /tasks/<id>/history`
- `GET /tasks/<id>/timeline`
- `GET /tasks/<id>/context`
### 操作接口依赖
- `POST /tasks/<id>/actions/run`
- `POST /tasks/<id>/actions/retry-step`
- `POST /tasks/<id>/actions/reset-to-step`
## Milestone 3: 手工绑定完整版 BV
目标:
- 用户在前端直接补 `full_video_bvid`
- 不需要再手工写 `full_video_bvid.txt`
### 页面任务
- 在任务详情页增加“绑定完整版 BV”表单
- 显示当前已绑定 BV
- 显示绑定来源:
- fallback
- task_context
- meta_sidecar
- webhook
### 组件任务
- `BindFullVideoForm`
- 输入框:`BV...`
- 提交按钮
- 成功反馈
- 错误反馈
### 接口依赖
- `POST /tasks/<id>/bind-full-video`
### 交互要求
- 提交前本地校验 `BV[0-9A-Za-z]+`
- 成功后刷新:
- `GET /tasks/<id>`
- `GET /tasks/<id>/context`
## Milestone 4: Session 合并 / 重绑
目标:
- 用户能处理“同一场多个断流片段”
- 用户能统一给整个 session 重绑完整版 BV
### 页面任务
- 在任务详情页显示当前任务所属 session
- 增加“查看同 session 任务”入口
- 增加“合并到现有 session”弹窗
- 增加“整个 session 重绑完整版 BV”表单
### 组件任务
- `SessionSummaryCard`
- `session_key`
- task count
- 当前 `full_video_bvid`
- `SessionTaskList`
- 列出该 session 下所有任务
- `MergeSessionDialog`
- 输入目标 `session_key`
- 选择任务
- `RebindSessionForm`
- 输入新的完整版 `BV`
### 接口依赖
- `GET /sessions/<session_key>`
- `POST /sessions/<session_key>/merge`
- `POST /sessions/<session_key>/rebind`
### 交互要求
- 合并成功后刷新:
- 当前任务详情
- session 详情
- 任务列表
- 如果目标 session 已有 `full_video_bvid`
- 前端提示“合并后会继承该完整版 BV”
## Milestone 5: 设置页常用配置强化
目标:
- 用户无需直接改 JSON 就能调优常用行为
### 页面任务
- 在设置页高亮常用 ingest/session 配置
- 在设置页高亮 comment 重试配置
- 在设置页高亮 cleanup 配置
### 应优先暴露的配置
- `ingest.session_gap_minutes`
- `ingest.meta_sidecar_enabled`
- `ingest.meta_sidecar_suffix`
- `comment.max_retries`
- `comment.base_delay_seconds`
- `cleanup.delete_source_video_after_collection_synced`
- `cleanup.delete_split_videos_after_collection_synced`
### 接口依赖
- `GET /settings`
- `GET /settings/schema`
- `PUT /settings`
## Common UX Rules
### 状态文案
- `failed_retryable` 不显示“失败”
- 优先显示:
- `等待自动重试`
- `等待B站可见`
- `正在处理中`
- `需人工处理`
### 错误提示
错误提示统一分成 2 行:
- 原因
- 建议动作
例如:
- 原因视频刚上传B站暂未可见
- 建议:系统会自动重试,无需人工处理
### 操作反馈
所有写操作都要有:
- loading 态
- 成功 toast
- 错误 toast
### 刷新策略
这些动作成功后必须自动刷新详情数据:
- `retry-step`
- `reset-to-step`
- `bind-full-video`
- `session merge`
- `session rebind`
## Suggested Frontend Types
建议前端统一定义这些类型:
```ts
type TaskDisplayStatus =
| "accepted"
| "processing"
| "waiting_retry"
| "waiting_visibility"
| "manual_action"
| "done";
type TaskSessionContext = {
task_id: string;
session_key: string | null;
streamer: string | null;
room_id: string | null;
source_title: string | null;
segment_started_at: string | null;
segment_duration_seconds: number | null;
full_video_bvid: string | null;
split_bvid: string | null;
context_source: string;
video_links: {
split_video_url: string | null;
full_video_url: string | null;
};
};
```
## Suggested Build Order Inside Frontend Repo
建议按这个顺序拆 PR
1. 状态映射工具函数
2. 任务列表页文案升级
3. 任务详情页 Session/Delivery 面板
4. 绑定完整版 BV 表单
5. Session 合并 / 重绑弹窗
6. 设置页常用配置高亮
## Definition Of Done
这一轮前端完成的标准建议是:
- 用户可以在任务列表页看懂所有任务当前状态
- 用户可以在任务详情页看到分P/完整版 BV 和链接
- 用户可以手工绑定完整版 BV
- 用户可以把多个任务合并为同一个 session
- 用户可以给整个 session 重绑完整版 BV
- 用户不需要 ssh 登录机器改 txt 文件
# Frontend Implementation Checklist
## Goal
把当前 `biliup-next` 已有的后端能力,整理成前端可直接开发的任务清单。
这份清单面向前端开发,不讨论后端架构,只回答 3 个问题:
1. 先做哪些页面最值钱
2. 每个页面要拆哪些组件
3. 每个组件依赖哪些接口和字段
## Priority
建议按这个顺序推进:
1. 任务列表页状态升级
2. 任务详情页
3. 手工绑定完整版 BV
4. Session 合并 / 重绑
5. 设置页常用配置强化
## Milestone 1: 任务列表页状态升级
目标:
- 用户一眼看懂任务是在运行、等待、失败还是完成
- 不需要理解内部状态机字段
### 页面任务
- 把当前任务列表中的内部状态替换成用户态状态
- 在任务列表中增加“当前步骤”列
- 在任务列表中增加“下次重试时间”列
- 在任务列表中增加“分P BV / 完整版 BV”列
- 在任务列表中增加“评论 / 合集 / 清理”状态列
### 组件任务
- `TaskStatusBadge`
- 输入:`task.status`, `task.retry_state`, `steps`
- 输出:`已接收 / 上传中 / 等待B站可见 / 需人工处理 / 已完成`
- `TaskStepBadge`
- 输入:`steps`
- 输出当前步骤文案
- `TaskDeliverySummary`
- 输入:`delivery_state`, `session_context`
- 输出:
- 分P BV
- 完整版 BV
- 评论状态
- 合集状态
- 清理状态
### 接口依赖
- `GET /tasks`
### 建议后端字段
- 现有可直接使用:
- `status`
- `retry_state`
- `delivery_state`
- `session_context`
- 建议前端先本地派生:
- `display_status`
- `current_step`
## Milestone 2: 任务详情页
目标:
- 用户不看日志也能知道这个任务发生了什么
- 用户能在单任务页完成最常见修复动作
### 页面任务
- 新建任务详情页 Hero 区
- 新建步骤时间线
- 新建交付结果卡片
- 新建 Session 信息卡片
- 新建产物列表卡片
- 新建历史动作卡片
- 新建错误说明卡片
### 组件任务
- `TaskHero`
- 标题
- 用户态状态
- 当前步骤
- 下次重试时间
- `TaskTimeline`
- ingest -> collection_b 全步骤
- `TaskDeliveryPanel`
- 分P `BV`
- 完整版 `BV`
- 分P链接
- 完整版链接
- 合集状态
- `TaskSessionPanel`
- `session_key`
- `streamer`
- `room_id`
- `segment_started_at`
- `segment_duration_seconds`
- `context_source`
- `TaskArtifactsPanel`
- source_video
- subtitle_srt
- songs.json
- songs.txt
- clip_video
- `TaskActionsPanel`
- 运行
- 重试
- 重置
- 绑定完整版 BV
### 接口依赖
- `GET /tasks/<id>`
- `GET /tasks/<id>/steps`
- `GET /tasks/<id>/artifacts`
- `GET /tasks/<id>/history`
- `GET /tasks/<id>/timeline`
- `GET /tasks/<id>/context`
### 操作接口依赖
- `POST /tasks/<id>/actions/run`
- `POST /tasks/<id>/actions/retry-step`
- `POST /tasks/<id>/actions/reset-to-step`
## Milestone 3: 手工绑定完整版 BV
目标:
- 用户在前端直接补 `full_video_bvid`
- 不需要再手工写 `full_video_bvid.txt`
### 页面任务
- 在任务详情页增加“绑定完整版 BV”表单
- 显示当前已绑定 BV
- 显示绑定来源:
- fallback
- task_context
- meta_sidecar
- webhook
### 组件任务
- `BindFullVideoForm`
- 输入框:`BV...`
- 提交按钮
- 成功反馈
- 错误反馈
### 接口依赖
- `POST /tasks/<id>/bind-full-video`
### 交互要求
- 提交前本地校验 `BV[0-9A-Za-z]+`
- 成功后刷新:
- `GET /tasks/<id>`
- `GET /tasks/<id>/context`
## Milestone 4: Session 合并 / 重绑
目标:
- 用户能处理“同一场多个断流片段”
- 用户能统一给整个 session 重绑完整版 BV
### 页面任务
- 在任务详情页显示当前任务所属 session
- 增加“查看同 session 任务”入口
- 增加“合并到现有 session”弹窗
- 增加“整个 session 重绑完整版 BV”表单
### 组件任务
- `SessionSummaryCard`
- `session_key`
- task count
- 当前 `full_video_bvid`
- `SessionTaskList`
- 列出该 session 下所有任务
- `MergeSessionDialog`
- 输入目标 `session_key`
- 选择任务
- `RebindSessionForm`
- 输入新的完整版 `BV`
### 接口依赖
- `GET /sessions/<session_key>`
- `POST /sessions/<session_key>/merge`
- `POST /sessions/<session_key>/rebind`
### 交互要求
- 合并成功后刷新:
- 当前任务详情
- session 详情
- 任务列表
- 如果目标 session 已有 `full_video_bvid`
- 前端提示“合并后会继承该完整版 BV”
## Milestone 5: 设置页常用配置强化
目标:
- 用户无需直接改 JSON 就能调优常用行为
### 页面任务
- 在设置页高亮常用 ingest/session 配置
- 在设置页高亮 comment 重试配置
- 在设置页高亮 cleanup 配置
### 应优先暴露的配置
- `ingest.session_gap_minutes`
- `ingest.meta_sidecar_enabled`
- `ingest.meta_sidecar_suffix`
- `comment.max_retries`
- `comment.base_delay_seconds`
- `cleanup.delete_source_video_after_collection_synced`
- `cleanup.delete_split_videos_after_collection_synced`
### 接口依赖
- `GET /settings`
- `GET /settings/schema`
- `PUT /settings`
## Common UX Rules
### 状态文案
- `failed_retryable` 不显示“失败”
- 优先显示:
- `等待自动重试`
- `等待B站可见`
- `正在处理中`
- `需人工处理`
### 错误提示
错误提示统一分成 2 行:
- 原因
- 建议动作
例如:
- 原因视频刚上传B站暂未可见
- 建议:系统会自动重试,无需人工处理
### 操作反馈
所有写操作都要有:
- loading 态
- 成功 toast
- 错误 toast
### 刷新策略
这些动作成功后必须自动刷新详情数据:
- `retry-step`
- `reset-to-step`
- `bind-full-video`
- `session merge`
- `session rebind`
## Suggested Frontend Types
建议前端统一定义这些类型:
```ts
type TaskDisplayStatus =
| "accepted"
| "processing"
| "waiting_retry"
| "waiting_visibility"
| "manual_action"
| "done";
type TaskSessionContext = {
task_id: string;
session_key: string | null;
streamer: string | null;
room_id: string | null;
source_title: string | null;
segment_started_at: string | null;
segment_duration_seconds: number | null;
full_video_bvid: string | null;
split_bvid: string | null;
context_source: string;
video_links: {
split_video_url: string | null;
full_video_url: string | null;
};
};
```
## Suggested Build Order Inside Frontend Repo
建议按这个顺序拆 PR
1. 状态映射工具函数
2. 任务列表页文案升级
3. 任务详情页 Session/Delivery 面板
4. 绑定完整版 BV 表单
5. Session 合并 / 重绑弹窗
6. 设置页常用配置高亮
## Definition Of Done
这一轮前端完成的标准建议是:
- 用户可以在任务列表页看懂所有任务当前状态
- 用户可以在任务详情页看到分P/完整版 BV 和链接
- 用户可以手工绑定完整版 BV
- 用户可以把多个任务合并为同一个 session
- 用户可以给整个 session 重绑完整版 BV
- 用户不需要 ssh 登录机器改 txt 文件

View File

@ -1,383 +1,383 @@
# Frontend Product Integration
## Goal
从用户视角,把当前 `biliup-next` 的任务状态机包装成可操作、可理解的控制面。
这份文档面向前端与后端联调,目标不是描述内部实现,而是明确:
- 前端应该有哪些页面
- 每个页面需要哪些字段
- 当前后端已经提供了哪些接口
- 哪些字段/接口还需要补
## User Goals
用户最关心的不是数据库状态,而是这 6 件事:
1. 视频有没有被接收
2. 现在卡在哪一步
3. 这是自动等待还是需要人工处理
4. 上传后的分P BV 和完整版 BV 是什么
5. 评论和合集有没有完成
6. 失败后应该点哪里恢复
因此,前端不应该直接暴露 `created/transcribed/failed_retryable` 这类内部状态,而应该提供一层用户可理解的派生展示。
## Information Architecture
建议前端固定成 4 个一级页面:
1. 总览页
2. 任务列表页
3. 任务详情页
4. 设置页
可选扩展页:
5. 日志页
6. Webhook / Sidecar 调试页
## Page Spec
### 1. 总览页
目标:让用户在 10 秒内知道系统是否正常、当前队列是否卡住。
核心模块:
- 任务摘要卡片
- 运行中
- 等待自动重试
- 需人工处理
- 已完成
- 最近 10 个任务
- 标题
- 用户态状态
- 当前步骤
- 下次重试时间
- 运行时摘要
- API 服务状态
- Worker 服务状态
- stage 目录文件数
- 最近一次调度结果
- 风险提示
- cookies 缺失
- 磁盘空间不足
- Groq/Codex/Biliup 不可用
现有接口可复用:
- `GET /health`
- `GET /doctor`
- `GET /tasks?limit=100`
- `GET /runtime/services`
- `GET /scheduler`
### 2. 任务列表页
目标:批量查看任务,快速定位失败或等待中的任务。
表格建议字段:
- 任务标题
- 用户态状态
- 当前步骤
- 完成进度
- 下次重试时间
- 分P BV
- 完整版 BV
- 评论状态
- 合集状态
- 清理状态
- 最近更新时间
筛选项建议:
- 全部
- 运行中
- 等待自动重试
- 需人工处理
- 已完成
- 仅显示未完成评论
- 仅显示未完成合集
- 仅显示未清理文件
现有接口可复用:
- `GET /tasks`
建议新增的派生字段:
- `display_status`
- `current_step`
- `progress_percent`
- `split_bvid`
- `full_video_bvid`
- `session_key`
- `session_binding_state`
### 3. 任务详情页
目标:让用户不看日志也能处理单个任务。
建议布局:
- Hero 区
- 标题
- 用户态状态
- 当前步骤
- 下次重试时间
- 主要操作按钮
- 步骤时间线
- ingest
- transcribe
- song_detect
- split
- publish
- comment
- collection_a
- collection_b
- 交付结果区
- 分P BV
- 完整版 BV
- 分P 链接
- 完整版链接
- 合集 A / B 链接
- Session 信息区
- session_key
- streamer
- room_id
- segment_started_at
- segment_duration_seconds
- 是否由 sidecar 提供
- 是否由时间连续性自动归并
- 文件与产物区
- source_video
- subtitle_srt
- songs.json
- songs.txt
- clip_video
- 历史动作区
- run
- retry-step
- reset-to-step
- 错误与建议区
- 错误码
- 错误摘要
- 系统建议动作
现有接口可复用:
- `GET /tasks/<id>`
- `GET /tasks/<id>/steps`
- `GET /tasks/<id>/artifacts`
- `GET /tasks/<id>/history`
- `GET /tasks/<id>/timeline`
- `POST /tasks/<id>/actions/run`
- `POST /tasks/<id>/actions/retry-step`
- `POST /tasks/<id>/actions/reset-to-step`
建议新增接口:
- `GET /tasks/<id>/context`
### 4. 设置页
目标:把常用配置变成可理解、可搜索、可修改的产品设置,而不是裸 JSON。
优先展示的用户级配置:
- `ingest.session_gap_minutes`
- `ingest.meta_sidecar_enabled`
- `ingest.meta_sidecar_suffix`
- `comment.max_retries`
- `comment.base_delay_seconds`
- `cleanup.delete_source_video_after_collection_synced`
- `cleanup.delete_split_videos_after_collection_synced`
- `collection.season_id_a`
- `collection.season_id_b`
现有接口可复用:
- `GET /settings`
- `GET /settings/schema`
- `PUT /settings`
## User-Facing Status Mapping
前端必须提供一层用户态状态,不要直接显示内部状态。
建议映射:
- `created` -> `已接收`
- `transcribed` -> `已转录`
- `songs_detected` -> `已识歌`
- `split_done` -> `已切片`
- `published` -> `已上传`
- `commented` -> `评论完成`
- `collection_synced` -> `已完成`
- `failed_retryable` + `step=comment` -> `等待B站可见`
- `failed_retryable` 其他 -> `等待自动重试`
- `failed_manual` -> `需人工处理`
- 任一步 `running` -> `<步骤名>处理中`
建议步骤名展示:
- `ingest` -> `接收视频`
- `transcribe` -> `转录字幕`
- `song_detect` -> `识别歌曲`
- `split` -> `切分分P`
- `publish` -> `上传分P`
- `comment` -> `发布评论`
- `collection_a` -> `加入完整版合集`
- `collection_b` -> `加入分P合集`
## API Integration
### Existing APIs That Frontend Should Reuse
- `GET /tasks`
- `GET /tasks/<id>`
- `GET /tasks/<id>/steps`
- `GET /tasks/<id>/artifacts`
- `GET /tasks/<id>/history`
- `GET /tasks/<id>/timeline`
- `POST /tasks/<id>/actions/run`
- `POST /tasks/<id>/actions/retry-step`
- `POST /tasks/<id>/actions/reset-to-step`
- `GET /settings`
- `GET /settings/schema`
- `PUT /settings`
- `GET /runtime/services`
- `POST /runtime/services/<service>/<action>`
- `POST /worker/run-once`
### Recommended New APIs
#### `GET /tasks/<id>/context`
用途:给任务详情页和 session 归并 UI 提供上下文。
返回建议:
```json
{
"task_id": "xxx",
"session_key": "王海颖:20260402T2203",
"streamer": "王海颖",
"room_id": "581192190066",
"source_title": "王海颖唱歌录播 04月02日 22时03分",
"segment_started_at": "2026-04-02T22:03:00+08:00",
"segment_duration_seconds": 4076.443,
"full_video_bvid": "BV1uH9wBsELC",
"binding_source": "meta_sidecar"
}
```
#### `POST /tasks/<id>/bind-full-video`
用途:用户在前端手工补绑完整版 BV。
请求:
```json
{
"full_video_bvid": "BV1uH9wBsELC"
}
```
#### `POST /sessions/<session_key>/merge`
用途:把多个任务手工归并到同一个 session。
请求:
```json
{
"task_ids": ["why-2205", "why-2306"]
}
```
#### `POST /sessions/<session_key>/rebind`
用途:修改 session 级完整版 BV。
请求:
```json
{
"full_video_bvid": "BV1uH9wBsELC"
}
```
## Derived Fields For UI
后端最好直接给前端这些派生字段,减少前端自行拼状态:
- `display_status`
- `display_step`
- `progress_percent`
- `split_bvid`
- `full_video_bvid`
- `video_links`
- `delivery_state`
- `retry_state`
- `session_context`
- `actions_available`
其中 `actions_available` 建议返回:
```json
{
"run": true,
"retry_step": true,
"reset_to_step": true,
"bind_full_video": true,
"merge_session": true
}
```
## Delivery State Contract
任务列表和详情页都依赖统一的交付状态模型。
建议结构:
```json
{
"split_bvid": "BV1GoDPBtEUg",
"full_video_bvid": "BV1uH9wBsELC",
"split_video_url": "https://www.bilibili.com/video/BV1GoDPBtEUg",
"full_video_url": "https://www.bilibili.com/video/BV1uH9wBsELC",
"comment_split_done": false,
"comment_full_done": false,
"collection_a_done": false,
"collection_b_done": false,
"source_video_present": true,
"split_videos_present": true
}
```
## Suggested Frontend Build Order
按实际价值排序:
1. 任务列表页状态文案升级
2. 任务详情页增加交付结果和重试说明
3. 详情页增加 session/context 区块
4. 设置页增加 session 归并相关配置
5. 增加“手工绑定完整版 BV”操作
6. 增加“合并 session”操作
## MVP Scope
如果只做一轮最小交付,建议先完成:
- 用户态状态映射
- 单任务详情页
- `GET /tasks/<id>/context`
- 手工绑定 `full_video_bvid`
- 前端重试/重置按钮统一化
这样即使 webhook 和自动 session 归并后面再完善,用户也已经能在前端完整处理问题。
# Frontend Product Integration
## Goal
从用户视角,把当前 `biliup-next` 的任务状态机包装成可操作、可理解的控制面。
这份文档面向前端与后端联调,目标不是描述内部实现,而是明确:
- 前端应该有哪些页面
- 每个页面需要哪些字段
- 当前后端已经提供了哪些接口
- 哪些字段/接口还需要补
## User Goals
用户最关心的不是数据库状态,而是这 6 件事:
1. 视频有没有被接收
2. 现在卡在哪一步
3. 这是自动等待还是需要人工处理
4. 上传后的分P BV 和完整版 BV 是什么
5. 评论和合集有没有完成
6. 失败后应该点哪里恢复
因此,前端不应该直接暴露 `created/transcribed/failed_retryable` 这类内部状态,而应该提供一层用户可理解的派生展示。
## Information Architecture
建议前端固定成 4 个一级页面:
1. 总览页
2. 任务列表页
3. 任务详情页
4. 设置页
可选扩展页:
5. 日志页
6. Webhook / Sidecar 调试页
## Page Spec
### 1. 总览页
目标:让用户在 10 秒内知道系统是否正常、当前队列是否卡住。
核心模块:
- 任务摘要卡片
- 运行中
- 等待自动重试
- 需人工处理
- 已完成
- 最近 10 个任务
- 标题
- 用户态状态
- 当前步骤
- 下次重试时间
- 运行时摘要
- API 服务状态
- Worker 服务状态
- stage 目录文件数
- 最近一次调度结果
- 风险提示
- cookies 缺失
- 磁盘空间不足
- Groq/Codex/Biliup 不可用
现有接口可复用:
- `GET /health`
- `GET /doctor`
- `GET /tasks?limit=100`
- `GET /runtime/services`
- `GET /scheduler`
### 2. 任务列表页
目标:批量查看任务,快速定位失败或等待中的任务。
表格建议字段:
- 任务标题
- 用户态状态
- 当前步骤
- 完成进度
- 下次重试时间
- 分P BV
- 完整版 BV
- 评论状态
- 合集状态
- 清理状态
- 最近更新时间
筛选项建议:
- 全部
- 运行中
- 等待自动重试
- 需人工处理
- 已完成
- 仅显示未完成评论
- 仅显示未完成合集
- 仅显示未清理文件
现有接口可复用:
- `GET /tasks`
建议新增的派生字段:
- `display_status`
- `current_step`
- `progress_percent`
- `split_bvid`
- `full_video_bvid`
- `session_key`
- `session_binding_state`
### 3. 任务详情页
目标:让用户不看日志也能处理单个任务。
建议布局:
- Hero 区
- 标题
- 用户态状态
- 当前步骤
- 下次重试时间
- 主要操作按钮
- 步骤时间线
- ingest
- transcribe
- song_detect
- split
- publish
- comment
- collection_a
- collection_b
- 交付结果区
- 分P BV
- 完整版 BV
- 分P 链接
- 完整版链接
- 合集 A / B 链接
- Session 信息区
- session_key
- streamer
- room_id
- segment_started_at
- segment_duration_seconds
- 是否由 sidecar 提供
- 是否由时间连续性自动归并
- 文件与产物区
- source_video
- subtitle_srt
- songs.json
- songs.txt
- clip_video
- 历史动作区
- run
- retry-step
- reset-to-step
- 错误与建议区
- 错误码
- 错误摘要
- 系统建议动作
现有接口可复用:
- `GET /tasks/<id>`
- `GET /tasks/<id>/steps`
- `GET /tasks/<id>/artifacts`
- `GET /tasks/<id>/history`
- `GET /tasks/<id>/timeline`
- `POST /tasks/<id>/actions/run`
- `POST /tasks/<id>/actions/retry-step`
- `POST /tasks/<id>/actions/reset-to-step`
建议新增接口:
- `GET /tasks/<id>/context`
### 4. 设置页
目标:把常用配置变成可理解、可搜索、可修改的产品设置,而不是裸 JSON。
优先展示的用户级配置:
- `ingest.session_gap_minutes`
- `ingest.meta_sidecar_enabled`
- `ingest.meta_sidecar_suffix`
- `comment.max_retries`
- `comment.base_delay_seconds`
- `cleanup.delete_source_video_after_collection_synced`
- `cleanup.delete_split_videos_after_collection_synced`
- `collection.season_id_a`
- `collection.season_id_b`
现有接口可复用:
- `GET /settings`
- `GET /settings/schema`
- `PUT /settings`
## User-Facing Status Mapping
前端必须提供一层用户态状态,不要直接显示内部状态。
建议映射:
- `created` -> `已接收`
- `transcribed` -> `已转录`
- `songs_detected` -> `已识歌`
- `split_done` -> `已切片`
- `published` -> `已上传`
- `commented` -> `评论完成`
- `collection_synced` -> `已完成`
- `failed_retryable` + `step=comment` -> `等待B站可见`
- `failed_retryable` 其他 -> `等待自动重试`
- `failed_manual` -> `需人工处理`
- 任一步 `running` -> `<步骤名>处理中`
建议步骤名展示:
- `ingest` -> `接收视频`
- `transcribe` -> `转录字幕`
- `song_detect` -> `识别歌曲`
- `split` -> `切分分P`
- `publish` -> `上传分P`
- `comment` -> `发布评论`
- `collection_a` -> `加入完整版合集`
- `collection_b` -> `加入分P合集`
## API Integration
### Existing APIs That Frontend Should Reuse
- `GET /tasks`
- `GET /tasks/<id>`
- `GET /tasks/<id>/steps`
- `GET /tasks/<id>/artifacts`
- `GET /tasks/<id>/history`
- `GET /tasks/<id>/timeline`
- `POST /tasks/<id>/actions/run`
- `POST /tasks/<id>/actions/retry-step`
- `POST /tasks/<id>/actions/reset-to-step`
- `GET /settings`
- `GET /settings/schema`
- `PUT /settings`
- `GET /runtime/services`
- `POST /runtime/services/<service>/<action>`
- `POST /worker/run-once`
### Recommended New APIs
#### `GET /tasks/<id>/context`
用途:给任务详情页和 session 归并 UI 提供上下文。
返回建议:
```json
{
"task_id": "xxx",
"session_key": "王海颖:20260402T2203",
"streamer": "王海颖",
"room_id": "581192190066",
"source_title": "王海颖唱歌录播 04月02日 22时03分",
"segment_started_at": "2026-04-02T22:03:00+08:00",
"segment_duration_seconds": 4076.443,
"full_video_bvid": "BV1uH9wBsELC",
"binding_source": "meta_sidecar"
}
```
#### `POST /tasks/<id>/bind-full-video`
用途:用户在前端手工补绑完整版 BV。
请求:
```json
{
"full_video_bvid": "BV1uH9wBsELC"
}
```
#### `POST /sessions/<session_key>/merge`
用途:把多个任务手工归并到同一个 session。
请求:
```json
{
"task_ids": ["why-2205", "why-2306"]
}
```
#### `POST /sessions/<session_key>/rebind`
用途:修改 session 级完整版 BV。
请求:
```json
{
"full_video_bvid": "BV1uH9wBsELC"
}
```
## Derived Fields For UI
后端最好直接给前端这些派生字段,减少前端自行拼状态:
- `display_status`
- `display_step`
- `progress_percent`
- `split_bvid`
- `full_video_bvid`
- `video_links`
- `delivery_state`
- `retry_state`
- `session_context`
- `actions_available`
其中 `actions_available` 建议返回:
```json
{
"run": true,
"retry_step": true,
"reset_to_step": true,
"bind_full_video": true,
"merge_session": true
}
```
## Delivery State Contract
任务列表和详情页都依赖统一的交付状态模型。
建议结构:
```json
{
"split_bvid": "BV1GoDPBtEUg",
"full_video_bvid": "BV1uH9wBsELC",
"split_video_url": "https://www.bilibili.com/video/BV1GoDPBtEUg",
"full_video_url": "https://www.bilibili.com/video/BV1uH9wBsELC",
"comment_split_done": false,
"comment_full_done": false,
"collection_a_done": false,
"collection_b_done": false,
"source_video_present": true,
"split_videos_present": true
}
```
## Suggested Frontend Build Order
按实际价值排序:
1. 任务列表页状态文案升级
2. 任务详情页增加交付结果和重试说明
3. 详情页增加 session/context 区块
4. 设置页增加 session 归并相关配置
5. 增加“手工绑定完整版 BV”操作
6. 增加“合并 session”操作
## MVP Scope
如果只做一轮最小交付,建议先完成:
- 用户态状态映射
- 单任务详情页
- `GET /tasks/<id>/context`
- 手工绑定 `full_video_bvid`
- 前端重试/重置按钮统一化
这样即使 webhook 和自动 session 归并后面再完善,用户也已经能在前端完整处理问题。

View File

@ -1,192 +1,192 @@
# Migration Plan
## Goal
在不破坏原项目运行的前提下,逐步将能力迁移到 `biliup-next`
## Migration Principles
- 原项目继续作为生产系统运行
- 新项目只在 `./biliup-next` 中演进
- 先文档、后骨架、再迁移功能
- 先控制面,后数据面
- 先兼容旧目录结构,再逐步替换旧入口
## Phase 0: Documentation Baseline
目标:
- 明确设计原则
- 明确架构分层
- 明确领域模型
- 明确配置系统和插件系统
产物:
- `vision.md`
- `architecture.md`
- `domain-model.md`
- `design-principles.md`
- `config-system.md`
- `plugin-system.md`
- `state-machine.md`
- `module-contracts.md`
- `migration-plan.md`
## Phase 1: Project Skeleton
目标:
- 建立 `biliup-next/src` 目录结构
- 建立基础 Python 包
- 建立最小配置系统
- 建立 SQLite 存储层
- 建立任务模型和状态模型
不做:
- 不迁移业务逻辑
- 不接管生产入口
## Phase 2: Control Plane MVP
目标:
- 提供最小 API
- 提供任务列表和配置读取能力
- 提供最小 CLI
- 提供 health / logs / settings / tasks 查询能力
产物:
- API server
- config service
- task repository
- runtime doctor
## Phase 3: Data Plane Adapters
目标:
- 把旧系统依赖的外部能力封装成 adapter
优先顺序:
1. `ffmpeg` adapter
2. `Groq` adapter
3. `Codex` adapter
4. `biliup` adapter
5. `Bili API` adapter
理由:
- 先封装外部依赖,后迁移业务模块,能减少后续反复返工
## Phase 4: Module Migration
按顺序迁移业务模块。
### 4.1 Ingest
- 替代 `monitor.py` 的任务创建部分
### 4.2 Transcribe
- 替代 `video2srt.py`
### 4.3 Song Detect
- 替代 `monitorSrt.py`
### 4.4 Split
- 替代 `monitorSongs.py`
### 4.5 Publish
- 替代 `upload.py`
### 4.6 Comment
- 替代 `session_top_comment.py`
### 4.7 Collection
- 替代 `add_to_collection.py`
## Phase 5: Parallel Verification
目标:
- 新旧系统并行验证
- 新系统只处理测试任务
- 对比产物、日志、状态和 B 站结果
重点检查:
- 字幕结果
- 歌曲识别结果
- 切片结果
- 上传结果
- 评论和合集结果
## Phase 6: Admin UI
目标:
- 构建本地控制台
第一版包含:
- 配置页
- 任务页
- 模块页
- 日志页
- 手动操作页
## Phase 7: Cutover
目标:
- 新系统逐步接管生产入口
顺序建议:
1. 只接管任务可视化
2. 接管配置管理
3. 接管测试任务处理
4. 接管单模块生产流量
5. 最终接管全部生产流量
## Risks
### Risk 1: 旧系统与新系统语义不一致
缓解:
- 先定义领域模型和状态机
- 迁移前写适配层,不直接照抄旧脚本行为
### Risk 2: 边迁移边污染旧项目
缓解:
- 所有新内容只放在 `./biliup-next`
- 不改原项目运行入口
### Risk 3: UI 先行导致底层不稳
缓解:
- 先做控制面模型和 API
- 最后做 UI
## Definition Of Done
迁移完成的标准不是“代码搬完”,而是:
- 新系统可以独立运行
- 配置统一管理
- 状态统一落库
- UI 可以完整观察任务
- 旧脚本不再是主入口
# Migration Plan
## Goal
在不破坏原项目运行的前提下,逐步将能力迁移到 `biliup-next`
## Migration Principles
- 原项目继续作为生产系统运行
- 新项目只在 `./biliup-next` 中演进
- 先文档、后骨架、再迁移功能
- 先控制面,后数据面
- 先兼容旧目录结构,再逐步替换旧入口
## Phase 0: Documentation Baseline
目标:
- 明确设计原则
- 明确架构分层
- 明确领域模型
- 明确配置系统和插件系统
产物:
- `vision.md`
- `architecture.md`
- `domain-model.md`
- `design-principles.md`
- `config-system.md`
- `plugin-system.md`
- `state-machine.md`
- `module-contracts.md`
- `migration-plan.md`
## Phase 1: Project Skeleton
目标:
- 建立 `biliup-next/src` 目录结构
- 建立基础 Python 包
- 建立最小配置系统
- 建立 SQLite 存储层
- 建立任务模型和状态模型
不做:
- 不迁移业务逻辑
- 不接管生产入口
## Phase 2: Control Plane MVP
目标:
- 提供最小 API
- 提供任务列表和配置读取能力
- 提供最小 CLI
- 提供 health / logs / settings / tasks 查询能力
产物:
- API server
- config service
- task repository
- runtime doctor
## Phase 3: Data Plane Adapters
目标:
- 把旧系统依赖的外部能力封装成 adapter
优先顺序:
1. `ffmpeg` adapter
2. `Groq` adapter
3. `Codex` adapter
4. `biliup` adapter
5. `Bili API` adapter
理由:
- 先封装外部依赖,后迁移业务模块,能减少后续反复返工
## Phase 4: Module Migration
按顺序迁移业务模块。
### 4.1 Ingest
- 替代 `monitor.py` 的任务创建部分
### 4.2 Transcribe
- 替代 `video2srt.py`
### 4.3 Song Detect
- 替代 `monitorSrt.py`
### 4.4 Split
- 替代 `monitorSongs.py`
### 4.5 Publish
- 替代 `upload.py`
### 4.6 Comment
- 替代 `session_top_comment.py`
### 4.7 Collection
- 替代 `add_to_collection.py`
## Phase 5: Parallel Verification
目标:
- 新旧系统并行验证
- 新系统只处理测试任务
- 对比产物、日志、状态和 B 站结果
重点检查:
- 字幕结果
- 歌曲识别结果
- 切片结果
- 上传结果
- 评论和合集结果
## Phase 6: Admin UI
目标:
- 构建本地控制台
第一版包含:
- 配置页
- 任务页
- 模块页
- 日志页
- 手动操作页
## Phase 7: Cutover
目标:
- 新系统逐步接管生产入口
顺序建议:
1. 只接管任务可视化
2. 接管配置管理
3. 接管测试任务处理
4. 接管单模块生产流量
5. 最终接管全部生产流量
## Risks
### Risk 1: 旧系统与新系统语义不一致
缓解:
- 先定义领域模型和状态机
- 迁移前写适配层,不直接照抄旧脚本行为
### Risk 2: 边迁移边污染旧项目
缓解:
- 所有新内容只放在 `./biliup-next`
- 不改原项目运行入口
### Risk 3: UI 先行导致底层不稳
缓解:
- 先做控制面模型和 API
- 最后做 UI
## Definition Of Done
迁移完成的标准不是“代码搬完”,而是:
- 新系统可以独立运行
- 配置统一管理
- 状态统一落库
- UI 可以完整观察任务
- 旧脚本不再是主入口

View File

@ -1,229 +1,229 @@
# Module Contracts
## Goal
定义各模块的职责边界、输入输出和契约,避免旧系统中“脚本互相读目录、互相猜状态”的耦合方式。
## Contract Principles
- 每个模块只处理一类能力
- 模块只接收明确输入,不扫描全世界
- 模块输出必须结构化
- 模块不直接操控其他模块的内部实现
- 模块不直接依赖具体 provider
## Shared Concepts
所有模块统一围绕这些对象协作:
- `Task`
- `TaskStep`
- `Artifact`
- `Settings`
- `ProviderRef`
## Ingest Module
### Responsibility
- 接收文件输入
- 校验最小处理条件
- 创建任务
### Input
- 本地文件路径
- 当前配置
### Output
- `Task`
- `source_video` artifact
### Must Not Do
- 不直接转录
- 不写上传状态
## Transcribe Module
### Responsibility
- 调用转录 provider
- 生成字幕产物
### Input
- `Task`
- `source_video` artifact
- `transcribe` settings
### Output
- `subtitle_srt` artifact
### Must Not Do
- 不识别歌曲
- 不决定切歌策略
## Song Detect Module
### Responsibility
- 根据字幕识别歌曲
- 生成歌曲结构化结果
### Input
- `Task`
- `subtitle_srt` artifact
- `song_detect` settings
### Output
- `songs_json` artifact
- `songs_txt` artifact
### Must Not Do
- 不切歌
- 不上传
## Split Module
### Responsibility
- 根据歌曲列表切割纯享版片段
### Input
- `Task`
- `songs_json` artifact
- `source_video` artifact
- `split` settings
### Output
- 多个 `clip_video` artifact
## Publish Module
### Responsibility
- 上传纯享版视频
- 记录发布结果
### Input
- `Task`
- `clip_video[]`
- `publish` settings
### Output
- `PublishRecord`
- `publish_bvid` artifact
### Must Not Do
- 不负责评论文案生成
- 不负责合集匹配策略
## Comment Module
### Responsibility
- 发布并置顶评论
### Input
- `Task`
- `PublishRecord`
- `songs_txt` artifact
- `comment` settings
### Output
- `comment_record` artifact
## Collection Module
### Responsibility
- 根据策略同步合集 A / B
### Input
- `Task`
- `PublishRecord` 或外部 `full_video_bvid`
- `collection` settings
- `collection strategy`
### Output
- `CollectionBinding`
### Internal Sub-Strategies
- `full_video_collection_strategy`
- `song_collection_strategy`
## Provider Contracts
### TranscribeProvider
```text
transcribe(task, source_video, settings) -> subtitle_srt
```
### SongDetector
```text
detect(task, subtitle_srt, settings) -> songs_json, songs_txt
```
### PublishProvider
```text
publish(task, clip_videos, settings) -> PublishRecord
```
### CommentStrategy
```text
sync_comment(task, publish_record, songs_txt, settings) -> comment_record
```
### CollectionStrategy
```text
sync_collection(task, context, settings) -> CollectionBinding[]
```
## Orchestration Rules
模块本身不负责全局编排。
全局编排由任务引擎或 worker 负责:
- 判断下一步该跑什么
- 决定是否重试
- 写入状态
- 调度具体模块
## Error Contract
所有模块失败时应返回统一错误结构:
- `code`
- `message`
- `retryable`
- `details`
不得只返回原始字符串日志作为唯一错误结果。
## Non-Goals
- 模块之间不共享私有目录扫描逻辑
- 模块契约不直接暴露 shell 命令细节
# Module Contracts
## Goal
定义各模块的职责边界、输入输出和契约,避免旧系统中“脚本互相读目录、互相猜状态”的耦合方式。
## Contract Principles
- 每个模块只处理一类能力
- 模块只接收明确输入,不扫描全世界
- 模块输出必须结构化
- 模块不直接操控其他模块的内部实现
- 模块不直接依赖具体 provider
## Shared Concepts
所有模块统一围绕这些对象协作:
- `Task`
- `TaskStep`
- `Artifact`
- `Settings`
- `ProviderRef`
## Ingest Module
### Responsibility
- 接收文件输入
- 校验最小处理条件
- 创建任务
### Input
- 本地文件路径
- 当前配置
### Output
- `Task`
- `source_video` artifact
### Must Not Do
- 不直接转录
- 不写上传状态
## Transcribe Module
### Responsibility
- 调用转录 provider
- 生成字幕产物
### Input
- `Task`
- `source_video` artifact
- `transcribe` settings
### Output
- `subtitle_srt` artifact
### Must Not Do
- 不识别歌曲
- 不决定切歌策略
## Song Detect Module
### Responsibility
- 根据字幕识别歌曲
- 生成歌曲结构化结果
### Input
- `Task`
- `subtitle_srt` artifact
- `song_detect` settings
### Output
- `songs_json` artifact
- `songs_txt` artifact
### Must Not Do
- 不切歌
- 不上传
## Split Module
### Responsibility
- 根据歌曲列表切割纯享版片段
### Input
- `Task`
- `songs_json` artifact
- `source_video` artifact
- `split` settings
### Output
- 多个 `clip_video` artifact
## Publish Module
### Responsibility
- 上传纯享版视频
- 记录发布结果
### Input
- `Task`
- `clip_video[]`
- `publish` settings
### Output
- `PublishRecord`
- `publish_bvid` artifact
### Must Not Do
- 不负责评论文案生成
- 不负责合集匹配策略
## Comment Module
### Responsibility
- 发布并置顶评论
### Input
- `Task`
- `PublishRecord`
- `songs_txt` artifact
- `comment` settings
### Output
- `comment_record` artifact
## Collection Module
### Responsibility
- 根据策略同步合集 A / B
### Input
- `Task`
- `PublishRecord` 或外部 `full_video_bvid`
- `collection` settings
- `collection strategy`
### Output
- `CollectionBinding`
### Internal Sub-Strategies
- `full_video_collection_strategy`
- `song_collection_strategy`
## Provider Contracts
### TranscribeProvider
```text
transcribe(task, source_video, settings) -> subtitle_srt
```
### SongDetector
```text
detect(task, subtitle_srt, settings) -> songs_json, songs_txt
```
### PublishProvider
```text
publish(task, clip_videos, settings) -> PublishRecord
```
### CommentStrategy
```text
sync_comment(task, publish_record, songs_txt, settings) -> comment_record
```
### CollectionStrategy
```text
sync_collection(task, context, settings) -> CollectionBinding[]
```
## Orchestration Rules
模块本身不负责全局编排。
全局编排由任务引擎或 worker 负责:
- 判断下一步该跑什么
- 决定是否重试
- 写入状态
- 调度具体模块
## Error Contract
所有模块失败时应返回统一错误结构:
- `code`
- `message`
- `retryable`
- `details`
不得只返回原始字符串日志作为唯一错误结果。
## Non-Goals
- 模块之间不共享私有目录扫描逻辑
- 模块契约不直接暴露 shell 命令细节

View File

@ -1,156 +1,156 @@
# Plugin System
## Goal
插件系统的目标不是“让任何东西都能热插拔”,而是为未来的能力替换和扩展提供稳定边界。
优先支持:
- 转录提供者替换
- 歌曲识别提供者替换
- 上传器替换
- 评论策略替换
- 合集策略替换
- 输入源扩展
## Design Principles
借鉴 OpenClaw 的思路,采用 `manifest-first` + `registry` 设计。
原则:
- 插件先注册元信息,再执行运行时代码
- 控制面优先读取 manifest 和 schema
- 核心系统只依赖抽象接口和 registry
- 插件配置必须可校验
## Plugin Composition
每个插件由两部分组成:
### 1. Manifest
描述插件的元信息和配置能力。
例如:
```json
{
"id": "codex-song-detector",
"name": "Codex Song Detector",
"version": "0.1.0",
"type": "song_detector",
"entrypoint": "plugins.codex_song_detector.runtime:register",
"configSchema": "plugins/codex_song_detector/config.schema.json",
"capabilities": ["song_detect"],
"enabledByDefault": true
}
```
### 2. Runtime
真正实现业务逻辑的代码。
## Registry
系统启动时统一构建 registry。
registry 负责:
- 注册插件能力
- 按类型查找实现
- 根据配置激活当前 provider
### Registry Types
- `ingest_provider`
- `transcribe_provider`
- `song_detector`
- `split_provider`
- `publish_provider`
- `comment_strategy`
- `collection_strategy`
## Plugin Loading Flow
```text
Discover manifests
-> Validate manifests
-> Register capabilities in registry
-> Load plugin config schema
-> Validate plugin config
-> Activate runtime implementation
```
## Why Manifest-First
这样设计有 4 个直接好处:
- 管理台可以在不执行插件代码时展示插件信息
- UI 可以根据 schema 渲染配置表单
- 系统可以提前发现缺失字段或不兼容版本
- 插件运行失败不会影响元数据层的可见性
## Suggested Plugin Boundaries
### Transcribe Provider
示例:
- `groq`
- `openai`
- `local_whisper`
### Song Detector
示例:
- `codex`
- `rule_engine`
- `custom_llm`
### Publish Provider
示例:
- `biliup_cli`
- `bilibili_api`
### Collection Strategy
示例:
- `default_song_collection`
- `title_match_full_video`
- `manual_binding`
## Control Plane Integration
插件系统必须服务于控制面。
因此管理台至少需要知道:
- 当前有哪些插件
- 每个插件类型是什么
- 当前启用的是哪一个
- 配置是否有效
- 最近一次健康检查结果
## Restrictions
为了避免再次走向“任意脚本散落”,插件系统需要约束:
- 插件不得直接修改核心数据库结构
- 插件不得绕过统一配置系统
- 插件不得私自写独立日志目录作为唯一状态来源
- 插件不得直接互相调用具体实现
## Initial Strategy
第一阶段不追求真正的第三方插件生态。
先实现“内置插件化”:
- 核心仓库内提供多个 provider
- 统一用 manifest + registry 管理
- 等边界稳定后,再考虑开放外部插件目录
# Plugin System
## Goal
插件系统的目标不是“让任何东西都能热插拔”,而是为未来的能力替换和扩展提供稳定边界。
优先支持:
- 转录提供者替换
- 歌曲识别提供者替换
- 上传器替换
- 评论策略替换
- 合集策略替换
- 输入源扩展
## Design Principles
借鉴 OpenClaw 的思路,采用 `manifest-first` + `registry` 设计。
原则:
- 插件先注册元信息,再执行运行时代码
- 控制面优先读取 manifest 和 schema
- 核心系统只依赖抽象接口和 registry
- 插件配置必须可校验
## Plugin Composition
每个插件由两部分组成:
### 1. Manifest
描述插件的元信息和配置能力。
例如:
```json
{
"id": "codex-song-detector",
"name": "Codex Song Detector",
"version": "0.1.0",
"type": "song_detector",
"entrypoint": "plugins.codex_song_detector.runtime:register",
"configSchema": "plugins/codex_song_detector/config.schema.json",
"capabilities": ["song_detect"],
"enabledByDefault": true
}
```
### 2. Runtime
真正实现业务逻辑的代码。
## Registry
系统启动时统一构建 registry。
registry 负责:
- 注册插件能力
- 按类型查找实现
- 根据配置激活当前 provider
### Registry Types
- `ingest_provider`
- `transcribe_provider`
- `song_detector`
- `split_provider`
- `publish_provider`
- `comment_strategy`
- `collection_strategy`
## Plugin Loading Flow
```text
Discover manifests
-> Validate manifests
-> Register capabilities in registry
-> Load plugin config schema
-> Validate plugin config
-> Activate runtime implementation
```
## Why Manifest-First
这样设计有 4 个直接好处:
- 管理台可以在不执行插件代码时展示插件信息
- UI 可以根据 schema 渲染配置表单
- 系统可以提前发现缺失字段或不兼容版本
- 插件运行失败不会影响元数据层的可见性
## Suggested Plugin Boundaries
### Transcribe Provider
示例:
- `groq`
- `openai`
- `local_whisper`
### Song Detector
示例:
- `codex`
- `rule_engine`
- `custom_llm`
### Publish Provider
示例:
- `biliup_cli`
- `bilibili_api`
### Collection Strategy
示例:
- `default_song_collection`
- `title_match_full_video`
- `manual_binding`
## Control Plane Integration
插件系统必须服务于控制面。
因此管理台至少需要知道:
- 当前有哪些插件
- 每个插件类型是什么
- 当前启用的是哪一个
- 配置是否有效
- 最近一次健康检查结果
## Restrictions
为了避免再次走向“任意脚本散落”,插件系统需要约束:
- 插件不得直接修改核心数据库结构
- 插件不得绕过统一配置系统
- 插件不得私自写独立日志目录作为唯一状态来源
- 插件不得直接互相调用具体实现
## Initial Strategy
第一阶段不追求真正的第三方插件生态。
先实现“内置插件化”:
- 核心仓库内提供多个 provider
- 统一用 manifest + registry 管理
- 等边界稳定后,再考虑开放外部插件目录

View File

@ -1,178 +1,178 @@
# biliup-next Professionalization Roadmap - 2026-04-06
## 目标
`biliup-next` 从“方向正确的重构工程”推进到“边界清晰、契约稳定、可持续演进的专业级本地控制面系统”。
本路线图以当前仓库中已经明确吸收的 OpenClaw 设计哲学为参照:
- modular monolith
- control-plane first
- schema-first
- manifest-first
- registry over direct coupling
- single source of truth
重点不是重复这些口号,而是把它们继续落实到真实代码和工程制度中。
## 维度一:平台边界
### 当前差距
- provider 内仍大量直接调用 `subprocess``requests`
- adapter / provider / module service 的边界还不够硬
- 外部依赖的超时、重试、错误翻译和观测没有统一制度
### 目标状态
- 外部命令和外部 HTTP 都通过稳定 adapter 层进入系统
- provider 只消费标准化 adapter 能力和统一错误语义
- 超时、重试、限流、日志和诊断在 adapter 层具备统一约束
### 改进事项
-`ffmpeg``codex``biliup`、Bili API、Groq 定义统一 adapter 接口
- 将 provider 中的直接 `subprocess.run()``requests` 逐步下沉到 adapter
- 统一 adapter 错误模型,减少 provider 自己拼接临时错误码
- 为 adapter 增加可观测上下文,例如 command name、target、duration、attempt
### 完成标志
- 业务模块不再直接拼 shell/http 调用
- adapter 成为唯一外部依赖入口
## 维度二:领域模型
### 当前差距
- 核心规则分散在 `task_engine``task_policies``task_actions`、provider 和部分工作区文件
- 文档已有 domain model但还没有形成更稳定的应用服务/领域服务边界
- `task``session``full_video_bvid` 这类跨模块关系仍有隐式规则
### 目标状态
- task lifecycle、retry policy、session binding、delivery side effects 都有清晰归属
- 领域规则主要存在于少数稳定模块,而不是散落在控制器和 provider 中
- “谁负责写什么状态”有明确制度
### 改进事项
- 明确 `Task``TaskContext``SessionBinding` 的边界和 ownership
-`full_video_bvid`、session 归并、评论/合集副作用收敛成独立领域服务
- 评估是否引入显式 domain event 或最小事件记录层
- 为状态迁移建立更显式的 transition table 或 policy object
### 完成标志
- 关键规则不再分散在多个入口函数中重复实现
- task/session/delivery 的事实源和写入职责稳定
## 维度三:接口契约
### 当前差距
- API handler 仍承担较多 payload 组装和视图拼接工作
- OpenAPI 与真实控制面细节还不够同步
- 内部领域模型与外部 API 视图没有充分分层
### 目标状态
- API 对外暴露稳定 DTO而不是直接拼内部模型
- handler 更薄,组装逻辑集中在 service / presenter / serializer 层
- 契约变更可追踪、可校验
### 改进事项
- 为 task detail、task list、session detail、timeline 建立稳定 serializer
- 清理 API handler 中的重复组装逻辑
- 更新 `docs/api/openapi.yaml`,让其覆盖真实控制面接口
- 明确哪些字段属于内部实现细节,不直接暴露给前端
### 完成标志
- handler 只做路由、鉴权、输入解析和响应返回
- API 文档与真实返回结构保持同步
## 维度四:测试体系
### 当前差距
- 已有最小回归测试,但仍偏重纯逻辑
- repository、API、provider 契约、端到端场景覆盖不足
### 目标状态
- 核心编排、存储、API、adapter 都有分层测试
- 关键重构不需要依赖手工回归
### 改进事项
- 新增 repository 的 SQLite 集成测试
- 为 API handler 增加最小接口行为测试
- 为 adapter/provider 增加契约测试和失败场景测试
- 保留现有纯逻辑 unittest继续增加 smoke 回归脚本
### 完成标志
- 至少形成:
- 逻辑单元测试
- SQLite 集成测试
- API 行为测试
- smoke / regression 流程
## 维度五:运维成熟度
### 当前差距
- 已有 doctor、logs、systemd 控制和 workspace 隔离
- 但健康度、指标、审计、恢复机制还不够体系化
### 目标状态
- 控制面不仅能“看到状态”,还能帮助判断风险和恢复问题
- 运行问题可以靠结构化信号而不是人工翻日志定位
### 改进事项
- 区分 health / readiness / degraded
- 规范结构化日志字段
- 为 task/step 增加最小指标视图
- 完善审计事件分类
- 明确数据库/配置变更/运行资产的迁移与回滚流程
### 完成标志
- 常见运行问题可以靠控制面和标准日志定位
- 关键操作具备审计和回滚说明
## 推荐优先顺序
1. 平台边界
2. 领域模型
3. 接口契约
4. 测试体系
5. 运维成熟度
## 下一批优先项
### Priority A
-`biliup`、Bili API 和 `codex` 建立统一 adapter 边界
-`task_actions` 中与 session/delivery 相关的规则继续抽成稳定服务
- 为 task list / task detail / session detail 提供 serializer 层
### Priority B
- 新增 repository SQLite 集成测试
- 新增 API 行为测试
- 更新 OpenAPI 契约
### Priority C
- 设计 health/readiness/degraded 模型
- 规范日志和审计字段
## 备注
- 这份路线图描述的是“距离专业化还有哪些结构性工作”,不是说当前系统不可用。
- 当前项目已经具备正确方向;接下来的重点是把设计哲学继续固化为代码边界、测试制度和运维约束。
# biliup-next Professionalization Roadmap - 2026-04-06
## 目标
`biliup-next` 从“方向正确的重构工程”推进到“边界清晰、契约稳定、可持续演进的专业级本地控制面系统”。
本路线图以当前仓库中已经明确吸收的 OpenClaw 设计哲学为参照:
- modular monolith
- control-plane first
- schema-first
- manifest-first
- registry over direct coupling
- single source of truth
重点不是重复这些口号,而是把它们继续落实到真实代码和工程制度中。
## 维度一:平台边界
### 当前差距
- provider 内仍大量直接调用 `subprocess``requests`
- adapter / provider / module service 的边界还不够硬
- 外部依赖的超时、重试、错误翻译和观测没有统一制度
### 目标状态
- 外部命令和外部 HTTP 都通过稳定 adapter 层进入系统
- provider 只消费标准化 adapter 能力和统一错误语义
- 超时、重试、限流、日志和诊断在 adapter 层具备统一约束
### 改进事项
-`ffmpeg``codex``biliup`、Bili API、Groq 定义统一 adapter 接口
- 将 provider 中的直接 `subprocess.run()``requests` 逐步下沉到 adapter
- 统一 adapter 错误模型,减少 provider 自己拼接临时错误码
- 为 adapter 增加可观测上下文,例如 command name、target、duration、attempt
### 完成标志
- 业务模块不再直接拼 shell/http 调用
- adapter 成为唯一外部依赖入口
## 维度二:领域模型
### 当前差距
- 核心规则分散在 `task_engine``task_policies``task_actions`、provider 和部分工作区文件
- 文档已有 domain model但还没有形成更稳定的应用服务/领域服务边界
- `task``session``full_video_bvid` 这类跨模块关系仍有隐式规则
### 目标状态
- task lifecycle、retry policy、session binding、delivery side effects 都有清晰归属
- 领域规则主要存在于少数稳定模块,而不是散落在控制器和 provider 中
- “谁负责写什么状态”有明确制度
### 改进事项
- 明确 `Task``TaskContext``SessionBinding` 的边界和 ownership
-`full_video_bvid`、session 归并、评论/合集副作用收敛成独立领域服务
- 评估是否引入显式 domain event 或最小事件记录层
- 为状态迁移建立更显式的 transition table 或 policy object
### 完成标志
- 关键规则不再分散在多个入口函数中重复实现
- task/session/delivery 的事实源和写入职责稳定
## 维度三:接口契约
### 当前差距
- API handler 仍承担较多 payload 组装和视图拼接工作
- OpenAPI 与真实控制面细节还不够同步
- 内部领域模型与外部 API 视图没有充分分层
### 目标状态
- API 对外暴露稳定 DTO而不是直接拼内部模型
- handler 更薄,组装逻辑集中在 service / presenter / serializer 层
- 契约变更可追踪、可校验
### 改进事项
- 为 task detail、task list、session detail、timeline 建立稳定 serializer
- 清理 API handler 中的重复组装逻辑
- 更新 `docs/api/openapi.yaml`,让其覆盖真实控制面接口
- 明确哪些字段属于内部实现细节,不直接暴露给前端
### 完成标志
- handler 只做路由、鉴权、输入解析和响应返回
- API 文档与真实返回结构保持同步
## 维度四:测试体系
### 当前差距
- 已有最小回归测试,但仍偏重纯逻辑
- repository、API、provider 契约、端到端场景覆盖不足
### 目标状态
- 核心编排、存储、API、adapter 都有分层测试
- 关键重构不需要依赖手工回归
### 改进事项
- 新增 repository 的 SQLite 集成测试
- 为 API handler 增加最小接口行为测试
- 为 adapter/provider 增加契约测试和失败场景测试
- 保留现有纯逻辑 unittest继续增加 smoke 回归脚本
### 完成标志
- 至少形成:
- 逻辑单元测试
- SQLite 集成测试
- API 行为测试
- smoke / regression 流程
## 维度五:运维成熟度
### 当前差距
- 已有 doctor、logs、systemd 控制和 workspace 隔离
- 但健康度、指标、审计、恢复机制还不够体系化
### 目标状态
- 控制面不仅能“看到状态”,还能帮助判断风险和恢复问题
- 运行问题可以靠结构化信号而不是人工翻日志定位
### 改进事项
- 区分 health / readiness / degraded
- 规范结构化日志字段
- 为 task/step 增加最小指标视图
- 完善审计事件分类
- 明确数据库/配置变更/运行资产的迁移与回滚流程
### 完成标志
- 常见运行问题可以靠控制面和标准日志定位
- 关键操作具备审计和回滚说明
## 推荐优先顺序
1. 平台边界
2. 领域模型
3. 接口契约
4. 测试体系
5. 运维成熟度
## 下一批优先项
### Priority A
-`biliup`、Bili API 和 `codex` 建立统一 adapter 边界
-`task_actions` 中与 session/delivery 相关的规则继续抽成稳定服务
- 为 task list / task detail / session detail 提供 serializer 层
### Priority B
- 新增 repository SQLite 集成测试
- 新增 API 行为测试
- 更新 OpenAPI 契约
### Priority C
- 设计 health/readiness/degraded 模型
- 规范日志和审计字段
## 备注
- 这份路线图描述的是“距离专业化还有哪些结构性工作”,不是说当前系统不可用。
- 当前项目已经具备正确方向;接下来的重点是把设计哲学继续固化为代码边界、测试制度和运维约束。

View File

@ -0,0 +1,321 @@
# 发布输出示例与流程说明
本文档面向使用者说明 `biliup-next` 的主流程、输入输出、当前已实现功能,以及一次多段同场直播发布后的示例文案。
## 项目功能
`biliup-next` 将一场直播录播拆成两个最终发布目标:
- 直播完整版:由外部流程或人工上传到 B 站,本项目负责记录/绑定它的 BV 号,并给它补充置顶时间轴评论、加入完整版合集。
- 歌曲纯享版:由本项目从直播录播中识别歌曲、切出歌曲片段、合并发布为一个分 P 视频,并给它补充置顶歌单评论、加入纯享版合集。
当前主链路:
```text
stage 输入视频
-> ingest 导入并归并 session
-> transcribe 语音转字幕
-> song_detect 识别歌曲
-> split 切出歌曲片段
-> publish 发布歌曲纯享版
-> comment 发布/置顶评论
-> collection 加入合集
```
## 输入
最常见输入是把录播视频放入 `data/workspace/stage/`
支持的形式:
- 单个视频文件:一场直播只有一个录播文件。
- 多个视频文件:同一场直播被分成多段录播文件。
- 浏览器上传:通过控制台上传到 stage。
- 本机复制:通过控制台把服务器上的文件复制到 stage。
输入文件名会用于推测主播和直播开始时间,例如:
```text
王海颖唱歌录播 04月19日 22时10分.mp4
王海颖唱歌录播 04月19日 23时05分.mp4
王海颖唱歌录播 04月20日 00时01分.mp4
```
## Session 归并
同一主播、时间接近的多个录播片段会归入同一个 session。
同一 session 的行为:
- 只发布一个歌曲纯享版 BV。
- 多段录播的歌曲会按时间顺序聚合。
- 评论按 `P1``P2``P3` 分段展示。
- 歌曲序号全局递增,不在每个 P 内重新从 1 开始。
示例:
```text
P1:
1. 程艾影 — 赵雷
2. 钟无艳 — 谢安琪
P2:
3. 慢慢喜欢你 — 莫文蔚
P3:
4. 空白格 — 蔡健雅
```
## BV 获取
### 歌曲纯享版 BV
歌曲纯享版由本项目调用 `biliup upload` 发布。
发布成功后,项目会从 `biliup` 输出中提取 BV 号,并写入当前 session 目录:
```text
bvid.txt
```
这个 BV 会用于:
- 纯享版评论发布。
- 完整版评论顶部反向链接。
- 纯享版合集同步。
### 直播完整版 BV
完整版 BV 可以来自三种方式:
- 控制台手动绑定。
- API/webhook 传入。
- `biliup list` 标题匹配。
`biliup list` 会同时接受 `开放浏览``审核中` 状态。完整版视频只要上传后生成了 BV即使仍在审核中也可以被写入纯享版简介、动态和评论互链。
成功解析后会写入:
```text
full_video_bvid.txt
```
默认标题匹配是保守的精确匹配:会先去掉空格、标点、括号、冒号等,只保留中文、英文、数字,再比较标题是否相等。
如果 `allow_fuzzy_full_video_match=false`,不会做包含式模糊匹配。为了避免误匹配,推荐在完整版上传完成后手动绑定 BV。
## 示例场景
假设本次直播由三段录播组成:
```text
王海颖唱歌录播 04月19日 22时10分
王海颖唱歌录播 04月19日 23时05分
王海颖唱歌录播 04月20日 00时01分
```
假设 BV 绑定结果如下:
```text
本次直播完整版BVFULLCURR
本次歌曲纯享版BVPURECURR
上次直播完整版BVFULLPREV
```
假设识别出的歌曲如下:
```text
P1:
00:06:32 程艾影 — 赵雷
00:14:45 钟无艳 — 谢安琪
P2:
00:20:57 慢慢喜欢你 — 莫文蔚
P3:
00:27:16 空白格 — 蔡健雅
```
## 歌曲纯享版标题
当前模板:
```text
【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌
```
示例:
```text
【王海颖 (歌曲纯享版)】 04月19日 22时10分 共4首歌
```
## 歌曲纯享版简介
当前模板会保持简介较短,完整歌单放到置顶评论中,避免 B 站简介截断。
示例:
```text
王海颖 04月19日 22时10分 歌曲纯享版。
完整歌单与时间轴见置顶评论。
直播完整版https://www.bilibili.com/video/BVFULLCURR
上次直播https://www.bilibili.com/video/BVFULLPREV
本视频为歌曲纯享切片,适合只听歌曲。
```
如果某个链接暂时没有 BV项目会自动移除对应的空链接行。
## 歌曲纯享版动态
示例:
```text
王海颖 04月19日 22时10分 歌曲纯享版已发布。完整歌单见置顶评论。
直播完整版https://www.bilibili.com/video/BVFULLCURR
上次直播https://www.bilibili.com/video/BVFULLPREV
```
## 歌曲纯享版置顶评论
纯享版评论主要给听歌用户看,不带歌曲时间轴,只展示歌名、歌手和互链。
默认由 `runtime/upload_config.json``comment_template.split_header``comment_template.split_part_header``comment_template.split_song_line` 生成。
示例:
```text
当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。
直播完整版https://www.bilibili.com/video/BVFULLCURR (完整录播,含聊天/互动/完整流程)
上次纯享https://www.bilibili.com/video/BVPUREPREV (上一场歌曲纯享版)
P1:
1. 程艾影 — 赵雷
2. 钟无艳 — 谢安琪
P2:
3. 慢慢喜欢你 — 莫文蔚
P3:
4. 空白格 — 蔡健雅
```
## 直播完整版置顶评论
完整版评论主要给看完整录播的用户跳转歌曲纯享版,并提供完整时间轴。
默认由 `runtime/upload_config.json``comment_template.full_header``comment_template.full_part_header``comment_template.full_timeline_line` 生成。
示例:
```text
当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。
歌曲纯享版https://www.bilibili.com/video/BVPURECURR (只听歌曲看这里)
上次完整版https://www.bilibili.com/video/BVFULLPREV (上一场完整录播)
P1:
1. 00:06:32 程艾影 — 赵雷
2. 00:14:45 钟无艳 — 谢安琪
P2:
3. 00:20:57 慢慢喜欢你 — 莫文蔚
P3:
4. 00:27:16 空白格 — 蔡健雅
```
## 评论格式配置
评论格式可以像标题、简介、动态一样通过 `runtime/upload_config.json` 修改:
```json
"comment_template": {
"split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享{previous_pure_video_link} (上一场歌曲纯享版)",
"full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版{current_pure_video_link} (只听歌曲看这里)\n上次完整版{previous_full_video_link} (上一场完整录播)",
"split_part_header": "P{part_index}:",
"full_part_header": "P{part_index}:",
"split_song_line": "{song_index}. {title}{artist_suffix}",
"split_text_song_line": "{song_index}. {song_text}",
"full_timeline_line": "{song_index}. {line_text}"
}
```
字段含义:
- `split_header`:纯享版评论顶部说明。
- `full_header`:完整版评论顶部说明。
- `split_part_header` / `full_part_header`:多片段 session 的分段标题,例如 `P1:`
- `split_song_line`:从 `songs.json` 生成纯享歌单时的单行格式。
- `split_text_song_line``songs.json` 不可用时,从 `songs.txt` 兜底生成纯享歌单的单行格式。
- `full_timeline_line`:完整版时间轴评论的单行格式。
常用变量:
- `{current_full_video_link}`:本场直播完整版链接。
- `{current_pure_video_link}`:本场歌曲纯享版链接。
- `{previous_full_video_link}`:上一场直播完整版链接。
- `{previous_pure_video_link}`:上一场歌曲纯享版链接。
- `{part_index}`P 分段序号。
- `{song_index}`:歌曲全局序号。
- `{title}` / `{artist}` / `{artist_suffix}`:歌曲标题、歌手、带分隔符的歌手后缀。
- `{song_text}`:不带时间戳的歌曲文本。
- `{line_text}`:原始时间轴行,通常包含时间戳。
如果评论头部某一行包含空链接变量,例如 `{previous_full_video_link}` 为空,这一整行会自动省略。
## 合集同步
项目维护两个合集目标:
- 合集 A直播完整版。
- 合集 B歌曲纯享版。
当前配置中的示例 ID
```text
直播完整版合集7196643
歌曲纯享版合集7196624
```
合集同步完成后,如果启用了清理策略,项目可以删除本地原视频或切片视频以节省空间。当前默认不删除。
## 幂等与重试
项目会在 session 目录写入标记文件,避免重复上传和重复评论。
常见标记:
```text
bvid.txt
full_video_bvid.txt
upload_done.flag
comment_split_done.flag
comment_full_done.flag
collection_a_done.flag
collection_b_done.flag
```
发布阶段的关键行为:
- 首批最多上传 5 个分 P。
- 超过 5 个分 P 时,后续通过 append 追加。
- 已经写入 `bvid.txt` 后,重试会优先 append 到已有视频,而不是重新发布。
- `publish_progress.json` 记录 append 进度,避免重试时重复追加已完成批次。
评论阶段的关键行为:
- 同一 session 只由最早片段负责聚合评论。
- 非 anchor 片段进入评论步骤时会跳过实际发评。
- 这样可以避免同一场直播的多个片段重复发布相同评论。
## 使用建议
发布前建议确认:
- stage 中的视频文件名能解析出主播和时间。
- `runtime/upload_config.json` 中标题、简介、动态符合预期。
- 完整版上传完成后,尽量手动绑定 `full_video_bvid`
- worker 重启前确认已有 `bvid.txt``publish_progress.json` 是否符合当前发布进度。
- 如需自动匹配完整版 BV确认 `biliup list` 中完整视频标题与任务标题标准化后相等。

View File

@ -1,134 +1,134 @@
# biliup-next Refactor Plan - 2026-04-06
## 目标
围绕当前重构项目已暴露出的状态一致性、数据一致性、运行稳定性和控制面性能问题,分阶段推进改造,优先修复会影响真实运行结果的问题,再收敛模型和技术债。
## 改造原则
- 先修正单一事实源,再优化展示层。
- 先修正状态机真实行为,再修正文档和 UI 映射。
- 先处理运行稳定性,再处理性能和结构整理。
- 每一阶段都要求有可验证的验收结果,避免只做“结构看起来更好”。
## 阶段划分
### Phase 1: 状态与事实源收敛
目标:
- 让 task 具备真实可用的 `running` 语义。
-`full_video_bvid` 只有一套权威写入路径。
- 消除“数据库状态”和“工作区文件状态”互相覆盖的问题。
任务:
- 在 step 开始执行时同步更新 task 运行态。
- 明确 task 完成后 task 状态如何从 `running` 返回业务态。
- 统一 `bind/rebind/webhook/ingest``full_video_bvid` 的读写入口。
- 明确 `task_contexts``session_bindings``full_video_bvid.txt` 的职责。
验收标准:
- 控制台能正确筛选和显示运行中的任务。
- 手工绑定、session 重绑、webhook 注入后,新旧任务读取到相同 BV。
- 不再出现新任务 ingest 继承旧 BV 的情况。
### Phase 2: 运行稳定性加固
目标:
- 让 API 与 worker 并行运行时的 SQLite 行为可控。
- 降低锁冲突、脏状态和半成功写入风险。
任务:
- 为 SQLite 连接增加 `busy_timeout``WAL``foreign_keys=ON`
- 检查高频 repo 写入点,减少不必要的小事务。
- 梳理关键写路径是否需要合并成原子操作。
验收标准:
- API 和 worker 并行运行时,不再轻易触发数据库锁错误。
- 关键任务状态写入具备基本原子性,不出现“步骤更新了、任务没更新”一类半状态。
### Phase 3: 控制面装配与查询优化
目标:
- 去掉 API 请求路径上的重复初始化。
- 解决 `/tasks` 列表的全量扫描和 N+1 查询问题。
任务:
-`ensure_initialized()` 从“每次请求即装配”改为更稳定的应用级初始化方式。
- 收敛 provider/registry 生命周期,避免每次请求重复扫描 manifest 和实例化 provider。
- 优化任务列表接口,把可下推的过滤逻辑下推到 repository 或持久化层。
- 减少列表查询时对工作区文件的逐条读取。
验收标准:
- 常规 API 请求不再重复做全量装配。
- 大量任务下的列表页和筛选页响应明显改善。
### Phase 4: 状态机与文档对齐
目标:
- 让文档状态机、代码状态机、控制面展示口径一致。
任务:
- 决定是否保留 `ingested``completed``cancelled`
- 明确 flag 文件在系统中的角色。
- 如果数据库是任务状态唯一来源,则把 delivery flag 降级为产物或外部副作用标记。
- 更新状态机文档、控制面展示文案和开发约束。
验收标准:
- 文档中的状态集合与代码中的状态集合一致。
- UI 不再依赖不存在或含义不稳定的 task 状态。
### Phase 5: 回归测试与维护收尾
目标:
- 为核心编排逻辑补回归保护。
- 降低后续重构再次引入状态漂移的概率。
任务:
- 新增 `tests/`
- 优先覆盖:
- `task_engine`
- `task_policies`
- `task_actions`
- `retry_meta`
- `task_reset`
- 决定 classic 控制台的保留策略。
验收标准:
- 核心状态流转具备最小自动化回归覆盖。
- 控制台维护策略明确,不再长期双线漂移。
## 推荐执行顺序
1. Phase 1
2. Phase 2
3. Phase 3
4. Phase 4
5. Phase 5
## 本轮起步范围
本轮先从以下子项开始:
- Phase 1.1: task `running` 状态落地
- Phase 1.2: `full_video_bvid` 写路径统一
- Phase 2.1: SQLite 连接配置加固
## 过程记录
- 2026-04-06完成代码审查确认当前优先问题集中在 task 运行态缺失、`full_video_bvid` 多源不一致、SQLite 并发配置不足、重复初始化、列表查询 N+1、状态机文档与实现漂移、测试缺失。
- 2026-04-06将问题整理为本改造计划按阶段拆分并确定先做状态一致性与运行稳定性。
# biliup-next Refactor Plan - 2026-04-06
## 目标
围绕当前重构项目已暴露出的状态一致性、数据一致性、运行稳定性和控制面性能问题,分阶段推进改造,优先修复会影响真实运行结果的问题,再收敛模型和技术债。
## 改造原则
- 先修正单一事实源,再优化展示层。
- 先修正状态机真实行为,再修正文档和 UI 映射。
- 先处理运行稳定性,再处理性能和结构整理。
- 每一阶段都要求有可验证的验收结果,避免只做“结构看起来更好”。
## 阶段划分
### Phase 1: 状态与事实源收敛
目标:
- 让 task 具备真实可用的 `running` 语义。
-`full_video_bvid` 只有一套权威写入路径。
- 消除“数据库状态”和“工作区文件状态”互相覆盖的问题。
任务:
- 在 step 开始执行时同步更新 task 运行态。
- 明确 task 完成后 task 状态如何从 `running` 返回业务态。
- 统一 `bind/rebind/webhook/ingest``full_video_bvid` 的读写入口。
- 明确 `task_contexts``session_bindings``full_video_bvid.txt` 的职责。
验收标准:
- 控制台能正确筛选和显示运行中的任务。
- 手工绑定、session 重绑、webhook 注入后,新旧任务读取到相同 BV。
- 不再出现新任务 ingest 继承旧 BV 的情况。
### Phase 2: 运行稳定性加固
目标:
- 让 API 与 worker 并行运行时的 SQLite 行为可控。
- 降低锁冲突、脏状态和半成功写入风险。
任务:
- 为 SQLite 连接增加 `busy_timeout``WAL``foreign_keys=ON`
- 检查高频 repo 写入点,减少不必要的小事务。
- 梳理关键写路径是否需要合并成原子操作。
验收标准:
- API 和 worker 并行运行时,不再轻易触发数据库锁错误。
- 关键任务状态写入具备基本原子性,不出现“步骤更新了、任务没更新”一类半状态。
### Phase 3: 控制面装配与查询优化
目标:
- 去掉 API 请求路径上的重复初始化。
- 解决 `/tasks` 列表的全量扫描和 N+1 查询问题。
任务:
-`ensure_initialized()` 从“每次请求即装配”改为更稳定的应用级初始化方式。
- 收敛 provider/registry 生命周期,避免每次请求重复扫描 manifest 和实例化 provider。
- 优化任务列表接口,把可下推的过滤逻辑下推到 repository 或持久化层。
- 减少列表查询时对工作区文件的逐条读取。
验收标准:
- 常规 API 请求不再重复做全量装配。
- 大量任务下的列表页和筛选页响应明显改善。
### Phase 4: 状态机与文档对齐
目标:
- 让文档状态机、代码状态机、控制面展示口径一致。
任务:
- 决定是否保留 `ingested``completed``cancelled`
- 明确 flag 文件在系统中的角色。
- 如果数据库是任务状态唯一来源,则把 delivery flag 降级为产物或外部副作用标记。
- 更新状态机文档、控制面展示文案和开发约束。
验收标准:
- 文档中的状态集合与代码中的状态集合一致。
- UI 不再依赖不存在或含义不稳定的 task 状态。
### Phase 5: 回归测试与维护收尾
目标:
- 为核心编排逻辑补回归保护。
- 降低后续重构再次引入状态漂移的概率。
任务:
- 新增 `tests/`
- 优先覆盖:
- `task_engine`
- `task_policies`
- `task_actions`
- `retry_meta`
- `task_reset`
- 决定 classic 控制台的保留策略。
验收标准:
- 核心状态流转具备最小自动化回归覆盖。
- 控制台维护策略明确,不再长期双线漂移。
## 推荐执行顺序
1. Phase 1
2. Phase 2
3. Phase 3
4. Phase 4
5. Phase 5
## 本轮起步范围
本轮先从以下子项开始:
- Phase 1.1: task `running` 状态落地
- Phase 1.2: `full_video_bvid` 写路径统一
- Phase 2.1: SQLite 连接配置加固
## 过程记录
- 2026-04-06完成代码审查确认当前优先问题集中在 task 运行态缺失、`full_video_bvid` 多源不一致、SQLite 并发配置不足、重复初始化、列表查询 N+1、状态机文档与实现漂移、测试缺失。
- 2026-04-06将问题整理为本改造计划按阶段拆分并确定先做状态一致性与运行稳定性。

View File

@ -1,271 +1,271 @@
# State Machine
## Goal
定义 `biliup-next` 当前实现使用的任务状态机,并明确数据库状态与工作区 flag 的职责边界。
状态机目标:
- 让每个任务始终有明确状态
- 支持失败重试和人工介入
- 让 UI 和 API 可以直接消费状态
- 保证步骤顺序和依赖关系清晰
## State Model
任务状态分为两层:
- `task status`:任务整体状态
- `step status`:任务中每一步的执行状态
## Task Status
### Core Statuses
- `created`
- `running`
- `transcribed`
- `songs_detected`
- `split_done`
- `published`
- `commented`
- `collection_synced`
### Failure Statuses
- `failed_retryable`
- `failed_manual`
### Terminal Statuses
- `collection_synced`
- `failed_manual`
## Step Status
每个步骤都独立维护自己的状态。
- `pending`
- `running`
- `succeeded`
- `failed_retryable`
- `failed_manual`
- `skipped`
## Step Definitions
### ingest
负责:
- 接收输入视频
- 基础校验
- 创建任务记录
### transcribe
负责:
- 生成字幕
- 记录字幕产物
### song_detect
负责:
- 识别歌曲列表
- 生成 `songs.json``songs.txt`
### split
负责:
- 根据歌单切割视频
- 生成切片产物
### publish
负责:
- 上传纯享版视频
- 同 session 多个 task 时,只由 anchor task 真正执行上传
- 聚合同 session 的全部 `clip_video`
- 成功后把同一个 `bvid` 写回整组 task
### comment
负责:
- 发布评论
- 置顶评论
- split 评论在 session 级聚合为 `P1/P2/P3`
- full 评论在 session 级聚合为 `P1/P2/P3`
- 同一 session 的评论只由 anchor task 执行一次
### collection_a
负责:
- 将完整版视频加入合集 A
### collection_b
负责:
- 将纯享版视频加入合集 B
## State Transition Rules
### Task-Level
```text
created
-> running
-> transcribed
-> running
-> songs_detected
-> running
-> split_done
-> running
-> published
-> running
-> commented
-> running
-> collection_synced
```
说明:
- `running` 是任务级瞬时状态,表示当前已有某个 step 被 claim 并正在执行。
- 当该 step 成功结束后task 会回到对应业务状态,例如 `transcribed``split_done``published`
- 当前实现中未使用 `ingested``completed``cancelled` 作为 task 状态。
### Failure Transition
任何步骤失败后:
- 若允许自动重试:任务进入 `failed_retryable`
- 若必须人工介入:任务进入 `failed_manual`
重试成功后:
- 任务回到该步骤成功后的下一个合法状态
## Dependency Rules
- `transcribe` 必须依赖 `ingest`
- `song_detect` 必须依赖 `transcribe`
- `split` 必须依赖 `song_detect`
- `publish` 必须依赖 `split`
- `comment` 必须依赖 `publish`
- `collection_b` 必须依赖 `publish`
- `collection_a` 通常依赖外部完整版 BV可独立于 `publish`
## Session Semantics
当多个 task 属于同一个 `session_key` 时,系统会引入 session 级语义:
- `split` 仍然保持 task 级
- `publish` 升级为 session 级
- `comment` 升级为 session 级
当前 anchor 规则:
- 同一 session 内按 `segment_started_at` 升序排序
- 最早那个 task 作为 anchor
当前 session 级行为:
- `publish`
- 只有 anchor task 执行真实上传
- 其余 task 复用同一个纯享 `BV`
- `comment.split`
- 只有 anchor task 对纯享版视频发评论
- 评论内容按 `P1/P2/P3` 聚合
- `comment.full`
- 只有 anchor task 对完整版视频发评论
- 评论内容按 `P1/P2/P3` 聚合
## Special Case: Collection A
合集 A 的数据来源与主上传链路不同。
因此:
- `collection_a` 不应阻塞主任务完成
- `collection_a` 可作为独立步骤存在
- 任务整体完成不必强依赖 `collection_a` 成功
当前实现:
- `collection_synced` 表示当前任务已经完成既定收尾流程。
- `collection_a` / `collection_b` 仍作为独立 step 存在,但系统暂未额外引入 `completed` 状态。
## Retry Strategy
### Retryable Errors
适合自动重试:
- 网络错误
- 外部 API 临时失败
- 上传频控
- 外部命令短时异常
### Manual Errors
需要人工介入:
- 配置缺失
- 凭证失效
- 文件损坏
- provider 不可用
- 标题无法匹配完整版 BV
## Persistence Requirements
每次状态变更都必须落库:
- 任务状态
- 步骤状态
- 开始时间
- 结束时间
- 错误码
- 错误信息
- 重试次数
## Flags And Files
工作区中的 flag 文件仍然存在,但它们不是 task 主状态的权威来源。
当前职责划分:
- 数据库:
- task 状态
- step 状态
- 重试信息
- 结构化上下文
- 工作区文件与 flag
- 外部副作用是否已执行
- 产物是否已落地
- 评论/合集等交付标记
换句话说:
- “任务现在处于什么状态”以数据库为准。
- “某个外部动作是否已经做过”可以由工作区 flag 辅助表达。
## UI Expectations
UI 至少需要直接展示:
- 当前任务状态
- 当前正在运行的步骤
- 最近失败步骤
- 重试次数
- 是否需要人工介入
## Non-Goals
- 不追求一个任务多个步骤完全并发执行
- 不把工作区 flag 文件当作 task 主状态来源
# State Machine
## Goal
定义 `biliup-next` 当前实现使用的任务状态机,并明确数据库状态与工作区 flag 的职责边界。
状态机目标:
- 让每个任务始终有明确状态
- 支持失败重试和人工介入
- 让 UI 和 API 可以直接消费状态
- 保证步骤顺序和依赖关系清晰
## State Model
任务状态分为两层:
- `task status`:任务整体状态
- `step status`:任务中每一步的执行状态
## Task Status
### Core Statuses
- `created`
- `running`
- `transcribed`
- `songs_detected`
- `split_done`
- `published`
- `commented`
- `collection_synced`
### Failure Statuses
- `failed_retryable`
- `failed_manual`
### Terminal Statuses
- `collection_synced`
- `failed_manual`
## Step Status
每个步骤都独立维护自己的状态。
- `pending`
- `running`
- `succeeded`
- `failed_retryable`
- `failed_manual`
- `skipped`
## Step Definitions
### ingest
负责:
- 接收输入视频
- 基础校验
- 创建任务记录
### transcribe
负责:
- 生成字幕
- 记录字幕产物
### song_detect
负责:
- 识别歌曲列表
- 生成 `songs.json``songs.txt`
### split
负责:
- 根据歌单切割视频
- 生成切片产物
### publish
负责:
- 上传纯享版视频
- 同 session 多个 task 时,只由 anchor task 真正执行上传
- 聚合同 session 的全部 `clip_video`
- 成功后把同一个 `bvid` 写回整组 task
### comment
负责:
- 发布评论
- 置顶评论
- split 评论在 session 级聚合为 `P1/P2/P3`
- full 评论在 session 级聚合为 `P1/P2/P3`
- 同一 session 的评论只由 anchor task 执行一次
### collection_a
负责:
- 将完整版视频加入合集 A
### collection_b
负责:
- 将纯享版视频加入合集 B
## State Transition Rules
### Task-Level
```text
created
-> running
-> transcribed
-> running
-> songs_detected
-> running
-> split_done
-> running
-> published
-> running
-> commented
-> running
-> collection_synced
```
说明:
- `running` 是任务级瞬时状态,表示当前已有某个 step 被 claim 并正在执行。
- 当该 step 成功结束后task 会回到对应业务状态,例如 `transcribed``split_done``published`
- 当前实现中未使用 `ingested``completed``cancelled` 作为 task 状态。
### Failure Transition
任何步骤失败后:
- 若允许自动重试:任务进入 `failed_retryable`
- 若必须人工介入:任务进入 `failed_manual`
重试成功后:
- 任务回到该步骤成功后的下一个合法状态
## Dependency Rules
- `transcribe` 必须依赖 `ingest`
- `song_detect` 必须依赖 `transcribe`
- `split` 必须依赖 `song_detect`
- `publish` 必须依赖 `split`
- `comment` 必须依赖 `publish`
- `collection_b` 必须依赖 `publish`
- `collection_a` 通常依赖外部完整版 BV可独立于 `publish`
## Session Semantics
当多个 task 属于同一个 `session_key` 时,系统会引入 session 级语义:
- `split` 仍然保持 task 级
- `publish` 升级为 session 级
- `comment` 升级为 session 级
当前 anchor 规则:
- 同一 session 内按 `segment_started_at` 升序排序
- 最早那个 task 作为 anchor
当前 session 级行为:
- `publish`
- 只有 anchor task 执行真实上传
- 其余 task 复用同一个纯享 `BV`
- `comment.split`
- 只有 anchor task 对纯享版视频发评论
- 评论内容按 `P1/P2/P3` 聚合
- `comment.full`
- 只有 anchor task 对完整版视频发评论
- 评论内容按 `P1/P2/P3` 聚合
## Special Case: Collection A
合集 A 的数据来源与主上传链路不同。
因此:
- `collection_a` 不应阻塞主任务完成
- `collection_a` 可作为独立步骤存在
- 任务整体完成不必强依赖 `collection_a` 成功
当前实现:
- `collection_synced` 表示当前任务已经完成既定收尾流程。
- `collection_a` / `collection_b` 仍作为独立 step 存在,但系统暂未额外引入 `completed` 状态。
## Retry Strategy
### Retryable Errors
适合自动重试:
- 网络错误
- 外部 API 临时失败
- 上传频控
- 外部命令短时异常
### Manual Errors
需要人工介入:
- 配置缺失
- 凭证失效
- 文件损坏
- provider 不可用
- 标题无法匹配完整版 BV
## Persistence Requirements
每次状态变更都必须落库:
- 任务状态
- 步骤状态
- 开始时间
- 结束时间
- 错误码
- 错误信息
- 重试次数
## Flags And Files
工作区中的 flag 文件仍然存在,但它们不是 task 主状态的权威来源。
当前职责划分:
- 数据库:
- task 状态
- step 状态
- 重试信息
- 结构化上下文
- 工作区文件与 flag
- 外部副作用是否已执行
- 产物是否已落地
- 评论/合集等交付标记
换句话说:
- “任务现在处于什么状态”以数据库为准。
- “某个外部动作是否已经做过”可以由工作区 flag 辅助表达。
## UI Expectations
UI 至少需要直接展示:
- 当前任务状态
- 当前正在运行的步骤
- 最近失败步骤
- 重试次数
- 是否需要人工介入
## Non-Goals
- 不追求一个任务多个步骤完全并发执行
- 不把工作区 flag 文件当作 task 主状态来源

View File

@ -1,196 +1,196 @@
# biliup-next Todo - 2026-04-06
## 今日待办
### P0
- 修正任务级 `running` 状态缺失问题。
- 当前 step 会进入 `running`,但 task 不会进入 `running`,导致控制台“处理中”筛选、优先级判断和注意力状态失真。
- 相关位置:
- `src/biliup_next/app/task_engine.py`
- `src/biliup_next/app/api_server.py`
- `src/biliup_next/modules/*/service.py`
- 收敛 `full_video_bvid` 的单一事实源。
- 当前 `task_contexts``session_bindings``session/full_video_bvid.txt` 三处状态可能不一致。
- `rebind_session_full_video_action()` 没有同步更新 `session_bindings`,后续新任务 ingest 仍可能继承旧 BV。
- 相关位置:
- `src/biliup_next/app/task_actions.py`
- `src/biliup_next/modules/ingest/service.py`
- `src/biliup_next/infra/task_repository.py`
- 补强 SQLite 并发配置。
- 当前 API 与 worker 可并行运行,但数据库连接仍是最基础配置,缺少 `busy_timeout``WAL``foreign_keys=ON` 等保护。
- 后续任务量或并发操作增加时,容易出现 `database is locked` 一类问题。
- 相关位置:
- `src/biliup_next/infra/db.py`
### P1
- 消除 API 路径上的重复初始化。
- `ensure_initialized()` 目前会重复执行配置加载、DB 初始化、插件扫描和 provider 实例化。
- API 每次请求都可能再次触发整套装配,后续会拖慢控制面并增加维护成本。
- 相关位置:
- `src/biliup_next/app/bootstrap.py`
- `src/biliup_next/app/api_server.py`
- 优化 `/tasks` 的全量扫描和 N+1 查询。
- 当前 `attention/delivery` 过滤会先拉最多 5000 条任务,再逐条补 task payload、step、context 和文件系统状态。
- 任务规模上来后会明显拖慢列表页和筛选体验。
- 相关位置:
- `src/biliup_next/app/api_server.py`
- `src/biliup_next/infra/task_repository.py`
- 收敛文档状态机与代码实现。
- 文档中存在 `ingested``completed``cancelled`,并声明不再依赖 flag 文件作为权威状态。
- 实际实现中这些状态并未完整落地,评论/合集完成态仍依赖多个 flag 文件。
- 需要统一“文档模型”和“代码真实状态机”,避免后续继续漂移。
- 相关位置:
- `docs/state-machine.md`
- `src/biliup_next/app/api_server.py`
- `src/biliup_next/modules/comment/providers/bilibili_top_comment.py`
- `src/biliup_next/modules/collection/providers/bilibili_collection.py`
### P2
- 为状态机、重试和手工干预流程补测试。
- 当前仓库没有看到 `tests/` 或自动化回归覆盖。
- 优先覆盖:
- `task_engine`
- `task_policies`
- `task_actions`
- `retry_meta`
- `task_reset`
- 明确两套控制台的维护策略。
- 当前 React 控制台和 classic 控制台并存。
- 需要决定 classic 是长期保留、冻结维护,还是逐步退役。
## 备注
- 以上问题来自 2026-04-06 对 `biliup-next` 当前重构实现的代码审查。
- 优先顺序按“状态一致性 / 数据一致性 / 运行稳定性 / 控制面性能 / 可维护性”排列。
## 过程记录
- 2026-04-06完成首轮代码审查确认当前优先问题。
- 2026-04-06基于问题清单拆出分阶段改造计划`docs/refactor-plan-2026-04-06.md`
- 2026-04-06确定首批执行范围为 task `running` 状态落地、`full_video_bvid` 写路径统一、SQLite 连接加固。
- 2026-04-06已完成首轮代码改造。
- task 在 step 被 claim 后会进入 `running`
- `bind/rebind/webhook` 已统一复用 `full_video_bvid` 持久化路径。
- SQLite 连接已增加 `foreign_keys``busy_timeout``WAL``synchronous=NORMAL`
- 已执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。
- 2026-04-06已完成第二轮控制面改造。
- `ensure_initialized()` 已改为进程内复用,避免 API 请求重复装配全套应用状态。
- `PUT /settings` 后会主动失效并重建缓存状态,避免新旧配置混用。
- `/tasks` 列表已改为批量预取 task context 和 steps减少列表页 N+1 查询。
- 已再次执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。
- 2026-04-06已完成状态机文档对齐。
- `state-machine.md``architecture.md` 已改成当前代码真实状态集合:`created/running/transcribed/songs_detected/split_done/published/commented/collection_synced/failed_*`
- 已明确 `ingested/completed/cancelled` 当前未落地,不再作为现阶段实现口径。
- 已明确工作区 flag 仅表示交付副作用和产物标记,不作为 task 主状态事实源。
- 2026-04-06已补最小回归测试集。
- 新增 `tests/test_task_engine.py`
- 新增 `tests/test_retry_meta.py`
- 新增 `tests/test_task_actions.py`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 7 个测试全部通过。
- 2026-04-06已继续收口 `task_actions` 的写路径。
- `rebind_session_full_video_action()` 不再重复 upsert session binding。
- `merge_session_action()` 在继承 `full_video_bvid` 时已复用统一持久化路径。
- 已补对应测试,当前测试数为 8全部通过。
- 2026-04-06已补第二层状态流转测试。
- 新增 `tests/test_task_policies.py`
- 新增 `tests/test_task_runner.py`
- 已覆盖 disabled step fallback、publish 重试调度、reset 后回退状态、step claim 后 task 进入 `running`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 12 个测试全部通过。
- 2026-04-06已完成一轮 API 代码清理。
- `api_server.py` 新增批量 task payload 组装 helper。
- `/tasks``/sessions/:session_key` 已复用同一套 task payload 预取与组装逻辑。
- 已重新执行测试,当前 12 个测试全部通过。
- 2026-04-06已整理专业化路线图。
- 新增 `docs/professionalization-roadmap-2026-04-06.md`
- 按平台边界、领域模型、接口契约、测试体系、运维成熟度五个维度拆解后续改进方向。
- 已明确下一批优先项为 adapter 边界、session/delivery 领域服务收敛、serializer 层、SQLite/API 测试与 OpenAPI 对齐。
- 2026-04-06已开始落最小 adapter 边界。
- 新增 `infra/adapters/codex_cli.py`
- 新增 `infra/adapters/biliup_cli.py`
- 新增 `infra/adapters/bilibili_api.py`
- `codex``biliup_cli``bilibili_top_comment``bilibili_collection` provider 已改为依赖 adapter
- 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。
- 2026-04-06已开始落 serializer 层。
- 新增 `app/serializers.py`
- task list / task detail / session detail 的 payload 组装已从 `api_server.py` 抽到 `ControlPlaneSerializer`
- `api_server.py` 进一步收敛为路由、鉴权和响应控制
- 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。
- 2026-04-06已继续收口 serializer 层。
- task timeline 的组装逻辑已从 `api_server.py` 抽到 `ControlPlaneSerializer.timeline_payload()`
- `api_server.py` 中 task 详情相关展示逻辑继续变薄
- 已重新执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。
- 2026-04-06已补 serializer 层测试。
- 新增 `tests/test_serializers.py`
- 已覆盖 task payload、session payload、timeline payload 的控制面展示契约
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 15 个测试全部通过。
- 2026-04-06已补 repository 的 SQLite 集成测试。
- 新增 `tests/test_task_repository_sqlite.py`
- 已覆盖 `query_tasks`、批量 context/steps 查询、`session_bindings` upsert 与 fallback 读取
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 18 个测试全部通过。
- 2026-04-06已补 API 行为测试。
- 扩展 `tests/test_api_server.py`
- 已覆盖 `GET /tasks``GET /tasks/:id/timeline``GET /sessions/:session_key``PUT /settings`
- 已覆盖 control token 鉴权分支
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 2026-04-06已继续补执行面 API 行为测试。
- `tests/test_api_server.py` 已新增 `POST /tasks``POST /tasks/:id/actions/run``POST /tasks/:id/actions/retry-step``POST /tasks/:id/actions/reset-to-step`
- 已覆盖写操作成功分支与 `missing step_name` 参数校验
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 28 个测试全部通过。
- 2026-04-06已补人工干预相关 API 行为测试。
- `tests/test_api_server.py` 已新增 `POST /tasks/:id/bind-full-video``POST /sessions/:session_key/rebind``POST /sessions/:session_key/merge``POST /webhooks/full-video-uploaded`
- 已覆盖成功分支、参数校验,以及 `TASK_NOT_FOUND/SESSION_NOT_FOUND` 的状态码映射
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 37 个测试全部通过。
- 2026-04-06已补运行面 API 行为测试。
- `tests/test_api_server.py` 已新增 `POST /worker/run-once``POST /scheduler/run-once``POST /runtime/services/:name/:action``POST /stage/import`
- 已覆盖 action record 落库、副作用返回值、`invalid action``missing source_path` 错误分支
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 43 个测试全部通过。
- 2026-04-06已补剩余控制面 GET 与上传接口测试。
- `tests/test_api_server.py` 已新增 `GET /history``GET /modules``GET /scheduler/preview``GET /settings/schema``POST /stage/upload`
- `stage/upload` 成功分支已通过 patch `cgi.FieldStorage` 固定最小 handler 契约,避免 multipart 解析细节导致测试脆弱
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 49 个测试全部通过。
- 2026-04-06已开始收口 session / delivery 领域服务。
- 新增 `app/session_delivery_service.py`,承接 `bind/rebind/merge/webhook` 的核心规则与持久化路径
- `app/task_actions.py` 已改为薄封装,仅保留 `ensure_initialized()`、审计记录与 service 调用
- 新增 `tests/test_session_delivery_service.py`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 51 个测试全部通过。
- 2026-04-06已继续收口 task control 领域服务。
- 新增 `app/task_control_service.py`,承接 `run/retry/reset` 编排
- `app/task_actions.py` 已进一步变薄,`run_task_action/retry_step_action/reset_to_step_action` 改为纯 service 封装 + 审计
- 新增 `tests/test_task_control_service.py`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 54 个测试全部通过。
- 2026-04-06已将 POST 路径分发从 API handler 中下沉。
- 新增 `app/control_plane_post_dispatcher.py`,统一承接 POST 路径的用例分发、状态码映射和运行面 action record
- `app/api_server.py``do_POST()` 已收敛为请求解析、dispatcher 调用和响应写出
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 54 个测试全部通过。
- 2026-04-06已补 dispatcher 直测。
- 新增 `tests/test_control_plane_get_dispatcher.py`
- 新增 `tests/test_control_plane_post_dispatcher.py`
- 已覆盖 dispatcher 层的状态码映射、过滤逻辑、运行面 action record 与创建任务冲突映射
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 62 个测试全部通过。
- 2026-04-06已开始做可迁移交付清理。
- `config/settings.json``config/settings.staged.json` 已替换为 standalone 默认模板,不再携带本机绝对路径和真实密钥
- `runtime/cookies.json``runtime/upload_config.json` 已替换为可分发模板
- 新增 `docs/cold-start-checklist.md`
- `README.md` 已补充冷启动入口说明
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 63 个测试全部通过。
# biliup-next Todo - 2026-04-06
## 今日待办
### P0
- 修正任务级 `running` 状态缺失问题。
- 当前 step 会进入 `running`,但 task 不会进入 `running`,导致控制台“处理中”筛选、优先级判断和注意力状态失真。
- 相关位置:
- `src/biliup_next/app/task_engine.py`
- `src/biliup_next/app/api_server.py`
- `src/biliup_next/modules/*/service.py`
- 收敛 `full_video_bvid` 的单一事实源。
- 当前 `task_contexts``session_bindings``session/full_video_bvid.txt` 三处状态可能不一致。
- `rebind_session_full_video_action()` 没有同步更新 `session_bindings`,后续新任务 ingest 仍可能继承旧 BV。
- 相关位置:
- `src/biliup_next/app/task_actions.py`
- `src/biliup_next/modules/ingest/service.py`
- `src/biliup_next/infra/task_repository.py`
- 补强 SQLite 并发配置。
- 当前 API 与 worker 可并行运行,但数据库连接仍是最基础配置,缺少 `busy_timeout``WAL``foreign_keys=ON` 等保护。
- 后续任务量或并发操作增加时,容易出现 `database is locked` 一类问题。
- 相关位置:
- `src/biliup_next/infra/db.py`
### P1
- 消除 API 路径上的重复初始化。
- `ensure_initialized()` 目前会重复执行配置加载、DB 初始化、插件扫描和 provider 实例化。
- API 每次请求都可能再次触发整套装配,后续会拖慢控制面并增加维护成本。
- 相关位置:
- `src/biliup_next/app/bootstrap.py`
- `src/biliup_next/app/api_server.py`
- 优化 `/tasks` 的全量扫描和 N+1 查询。
- 当前 `attention/delivery` 过滤会先拉最多 5000 条任务,再逐条补 task payload、step、context 和文件系统状态。
- 任务规模上来后会明显拖慢列表页和筛选体验。
- 相关位置:
- `src/biliup_next/app/api_server.py`
- `src/biliup_next/infra/task_repository.py`
- 收敛文档状态机与代码实现。
- 文档中存在 `ingested``completed``cancelled`,并声明不再依赖 flag 文件作为权威状态。
- 实际实现中这些状态并未完整落地,评论/合集完成态仍依赖多个 flag 文件。
- 需要统一“文档模型”和“代码真实状态机”,避免后续继续漂移。
- 相关位置:
- `docs/state-machine.md`
- `src/biliup_next/app/api_server.py`
- `src/biliup_next/modules/comment/providers/bilibili_top_comment.py`
- `src/biliup_next/modules/collection/providers/bilibili_collection.py`
### P2
- 为状态机、重试和手工干预流程补测试。
- 当前仓库没有看到 `tests/` 或自动化回归覆盖。
- 优先覆盖:
- `task_engine`
- `task_policies`
- `task_actions`
- `retry_meta`
- `task_reset`
- 明确两套控制台的维护策略。
- 当前 React 控制台和 classic 控制台并存。
- 需要决定 classic 是长期保留、冻结维护,还是逐步退役。
## 备注
- 以上问题来自 2026-04-06 对 `biliup-next` 当前重构实现的代码审查。
- 优先顺序按“状态一致性 / 数据一致性 / 运行稳定性 / 控制面性能 / 可维护性”排列。
## 过程记录
- 2026-04-06完成首轮代码审查确认当前优先问题。
- 2026-04-06基于问题清单拆出分阶段改造计划`docs/refactor-plan-2026-04-06.md`
- 2026-04-06确定首批执行范围为 task `running` 状态落地、`full_video_bvid` 写路径统一、SQLite 连接加固。
- 2026-04-06已完成首轮代码改造。
- task 在 step 被 claim 后会进入 `running`
- `bind/rebind/webhook` 已统一复用 `full_video_bvid` 持久化路径。
- SQLite 连接已增加 `foreign_keys``busy_timeout``WAL``synchronous=NORMAL`
- 已执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。
- 2026-04-06已完成第二轮控制面改造。
- `ensure_initialized()` 已改为进程内复用,避免 API 请求重复装配全套应用状态。
- `PUT /settings` 后会主动失效并重建缓存状态,避免新旧配置混用。
- `/tasks` 列表已改为批量预取 task context 和 steps减少列表页 N+1 查询。
- 已再次执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。
- 2026-04-06已完成状态机文档对齐。
- `state-machine.md``architecture.md` 已改成当前代码真实状态集合:`created/running/transcribed/songs_detected/split_done/published/commented/collection_synced/failed_*`
- 已明确 `ingested/completed/cancelled` 当前未落地,不再作为现阶段实现口径。
- 已明确工作区 flag 仅表示交付副作用和产物标记,不作为 task 主状态事实源。
- 2026-04-06已补最小回归测试集。
- 新增 `tests/test_task_engine.py`
- 新增 `tests/test_retry_meta.py`
- 新增 `tests/test_task_actions.py`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 7 个测试全部通过。
- 2026-04-06已继续收口 `task_actions` 的写路径。
- `rebind_session_full_video_action()` 不再重复 upsert session binding。
- `merge_session_action()` 在继承 `full_video_bvid` 时已复用统一持久化路径。
- 已补对应测试,当前测试数为 8全部通过。
- 2026-04-06已补第二层状态流转测试。
- 新增 `tests/test_task_policies.py`
- 新增 `tests/test_task_runner.py`
- 已覆盖 disabled step fallback、publish 重试调度、reset 后回退状态、step claim 后 task 进入 `running`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 12 个测试全部通过。
- 2026-04-06已完成一轮 API 代码清理。
- `api_server.py` 新增批量 task payload 组装 helper。
- `/tasks``/sessions/:session_key` 已复用同一套 task payload 预取与组装逻辑。
- 已重新执行测试,当前 12 个测试全部通过。
- 2026-04-06已整理专业化路线图。
- 新增 `docs/professionalization-roadmap-2026-04-06.md`
- 按平台边界、领域模型、接口契约、测试体系、运维成熟度五个维度拆解后续改进方向。
- 已明确下一批优先项为 adapter 边界、session/delivery 领域服务收敛、serializer 层、SQLite/API 测试与 OpenAPI 对齐。
- 2026-04-06已开始落最小 adapter 边界。
- 新增 `infra/adapters/codex_cli.py`
- 新增 `infra/adapters/biliup_cli.py`
- 新增 `infra/adapters/bilibili_api.py`
- `codex``biliup_cli``bilibili_top_comment``bilibili_collection` provider 已改为依赖 adapter
- 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。
- 2026-04-06已开始落 serializer 层。
- 新增 `app/serializers.py`
- task list / task detail / session detail 的 payload 组装已从 `api_server.py` 抽到 `ControlPlaneSerializer`
- `api_server.py` 进一步收敛为路由、鉴权和响应控制
- 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。
- 2026-04-06已继续收口 serializer 层。
- task timeline 的组装逻辑已从 `api_server.py` 抽到 `ControlPlaneSerializer.timeline_payload()`
- `api_server.py` 中 task 详情相关展示逻辑继续变薄
- 已重新执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。
- 2026-04-06已补 serializer 层测试。
- 新增 `tests/test_serializers.py`
- 已覆盖 task payload、session payload、timeline payload 的控制面展示契约
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 15 个测试全部通过。
- 2026-04-06已补 repository 的 SQLite 集成测试。
- 新增 `tests/test_task_repository_sqlite.py`
- 已覆盖 `query_tasks`、批量 context/steps 查询、`session_bindings` upsert 与 fallback 读取
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 18 个测试全部通过。
- 2026-04-06已补 API 行为测试。
- 扩展 `tests/test_api_server.py`
- 已覆盖 `GET /tasks``GET /tasks/:id/timeline``GET /sessions/:session_key``PUT /settings`
- 已覆盖 control token 鉴权分支
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 2026-04-06已继续补执行面 API 行为测试。
- `tests/test_api_server.py` 已新增 `POST /tasks``POST /tasks/:id/actions/run``POST /tasks/:id/actions/retry-step``POST /tasks/:id/actions/reset-to-step`
- 已覆盖写操作成功分支与 `missing step_name` 参数校验
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 28 个测试全部通过。
- 2026-04-06已补人工干预相关 API 行为测试。
- `tests/test_api_server.py` 已新增 `POST /tasks/:id/bind-full-video``POST /sessions/:session_key/rebind``POST /sessions/:session_key/merge``POST /webhooks/full-video-uploaded`
- 已覆盖成功分支、参数校验,以及 `TASK_NOT_FOUND/SESSION_NOT_FOUND` 的状态码映射
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 37 个测试全部通过。
- 2026-04-06已补运行面 API 行为测试。
- `tests/test_api_server.py` 已新增 `POST /worker/run-once``POST /scheduler/run-once``POST /runtime/services/:name/:action``POST /stage/import`
- 已覆盖 action record 落库、副作用返回值、`invalid action``missing source_path` 错误分支
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 43 个测试全部通过。
- 2026-04-06已补剩余控制面 GET 与上传接口测试。
- `tests/test_api_server.py` 已新增 `GET /history``GET /modules``GET /scheduler/preview``GET /settings/schema``POST /stage/upload`
- `stage/upload` 成功分支已通过 patch `cgi.FieldStorage` 固定最小 handler 契约,避免 multipart 解析细节导致测试脆弱
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 49 个测试全部通过。
- 2026-04-06已开始收口 session / delivery 领域服务。
- 新增 `app/session_delivery_service.py`,承接 `bind/rebind/merge/webhook` 的核心规则与持久化路径
- `app/task_actions.py` 已改为薄封装,仅保留 `ensure_initialized()`、审计记录与 service 调用
- 新增 `tests/test_session_delivery_service.py`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 51 个测试全部通过。
- 2026-04-06已继续收口 task control 领域服务。
- 新增 `app/task_control_service.py`,承接 `run/retry/reset` 编排
- `app/task_actions.py` 已进一步变薄,`run_task_action/retry_step_action/reset_to_step_action` 改为纯 service 封装 + 审计
- 新增 `tests/test_task_control_service.py`
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 54 个测试全部通过。
- 2026-04-06已将 POST 路径分发从 API handler 中下沉。
- 新增 `app/control_plane_post_dispatcher.py`,统一承接 POST 路径的用例分发、状态码映射和运行面 action record
- `app/api_server.py``do_POST()` 已收敛为请求解析、dispatcher 调用和响应写出
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 54 个测试全部通过。
- 2026-04-06已补 dispatcher 直测。
- 新增 `tests/test_control_plane_get_dispatcher.py`
- 新增 `tests/test_control_plane_post_dispatcher.py`
- 已覆盖 dispatcher 层的状态码映射、过滤逻辑、运行面 action record 与创建任务冲突映射
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 62 个测试全部通过。
- 2026-04-06已开始做可迁移交付清理。
- `config/settings.json``config/settings.staged.json` 已替换为 standalone 默认模板,不再携带本机绝对路径和真实密钥
- `runtime/cookies.json``runtime/upload_config.json` 已替换为可分发模板
- 新增 `docs/cold-start-checklist.md`
- `README.md` 已补充冷启动入口说明
- 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v`
- 当前 63 个测试全部通过。

View File

@ -1,54 +1,54 @@
# Vision
## Goal
将当前基于目录监听和脚本拼接的流水线,重构为一个模块化、可扩展、可观测、可运维的单体系统。
系统负责:
- 接收本地视频任务
- 执行转录、识歌、切歌、上传、评论、合集归档
- 记录任务状态、产物、错误和外部结果
- 提供统一配置和管理入口
系统不负责:
- 直播录制
- 完整版视频的外部发布流程
- 多账号复杂运营后台
- 分布式调度
## Users
- 运维者:部署、启动、排查、重试任务
- 内容生产者:投放视频、观察任务状态
- 开发者:新增模块、替换外部依赖、扩展功能
## Problems In Current Project
- 状态分散在目录名、flag 文件、日志中,缺少单一事实来源
- 业务逻辑和运维逻辑耦合严重
- 配置项散落在多个脚本和常量中
- 同类逻辑重复实现,例如 B 站列表解析、合集处理、任务扫描
- 可观测性不足,失败后需要人工翻日志定位
- 扩展新能力时只能继续加脚本,结构会越来越乱
## Target Characteristics
- 模块化单体,而不是脚本集合
- 显式任务状态机
- 统一配置系统
- 外部依赖适配器化
- 结构化任务存储
- 插件式扩展点
- Web 管理台
- 文档优先
## Milestones
1. 定义架构、领域模型、模块接口和 API。
2. 建立新系统骨架,不影响旧系统运行。
3. 落地统一配置、任务状态存储和最小管理 API。
4. 按模块迁移旧能力:转录、识歌、切歌、上传、评论、合集。
5. 接入 Web 管理台。
6. 逐步切换生产流量,最终替换旧脚本体系。
# Vision
## Goal
将当前基于目录监听和脚本拼接的流水线,重构为一个模块化、可扩展、可观测、可运维的单体系统。
系统负责:
- 接收本地视频任务
- 执行转录、识歌、切歌、上传、评论、合集归档
- 记录任务状态、产物、错误和外部结果
- 提供统一配置和管理入口
系统不负责:
- 直播录制
- 完整版视频的外部发布流程
- 多账号复杂运营后台
- 分布式调度
## Users
- 运维者:部署、启动、排查、重试任务
- 内容生产者:投放视频、观察任务状态
- 开发者:新增模块、替换外部依赖、扩展功能
## Problems In Current Project
- 状态分散在目录名、flag 文件、日志中,缺少单一事实来源
- 业务逻辑和运维逻辑耦合严重
- 配置项散落在多个脚本和常量中
- 同类逻辑重复实现,例如 B 站列表解析、合集处理、任务扫描
- 可观测性不足,失败后需要人工翻日志定位
- 扩展新能力时只能继续加脚本,结构会越来越乱
## Target Characteristics
- 模块化单体,而不是脚本集合
- 显式任务状态机
- 统一配置系统
- 外部依赖适配器化
- 结构化任务存储
- 插件式扩展点
- Web 管理台
- 文档优先
## Milestones
1. 定义架构、领域模型、模块接口和 API。
2. 建立新系统骨架,不影响旧系统运行。
3. 落地统一配置、任务状态存储和最小管理 API。
4. 按模块迁移旧能力:转录、识歌、切歌、上传、评论、合集。
5. 接入 Web 管理台。
6. 逐步切换生产流量,最终替换旧脚本体系。

View File

@ -1,71 +1,71 @@
# Frontend
`frontend/` 是新的 React + Vite 控制台迁移骨架,目标是逐步替换当前 `src/biliup_next/app/static/` 下的原生前端。
当前已迁入:
- 基础导航:`Overview / Tasks / Settings / Logs`
- `Overview` 第一版
- `Tasks` 工作台第一版
- `Logs` 工作台第一版
- 任务表
- 任务详情
- 与现有 Python API 的直连
## 目录
- `src/App.jsx`
- `src/components/`
- `src/api/client.js`
- `src/lib/format.js`
- `src/styles.css`
## 启动
当前机器未安装 `npm``corepack`,所以这套前端骨架还没有在本机完成依赖安装。
有包管理器后,在 `frontend/` 下执行:
```bash
npm install
npm run dev
```
开发服务器默认地址:
```text
http://127.0.0.1:5173/ui/
```
默认会通过 `vite.config.mjs` 把这些路径代理到现有后端 `http://127.0.0.1:8787`
- `/health`
- `/doctor`
- `/tasks`
- `/settings`
- `/runtime`
- `/history`
- `/logs`
- `/modules`
- `/scheduler`
- `/worker`
- `/stage`
生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它:
```text
http://127.0.0.1:8787/
```
旧控制台回退入口:
```text
http://127.0.0.1:8787/classic
```
## 当前状态
- React 控制台已接管默认首页
- 任务页已支持 `session context / bind full video / session merge / session rebind`
- 高频任务操作已改为局部刷新
- 旧原生控制台仍保留作回退路径
# Frontend
`frontend/` 是新的 React + Vite 控制台迁移骨架,目标是逐步替换当前 `src/biliup_next/app/static/` 下的原生前端。
当前已迁入:
- 基础导航:`Overview / Tasks / Settings / Logs`
- `Overview` 第一版
- `Tasks` 工作台第一版
- `Logs` 工作台第一版
- 任务表
- 任务详情
- 与现有 Python API 的直连
## 目录
- `src/App.jsx`
- `src/components/`
- `src/api/client.js`
- `src/lib/format.js`
- `src/styles.css`
## 启动
当前机器未安装 `npm``corepack`,所以这套前端骨架还没有在本机完成依赖安装。
有包管理器后,在 `frontend/` 下执行:
```bash
npm install
npm run dev
```
开发服务器默认地址:
```text
http://127.0.0.1:5173/ui/
```
默认会通过 `vite.config.mjs` 把这些路径代理到现有后端 `http://127.0.0.1:8787`
- `/health`
- `/doctor`
- `/tasks`
- `/settings`
- `/runtime`
- `/history`
- `/logs`
- `/modules`
- `/scheduler`
- `/worker`
- `/stage`
生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它:
```text
http://127.0.0.1:8787/
```
旧控制台回退入口:
```text
http://127.0.0.1:8787/classic
```
## 当前状态
- React 控制台已接管默认首页
- 任务页已支持 `session context / bind full video / session merge / session rebind`
- 高频任务操作已改为局部刷新
- 旧原生控制台仍保留作回退路径

View File

@ -1,12 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>biliup-next Frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>biliup-next Frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,19 @@
{
"name": "biliup-next-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0"
}
}
{
"name": "biliup-next-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,67 +1,67 @@
const jsonCache = new Map();
function cacheKey(url, options = {}) {
return JSON.stringify({
url,
method: options.method || "GET",
headers: options.headers || {},
});
}
export async function fetchJson(url, options = {}) {
const token = localStorage.getItem("biliup_next_token") || "";
const headers = { ...(options.headers || {}) };
if (token) headers["X-Biliup-Token"] = token;
const response = await fetch(url, { ...options, headers });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || payload.error || JSON.stringify(payload));
}
return payload;
}
export async function fetchJsonCached(url, options = {}, ttlMs = 8000) {
const method = options.method || "GET";
if (method !== "GET") {
return fetchJson(url, options);
}
const key = cacheKey(url, options);
const cached = jsonCache.get(key);
if (cached && Date.now() - cached.time < ttlMs) {
return cached.payload;
}
const payload = await fetchJson(url, options);
jsonCache.set(key, { time: Date.now(), payload });
return payload;
}
export function primeJsonCache(url, payload, options = {}) {
const key = cacheKey(url, options);
jsonCache.set(key, { time: Date.now(), payload });
}
export function invalidateJsonCache(match) {
for (const key of jsonCache.keys()) {
if (typeof match === "string" ? key.includes(match) : match.test(key)) {
jsonCache.delete(key);
}
}
}
export async function uploadFile(url, file) {
const token = localStorage.getItem("biliup_next_token") || "";
const form = new FormData();
form.append("file", file);
const headers = {};
if (token) headers["X-Biliup-Token"] = token;
const response = await fetch(url, {
method: "POST",
headers,
body: form,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || payload.error || JSON.stringify(payload));
}
return payload;
}
const jsonCache = new Map();
function cacheKey(url, options = {}) {
return JSON.stringify({
url,
method: options.method || "GET",
headers: options.headers || {},
});
}
export async function fetchJson(url, options = {}) {
const token = localStorage.getItem("biliup_next_token") || "";
const headers = { ...(options.headers || {}) };
if (token) headers["X-Biliup-Token"] = token;
const response = await fetch(url, { ...options, headers });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || payload.error || JSON.stringify(payload));
}
return payload;
}
export async function fetchJsonCached(url, options = {}, ttlMs = 8000) {
const method = options.method || "GET";
if (method !== "GET") {
return fetchJson(url, options);
}
const key = cacheKey(url, options);
const cached = jsonCache.get(key);
if (cached && Date.now() - cached.time < ttlMs) {
return cached.payload;
}
const payload = await fetchJson(url, options);
jsonCache.set(key, { time: Date.now(), payload });
return payload;
}
export function primeJsonCache(url, payload, options = {}) {
const key = cacheKey(url, options);
jsonCache.set(key, { time: Date.now(), payload });
}
export function invalidateJsonCache(match) {
for (const key of jsonCache.keys()) {
if (typeof match === "string" ? key.includes(match) : match.test(key)) {
jsonCache.delete(key);
}
}
}
export async function uploadFile(url, file) {
const token = localStorage.getItem("biliup_next_token") || "";
const form = new FormData();
form.append("file", file);
const headers = {};
if (token) headers["X-Biliup-Token"] = token;
const response = await fetch(url, {
method: "POST",
headers,
body: form,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || payload.error || JSON.stringify(payload));
}
return payload;
}

View File

@ -1,90 +1,90 @@
import { useMemo, useState } from "react";
function buildFilteredLines(lines, query) {
if (!query) return lines;
const needle = query.toLowerCase();
return lines.filter((line) => String(line).toLowerCase().includes(needle));
}
export default function LogsPanel({
logs,
selectedLogName,
onSelectLog,
logContent,
loading,
onRefreshLog,
currentTaskTitle,
filterCurrentTask,
onToggleFilterCurrentTask,
autoRefresh,
onToggleAutoRefresh,
busy,
}) {
const [search, setSearch] = useState("");
const [lineFilter, setLineFilter] = useState("");
const visibleLogs = useMemo(() => {
if (!search) return logs;
const needle = search.toLowerCase();
return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle));
}, [logs, search]);
const filteredLines = useMemo(
() => buildFilteredLines(logContent?.lines || [], lineFilter),
[logContent?.lines, lineFilter],
);
return (
<section className="logs-layout-react">
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Logs Workspace</p>
<h2>Log Index</h2>
</div>
<div className="panel-meta">{visibleLogs.length} logs</div>
</div>
<div className="toolbar-grid compact-grid">
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索日志文件" />
</div>
<div className="log-index-list">
{visibleLogs.map((item) => (
<button
key={item.name}
className={selectedLogName === item.name ? "log-index-item active" : "log-index-item"}
onClick={() => onSelectLog(item.name)}
>
<strong>{item.name}</strong>
<span>{item.path}</span>
</button>
))}
{!visibleLogs.length ? <p className="muted">{loading ? "loading..." : "暂无日志文件"}</p> : null}
</div>
</article>
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Log Detail</p>
<h2>{selectedLogName || "选择一个日志"}</h2>
</div>
<button className="nav-btn" onClick={onRefreshLog} disabled={busy}>
{busy ? "刷新中..." : "刷新"}
</button>
</div>
<div className="toolbar-grid compact-grid">
<input value={lineFilter} onChange={(event) => setLineFilter(event.target.value)} placeholder="过滤日志行内容" />
<label className="toggle-row">
<input type="checkbox" checked={filterCurrentTask} onChange={(event) => onToggleFilterCurrentTask?.(event.target.checked)} />
<span>按当前任务过滤{currentTaskTitle ? ` · ${currentTaskTitle}` : ""}</span>
</label>
<label className="toggle-row">
<input type="checkbox" checked={autoRefresh} onChange={(event) => onToggleAutoRefresh?.(event.target.checked)} />
<span>自动刷新</span>
</label>
</div>
<pre className="log-pre">{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}</pre>
</article>
</section>
);
}
import { useMemo, useState } from "react";
function buildFilteredLines(lines, query) {
if (!query) return lines;
const needle = query.toLowerCase();
return lines.filter((line) => String(line).toLowerCase().includes(needle));
}
export default function LogsPanel({
logs,
selectedLogName,
onSelectLog,
logContent,
loading,
onRefreshLog,
currentTaskTitle,
filterCurrentTask,
onToggleFilterCurrentTask,
autoRefresh,
onToggleAutoRefresh,
busy,
}) {
const [search, setSearch] = useState("");
const [lineFilter, setLineFilter] = useState("");
const visibleLogs = useMemo(() => {
if (!search) return logs;
const needle = search.toLowerCase();
return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle));
}, [logs, search]);
const filteredLines = useMemo(
() => buildFilteredLines(logContent?.lines || [], lineFilter),
[logContent?.lines, lineFilter],
);
return (
<section className="logs-layout-react">
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Logs Workspace</p>
<h2>Log Index</h2>
</div>
<div className="panel-meta">{visibleLogs.length} logs</div>
</div>
<div className="toolbar-grid compact-grid">
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索日志文件" />
</div>
<div className="log-index-list">
{visibleLogs.map((item) => (
<button
key={item.name}
className={selectedLogName === item.name ? "log-index-item active" : "log-index-item"}
onClick={() => onSelectLog(item.name)}
>
<strong>{item.name}</strong>
<span>{item.path}</span>
</button>
))}
{!visibleLogs.length ? <p className="muted">{loading ? "loading..." : "暂无日志文件"}</p> : null}
</div>
</article>
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Log Detail</p>
<h2>{selectedLogName || "选择一个日志"}</h2>
</div>
<button className="nav-btn" onClick={onRefreshLog} disabled={busy}>
{busy ? "刷新中..." : "刷新"}
</button>
</div>
<div className="toolbar-grid compact-grid">
<input value={lineFilter} onChange={(event) => setLineFilter(event.target.value)} placeholder="过滤日志行内容" />
<label className="toggle-row">
<input type="checkbox" checked={filterCurrentTask} onChange={(event) => onToggleFilterCurrentTask?.(event.target.checked)} />
<span>按当前任务过滤{currentTaskTitle ? ` · ${currentTaskTitle}` : ""}</span>
</label>
<label className="toggle-row">
<input type="checkbox" checked={autoRefresh} onChange={(event) => onToggleAutoRefresh?.(event.target.checked)} />
<span>自动刷新</span>
</label>
</div>
<pre className="log-pre">{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}</pre>
</article>
</section>
);
}

View File

@ -1,175 +1,175 @@
import { useState } from "react";
import StatusBadge from "./StatusBadge.jsx";
import { attentionLabel, summarizeAttention } from "../lib/format.js";
function SummaryCard({ label, value, tone = "" }) {
return (
<article className="summary-card">
<span className="eyebrow">{label}</span>
<strong>{value}</strong>
{tone ? <StatusBadge tone={tone}>{tone}</StatusBadge> : null}
</article>
);
}
export default function OverviewPanel({
health,
doctorOk,
tasks,
services,
scheduler,
history,
loading,
onRefreshScheduler,
onRefreshHistory,
onRunOnce,
onServiceAction,
onStageImport,
onStageUpload,
busy,
}) {
const [stageSourcePath, setStageSourcePath] = useState("");
const [stageFile, setStageFile] = useState(null);
const taskItems = tasks?.items || [];
const serviceItems = services?.items || [];
const actionItems = history?.items || [];
const scheduled = scheduler?.scheduled || [];
const deferred = scheduler?.deferred || [];
const attentionCounts = taskItems.reduce(
(acc, task) => {
const key = summarizeAttention(task);
acc[key] = (acc[key] || 0) + 1;
return acc;
},
{},
);
return (
<section className="overview-stack-react">
<div className="overview-grid">
<SummaryCard label="Health" value={health ? "ok" : "down"} tone={health ? "good" : "hot"} />
<SummaryCard label="Doctor" value={doctorOk ? "ready" : "warn"} tone={doctorOk ? "good" : "warn"} />
<SummaryCard label="Tasks" value={String(taskItems.length)} />
</div>
<div className="detail-grid">
<article className="detail-card">
<div className="card-head-inline">
<h3>Import To Stage</h3>
</div>
<div className="stage-input-grid">
<input
value={stageSourcePath}
onChange={(event) => setStageSourcePath(event.target.value)}
placeholder="/absolute/path/to/video.mp4"
/>
<button
className="nav-btn compact-btn"
disabled={busy === "stage_import"}
onClick={async () => {
if (!stageSourcePath.trim()) return;
await onStageImport?.(stageSourcePath.trim());
setStageSourcePath("");
}}
>
{busy === "stage_import" ? "导入中..." : "复制到隔离 Stage"}
</button>
</div>
<div className="stage-input-grid upload-grid-react">
<input
type="file"
onChange={(event) => setStageFile(event.target.files?.[0] || null)}
/>
<button
className="nav-btn compact-btn strong-btn"
disabled={!stageFile || busy === "stage_upload"}
onClick={async () => {
if (!stageFile) return;
await onStageUpload?.(stageFile);
setStageFile(null);
}}
>
{busy === "stage_upload" ? "上传中..." : "上传到隔离 Stage"}
</button>
</div>
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`不会移动原文件</p>
</article>
<article className="detail-card">
<div className="card-head-inline">
<h3>Runtime Services</h3>
<button className="nav-btn compact-btn strong-btn" onClick={onRunOnce} disabled={busy === "run_once"}>
{busy === "run_once" ? "执行中..." : "执行一轮 Worker"}
</button>
</div>
<div className="list-stack">
{serviceItems.map((service) => (
<div className="list-row" key={service.id}>
<div>
<strong>{service.id}</strong>
<div className="muted">{service.description}</div>
</div>
<div className="service-actions">
<StatusBadge tone={service.active_state === "active" ? "good" : "hot"}>{service.active_state}</StatusBadge>
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "start")} disabled={busy === `service:${service.id}:start`}>start</button>
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "restart")} disabled={busy === `service:${service.id}:restart`}>restart</button>
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "stop")} disabled={busy === `service:${service.id}:stop`}>stop</button>
</div>
</div>
))}
{!serviceItems.length ? <p className="muted">{loading ? "loading..." : "暂无服务数据"}</p> : null}
</div>
</article>
<article className="detail-card">
<div className="card-head-inline">
<h3>Scheduler Queue</h3>
<button className="nav-btn compact-btn" onClick={onRefreshScheduler} disabled={busy === "refresh_scheduler"}>
{busy === "refresh_scheduler" ? "刷新中..." : "刷新"}
</button>
</div>
<div className="list-stack">
<div className="list-row"><span>scheduled</span><strong>{scheduled.length}</strong></div>
<div className="list-row"><span>deferred</span><strong>{deferred.length}</strong></div>
<div className="list-row"><span>scanned</span><strong>{scheduler?.summary?.scanned_count ?? 0}</strong></div>
<div className="list-row"><span>truncated</span><strong>{scheduler?.summary?.truncated_count ?? 0}</strong></div>
</div>
</article>
</div>
<div className="detail-grid">
<article className="detail-card">
<h3>Attention Summary</h3>
<div className="list-stack">
{Object.entries(attentionCounts).map(([key, count]) => (
<div className="list-row" key={key}>
<span>{attentionLabel(key)}</span>
<strong>{count}</strong>
</div>
))}
{!Object.keys(attentionCounts).length ? <p className="muted">暂无任务摘要</p> : null}
</div>
</article>
<article className="detail-card">
<div className="card-head-inline">
<h3>Recent Actions</h3>
<button className="nav-btn compact-btn" onClick={onRefreshHistory} disabled={busy === "refresh_history"}>
{busy === "refresh_history" ? "刷新中..." : "刷新"}
</button>
</div>
<div className="list-stack">
{actionItems.slice(0, 8).map((item) => (
<div className="list-row" key={`${item.created_at}:${item.action_name}`}>
<span>{item.action_name}</span>
<StatusBadge tone={item.status === "error" ? "hot" : item.status === "warn" ? "warn" : "good"}>{item.status}</StatusBadge>
</div>
))}
{!actionItems.length ? <p className="muted">{loading ? "loading..." : "暂无动作记录"}</p> : null}
</div>
</article>
</div>
</section>
);
}
import { useState } from "react";
import StatusBadge from "./StatusBadge.jsx";
import { attentionLabel, summarizeAttention } from "../lib/format.js";
function SummaryCard({ label, value, tone = "" }) {
return (
<article className="summary-card">
<span className="eyebrow">{label}</span>
<strong>{value}</strong>
{tone ? <StatusBadge tone={tone}>{tone}</StatusBadge> : null}
</article>
);
}
export default function OverviewPanel({
health,
doctorOk,
tasks,
services,
scheduler,
history,
loading,
onRefreshScheduler,
onRefreshHistory,
onRunOnce,
onServiceAction,
onStageImport,
onStageUpload,
busy,
}) {
const [stageSourcePath, setStageSourcePath] = useState("");
const [stageFile, setStageFile] = useState(null);
const taskItems = tasks?.items || [];
const serviceItems = services?.items || [];
const actionItems = history?.items || [];
const scheduled = scheduler?.scheduled || [];
const deferred = scheduler?.deferred || [];
const attentionCounts = taskItems.reduce(
(acc, task) => {
const key = summarizeAttention(task);
acc[key] = (acc[key] || 0) + 1;
return acc;
},
{},
);
return (
<section className="overview-stack-react">
<div className="overview-grid">
<SummaryCard label="Health" value={health ? "ok" : "down"} tone={health ? "good" : "hot"} />
<SummaryCard label="Doctor" value={doctorOk ? "ready" : "warn"} tone={doctorOk ? "good" : "warn"} />
<SummaryCard label="Tasks" value={String(taskItems.length)} />
</div>
<div className="detail-grid">
<article className="detail-card">
<div className="card-head-inline">
<h3>Import To Stage</h3>
</div>
<div className="stage-input-grid">
<input
value={stageSourcePath}
onChange={(event) => setStageSourcePath(event.target.value)}
placeholder="/absolute/path/to/video.mp4"
/>
<button
className="nav-btn compact-btn"
disabled={busy === "stage_import"}
onClick={async () => {
if (!stageSourcePath.trim()) return;
await onStageImport?.(stageSourcePath.trim());
setStageSourcePath("");
}}
>
{busy === "stage_import" ? "导入中..." : "复制到隔离 Stage"}
</button>
</div>
<div className="stage-input-grid upload-grid-react">
<input
type="file"
onChange={(event) => setStageFile(event.target.files?.[0] || null)}
/>
<button
className="nav-btn compact-btn strong-btn"
disabled={!stageFile || busy === "stage_upload"}
onClick={async () => {
if (!stageFile) return;
await onStageUpload?.(stageFile);
setStageFile(null);
}}
>
{busy === "stage_upload" ? "上传中..." : "上传到隔离 Stage"}
</button>
</div>
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`不会移动原文件</p>
</article>
<article className="detail-card">
<div className="card-head-inline">
<h3>Runtime Services</h3>
<button className="nav-btn compact-btn strong-btn" onClick={onRunOnce} disabled={busy === "run_once"}>
{busy === "run_once" ? "执行中..." : "执行一轮 Worker"}
</button>
</div>
<div className="list-stack">
{serviceItems.map((service) => (
<div className="list-row" key={service.id}>
<div>
<strong>{service.id}</strong>
<div className="muted">{service.description}</div>
</div>
<div className="service-actions">
<StatusBadge tone={service.active_state === "active" ? "good" : "hot"}>{service.active_state}</StatusBadge>
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "start")} disabled={busy === `service:${service.id}:start`}>start</button>
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "restart")} disabled={busy === `service:${service.id}:restart`}>restart</button>
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "stop")} disabled={busy === `service:${service.id}:stop`}>stop</button>
</div>
</div>
))}
{!serviceItems.length ? <p className="muted">{loading ? "loading..." : "暂无服务数据"}</p> : null}
</div>
</article>
<article className="detail-card">
<div className="card-head-inline">
<h3>Scheduler Queue</h3>
<button className="nav-btn compact-btn" onClick={onRefreshScheduler} disabled={busy === "refresh_scheduler"}>
{busy === "refresh_scheduler" ? "刷新中..." : "刷新"}
</button>
</div>
<div className="list-stack">
<div className="list-row"><span>scheduled</span><strong>{scheduled.length}</strong></div>
<div className="list-row"><span>deferred</span><strong>{deferred.length}</strong></div>
<div className="list-row"><span>scanned</span><strong>{scheduler?.summary?.scanned_count ?? 0}</strong></div>
<div className="list-row"><span>truncated</span><strong>{scheduler?.summary?.truncated_count ?? 0}</strong></div>
</div>
</article>
</div>
<div className="detail-grid">
<article className="detail-card">
<h3>Attention Summary</h3>
<div className="list-stack">
{Object.entries(attentionCounts).map(([key, count]) => (
<div className="list-row" key={key}>
<span>{attentionLabel(key)}</span>
<strong>{count}</strong>
</div>
))}
{!Object.keys(attentionCounts).length ? <p className="muted">暂无任务摘要</p> : null}
</div>
</article>
<article className="detail-card">
<div className="card-head-inline">
<h3>Recent Actions</h3>
<button className="nav-btn compact-btn" onClick={onRefreshHistory} disabled={busy === "refresh_history"}>
{busy === "refresh_history" ? "刷新中..." : "刷新"}
</button>
</div>
<div className="list-stack">
{actionItems.slice(0, 8).map((item) => (
<div className="list-row" key={`${item.created_at}:${item.action_name}`}>
<span>{item.action_name}</span>
<StatusBadge tone={item.status === "error" ? "hot" : item.status === "warn" ? "warn" : "good"}>{item.status}</StatusBadge>
</div>
))}
{!actionItems.length ? <p className="muted">{loading ? "loading..." : "暂无动作记录"}</p> : null}
</div>
</article>
</div>
</section>
);
}

View File

@ -1,310 +1,310 @@
import { useEffect, useMemo, useState } from "react";
const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__";
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function fieldKey(groupName, fieldName) {
return `${groupName}.${fieldName}`;
}
function compareEntries(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]), "zh-CN");
}
function stableStringify(value) {
return JSON.stringify(value ?? {}, null, 2);
}
function FieldInput({ groupName, fieldName, schema, value, onChange }) {
if (schema.type === "boolean") {
return (
<input
type="checkbox"
checked={Boolean(value)}
onChange={(event) => onChange(groupName, fieldName, event.target.checked)}
/>
);
}
if (Array.isArray(schema.enum)) {
return (
<select value={String(value ?? "")} onChange={(event) => onChange(groupName, fieldName, event.target.value)}>
{schema.enum.map((optionValue) => (
<option key={String(optionValue)} value={String(optionValue)}>
{String(optionValue)}
</option>
))}
</select>
);
}
if (schema.type === "array") {
return (
<textarea
value={JSON.stringify(value ?? [], null, 2)}
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
/>
);
}
return (
<input
type={schema.sensitive ? "password" : schema.type === "integer" ? "number" : "text"}
value={value ?? ""}
min={schema.type === "integer" && typeof schema.minimum === "number" ? schema.minimum : undefined}
step={schema.type === "integer" ? 1 : undefined}
placeholder={schema.ui_placeholder || ""}
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
/>
);
}
function normalizeFieldValue(schema, rawValue) {
if (schema.type === "boolean") return Boolean(rawValue);
if (schema.type === "integer") {
if (rawValue === "" || Number.isNaN(Number(rawValue))) return { error: "必须填写整数" };
const value = Number(rawValue);
if (typeof schema.minimum === "number" && value < schema.minimum) {
return { error: `最小值为 ${schema.minimum}` };
}
return { value };
}
if (schema.type === "array") {
try {
const value = typeof rawValue === "string" ? JSON.parse(rawValue || "[]") : rawValue;
if (!Array.isArray(value)) return { error: "必须是 JSON 数组" };
return { value };
} catch {
return { error: "必须是 JSON 数组" };
}
}
return { value: rawValue };
}
export default function SettingsPanel({ settings, schema, onSave, loading }) {
const [draft, setDraft] = useState({});
const [rawDraft, setRawDraft] = useState("{}");
const [search, setSearch] = useState("");
const [errors, setErrors] = useState({});
const [dirty, setDirty] = useState({});
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState("");
const [jsonError, setJsonError] = useState("");
useEffect(() => {
const nextDraft = clone(settings || {});
setDraft(nextDraft);
setRawDraft(stableStringify(nextDraft));
setErrors({});
setDirty({});
setJsonError("");
setSaveMessage("");
}, [settings]);
const groups = useMemo(() => {
const entries = Object.entries(schema?.groups || {}).sort((a, b) => {
const orderA = Number(schema?.group_ui?.[a[0]]?.order || 9999);
const orderB = Number(schema?.group_ui?.[b[0]]?.order || 9999);
return orderA - orderB;
});
const needle = search.trim().toLowerCase();
return entries
.map(([groupName, fields]) => {
const featured = [];
const advanced = [];
Object.entries(fields)
.sort(compareEntries)
.forEach(([fieldName, fieldSchema]) => {
const haystack = `${groupName}.${fieldName} ${fieldSchema.title || ""} ${fieldSchema.description || ""}`.toLowerCase();
if (needle && !haystack.includes(needle)) return;
(fieldSchema.ui_featured ? featured : advanced).push([fieldName, fieldSchema]);
});
return [groupName, featured, advanced];
})
.filter(([, featured, advanced]) => featured.length || advanced.length);
}, [schema, search]);
function handleFieldChange(groupName, fieldName, rawValue) {
const fieldSchema = schema.groups[groupName][fieldName];
const result = normalizeFieldValue(fieldSchema, rawValue);
setDraft((current) => {
const next = clone(current);
next[groupName] ??= {};
next[groupName][fieldName] = result.value ?? rawValue;
setRawDraft(stableStringify(next));
return next;
});
const key = fieldKey(groupName, fieldName);
setDirty((current) => ({ ...current, [key]: true }));
setSaveMessage("");
setErrors((current) => {
const next = { ...current };
if (result.error) next[key] = result.error;
else delete next[key];
return next;
});
}
function handleRevertField(groupName, fieldName) {
const key = fieldKey(groupName, fieldName);
const originalValue = settings?.[groupName]?.[fieldName];
const fieldSchema = schema.groups[groupName][fieldName];
const rawValue = fieldSchema.type === "array" ? clone(originalValue ?? []) : originalValue ?? "";
setDraft((current) => {
const next = clone(current);
next[groupName] ??= {};
next[groupName][fieldName] = rawValue;
setRawDraft(stableStringify(next));
return next;
});
setDirty((current) => {
const next = { ...current };
delete next[key];
return next;
});
setErrors((current) => {
const next = { ...current };
delete next[key];
return next;
});
setSaveMessage("");
}
function syncJsonToForm() {
try {
const parsed = JSON.parse(rawDraft || "{}");
setDraft(parsed);
setJsonError("");
setErrors({});
setDirty({});
setSaveMessage("JSON 已同步到表单");
} catch {
setJsonError("JSON 格式无效,无法同步到表单");
}
}
function syncFormToJson() {
setRawDraft(stableStringify(draft));
setJsonError("");
setSaveMessage("表单已同步到 JSON");
}
async function handleSave() {
if (Object.keys(errors).length || jsonError) return;
setSaving(true);
try {
const saved = await onSave(draft);
const nextDraft = clone(saved || draft);
setDraft(nextDraft);
setRawDraft(stableStringify(nextDraft));
setDirty({});
setErrors({});
setSaveMessage("Settings 已保存");
setJsonError("");
} catch (error) {
setSaveMessage(`保存失败: ${error}`);
} finally {
setSaving(false);
}
}
return (
<section className="settings-layout-react">
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Settings Workspace</p>
<h2>Schema Form</h2>
</div>
<button className="nav-btn" onClick={handleSave} disabled={saving || Boolean(Object.keys(errors).length)}>
{saving ? "保存中..." : "保存 Settings"}
</button>
</div>
<div className="toolbar-grid compact-grid">
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索配置项" />
</div>
<div className="settings-note-stack">
<p className="muted">
敏感字段显示为 <code>{SECRET_PLACEHOLDER}</code>保留占位符表示不改原值改成空字符串表示清空
</p>
{saveMessage ? <div className="status-inline-note">{saveMessage}</div> : null}
{jsonError ? <div className="status-inline-note error">{jsonError}</div> : null}
</div>
<div className="settings-react-groups">
{groups.map(([groupName, featured, advanced]) => (
<section className="detail-card" key={groupName}>
<div className="settings-group-head">
<div>
<p className="eyebrow">{groupName}</p>
<h3>{schema.group_ui?.[groupName]?.title || groupName}</h3>
</div>
{schema.group_ui?.[groupName]?.description ? (
<p className="muted">{schema.group_ui[groupName].description}</p>
) : null}
</div>
<div className="settings-field-grid">
{[...featured, ...advanced].map(([fieldName, fieldSchema]) => {
const key = fieldKey(groupName, fieldName);
return (
<label className={`settings-field-card ${dirty[key] ? "dirty" : ""} ${errors[key] ? "error" : ""}`} key={key}>
<div className="settings-field-head">
<strong>{fieldSchema.title || key}</strong>
<div className="status-row">
{fieldSchema.ui_widget ? <span className="status-badge">{fieldSchema.ui_widget}</span> : null}
{fieldSchema.ui_featured ? <span className="status-badge warn">featured</span> : null}
{dirty[key] ? (
<button
type="button"
className="nav-btn compact-btn"
onClick={() => handleRevertField(groupName, fieldName)}
>
撤销
</button>
) : null}
</div>
</div>
{fieldSchema.description ? <span className="muted">{fieldSchema.description}</span> : null}
<FieldInput
groupName={groupName}
fieldName={fieldName}
schema={fieldSchema}
value={draft[groupName]?.[fieldName]}
onChange={handleFieldChange}
/>
{errors[key] ? <span className="field-error-react">{errors[key]}</span> : null}
</label>
);
})}
</div>
</section>
))}
{!groups.length ? <article className="detail-card"><p className="muted">{loading ? "loading..." : "没有匹配配置项"}</p></article> : null}
</div>
<section className="detail-card">
<div className="card-head-inline">
<div>
<p className="eyebrow">Advanced</p>
<h3>Raw JSON</h3>
</div>
<div className="row-actions">
<button type="button" className="nav-btn compact-btn" onClick={syncFormToJson}>表单同步到 JSON</button>
<button type="button" className="nav-btn compact-btn" onClick={syncJsonToForm}>JSON 重绘表单</button>
</div>
</div>
<textarea
className="settings-json-editor"
value={rawDraft}
onChange={(event) => {
setRawDraft(event.target.value);
setJsonError("");
setSaveMessage("");
}}
/>
</section>
</article>
</section>
);
}
import { useEffect, useMemo, useState } from "react";
const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__";
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function fieldKey(groupName, fieldName) {
return `${groupName}.${fieldName}`;
}
function compareEntries(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]), "zh-CN");
}
function stableStringify(value) {
return JSON.stringify(value ?? {}, null, 2);
}
function FieldInput({ groupName, fieldName, schema, value, onChange }) {
if (schema.type === "boolean") {
return (
<input
type="checkbox"
checked={Boolean(value)}
onChange={(event) => onChange(groupName, fieldName, event.target.checked)}
/>
);
}
if (Array.isArray(schema.enum)) {
return (
<select value={String(value ?? "")} onChange={(event) => onChange(groupName, fieldName, event.target.value)}>
{schema.enum.map((optionValue) => (
<option key={String(optionValue)} value={String(optionValue)}>
{String(optionValue)}
</option>
))}
</select>
);
}
if (schema.type === "array") {
return (
<textarea
value={JSON.stringify(value ?? [], null, 2)}
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
/>
);
}
return (
<input
type={schema.sensitive ? "password" : schema.type === "integer" ? "number" : "text"}
value={value ?? ""}
min={schema.type === "integer" && typeof schema.minimum === "number" ? schema.minimum : undefined}
step={schema.type === "integer" ? 1 : undefined}
placeholder={schema.ui_placeholder || ""}
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
/>
);
}
function normalizeFieldValue(schema, rawValue) {
if (schema.type === "boolean") return Boolean(rawValue);
if (schema.type === "integer") {
if (rawValue === "" || Number.isNaN(Number(rawValue))) return { error: "必须填写整数" };
const value = Number(rawValue);
if (typeof schema.minimum === "number" && value < schema.minimum) {
return { error: `最小值为 ${schema.minimum}` };
}
return { value };
}
if (schema.type === "array") {
try {
const value = typeof rawValue === "string" ? JSON.parse(rawValue || "[]") : rawValue;
if (!Array.isArray(value)) return { error: "必须是 JSON 数组" };
return { value };
} catch {
return { error: "必须是 JSON 数组" };
}
}
return { value: rawValue };
}
export default function SettingsPanel({ settings, schema, onSave, loading }) {
const [draft, setDraft] = useState({});
const [rawDraft, setRawDraft] = useState("{}");
const [search, setSearch] = useState("");
const [errors, setErrors] = useState({});
const [dirty, setDirty] = useState({});
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState("");
const [jsonError, setJsonError] = useState("");
useEffect(() => {
const nextDraft = clone(settings || {});
setDraft(nextDraft);
setRawDraft(stableStringify(nextDraft));
setErrors({});
setDirty({});
setJsonError("");
setSaveMessage("");
}, [settings]);
const groups = useMemo(() => {
const entries = Object.entries(schema?.groups || {}).sort((a, b) => {
const orderA = Number(schema?.group_ui?.[a[0]]?.order || 9999);
const orderB = Number(schema?.group_ui?.[b[0]]?.order || 9999);
return orderA - orderB;
});
const needle = search.trim().toLowerCase();
return entries
.map(([groupName, fields]) => {
const featured = [];
const advanced = [];
Object.entries(fields)
.sort(compareEntries)
.forEach(([fieldName, fieldSchema]) => {
const haystack = `${groupName}.${fieldName} ${fieldSchema.title || ""} ${fieldSchema.description || ""}`.toLowerCase();
if (needle && !haystack.includes(needle)) return;
(fieldSchema.ui_featured ? featured : advanced).push([fieldName, fieldSchema]);
});
return [groupName, featured, advanced];
})
.filter(([, featured, advanced]) => featured.length || advanced.length);
}, [schema, search]);
function handleFieldChange(groupName, fieldName, rawValue) {
const fieldSchema = schema.groups[groupName][fieldName];
const result = normalizeFieldValue(fieldSchema, rawValue);
setDraft((current) => {
const next = clone(current);
next[groupName] ??= {};
next[groupName][fieldName] = result.value ?? rawValue;
setRawDraft(stableStringify(next));
return next;
});
const key = fieldKey(groupName, fieldName);
setDirty((current) => ({ ...current, [key]: true }));
setSaveMessage("");
setErrors((current) => {
const next = { ...current };
if (result.error) next[key] = result.error;
else delete next[key];
return next;
});
}
function handleRevertField(groupName, fieldName) {
const key = fieldKey(groupName, fieldName);
const originalValue = settings?.[groupName]?.[fieldName];
const fieldSchema = schema.groups[groupName][fieldName];
const rawValue = fieldSchema.type === "array" ? clone(originalValue ?? []) : originalValue ?? "";
setDraft((current) => {
const next = clone(current);
next[groupName] ??= {};
next[groupName][fieldName] = rawValue;
setRawDraft(stableStringify(next));
return next;
});
setDirty((current) => {
const next = { ...current };
delete next[key];
return next;
});
setErrors((current) => {
const next = { ...current };
delete next[key];
return next;
});
setSaveMessage("");
}
function syncJsonToForm() {
try {
const parsed = JSON.parse(rawDraft || "{}");
setDraft(parsed);
setJsonError("");
setErrors({});
setDirty({});
setSaveMessage("JSON 已同步到表单");
} catch {
setJsonError("JSON 格式无效,无法同步到表单");
}
}
function syncFormToJson() {
setRawDraft(stableStringify(draft));
setJsonError("");
setSaveMessage("表单已同步到 JSON");
}
async function handleSave() {
if (Object.keys(errors).length || jsonError) return;
setSaving(true);
try {
const saved = await onSave(draft);
const nextDraft = clone(saved || draft);
setDraft(nextDraft);
setRawDraft(stableStringify(nextDraft));
setDirty({});
setErrors({});
setSaveMessage("Settings 已保存");
setJsonError("");
} catch (error) {
setSaveMessage(`保存失败: ${error}`);
} finally {
setSaving(false);
}
}
return (
<section className="settings-layout-react">
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Settings Workspace</p>
<h2>Schema Form</h2>
</div>
<button className="nav-btn" onClick={handleSave} disabled={saving || Boolean(Object.keys(errors).length)}>
{saving ? "保存中..." : "保存 Settings"}
</button>
</div>
<div className="toolbar-grid compact-grid">
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索配置项" />
</div>
<div className="settings-note-stack">
<p className="muted">
敏感字段显示为 <code>{SECRET_PLACEHOLDER}</code>保留占位符表示不改原值改成空字符串表示清空
</p>
{saveMessage ? <div className="status-inline-note">{saveMessage}</div> : null}
{jsonError ? <div className="status-inline-note error">{jsonError}</div> : null}
</div>
<div className="settings-react-groups">
{groups.map(([groupName, featured, advanced]) => (
<section className="detail-card" key={groupName}>
<div className="settings-group-head">
<div>
<p className="eyebrow">{groupName}</p>
<h3>{schema.group_ui?.[groupName]?.title || groupName}</h3>
</div>
{schema.group_ui?.[groupName]?.description ? (
<p className="muted">{schema.group_ui[groupName].description}</p>
) : null}
</div>
<div className="settings-field-grid">
{[...featured, ...advanced].map(([fieldName, fieldSchema]) => {
const key = fieldKey(groupName, fieldName);
return (
<label className={`settings-field-card ${dirty[key] ? "dirty" : ""} ${errors[key] ? "error" : ""}`} key={key}>
<div className="settings-field-head">
<strong>{fieldSchema.title || key}</strong>
<div className="status-row">
{fieldSchema.ui_widget ? <span className="status-badge">{fieldSchema.ui_widget}</span> : null}
{fieldSchema.ui_featured ? <span className="status-badge warn">featured</span> : null}
{dirty[key] ? (
<button
type="button"
className="nav-btn compact-btn"
onClick={() => handleRevertField(groupName, fieldName)}
>
撤销
</button>
) : null}
</div>
</div>
{fieldSchema.description ? <span className="muted">{fieldSchema.description}</span> : null}
<FieldInput
groupName={groupName}
fieldName={fieldName}
schema={fieldSchema}
value={draft[groupName]?.[fieldName]}
onChange={handleFieldChange}
/>
{errors[key] ? <span className="field-error-react">{errors[key]}</span> : null}
</label>
);
})}
</div>
</section>
))}
{!groups.length ? <article className="detail-card"><p className="muted">{loading ? "loading..." : "没有匹配配置项"}</p></article> : null}
</div>
<section className="detail-card">
<div className="card-head-inline">
<div>
<p className="eyebrow">Advanced</p>
<h3>Raw JSON</h3>
</div>
<div className="row-actions">
<button type="button" className="nav-btn compact-btn" onClick={syncFormToJson}>表单同步到 JSON</button>
<button type="button" className="nav-btn compact-btn" onClick={syncJsonToForm}>JSON 重绘表单</button>
</div>
</div>
<textarea
className="settings-json-editor"
value={rawDraft}
onChange={(event) => {
setRawDraft(event.target.value);
setJsonError("");
setSaveMessage("");
}}
/>
</section>
</article>
</section>
);
}

View File

@ -1,6 +1,6 @@
import { statusClass } from "../lib/format.js";
export default function StatusBadge({ children, tone }) {
const klass = tone || statusClass(String(children));
return <span className={`status-badge ${klass}`.trim()}>{children}</span>;
}
import { statusClass } from "../lib/format.js";
export default function StatusBadge({ children, tone }) {
const klass = tone || statusClass(String(children));
return <span className={`status-badge ${klass}`.trim()}>{children}</span>;
}

View File

@ -1,270 +1,270 @@
import { useEffect, useState } from "react";
import StatusBadge from "./StatusBadge.jsx";
import {
actionAdvice,
attentionLabel,
currentStepLabel,
deliveryLabel,
formatDate,
summarizeAttention,
summarizeDelivery,
recommendedAction,
taskDisplayStatus,
} from "../lib/format.js";
function SummaryRow({ label, value }) {
return (
<div className="detail-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function suggestedStepName(steps) {
const items = steps?.items || [];
const retryable = items.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status));
return retryable?.step_name || items.find((step) => step.status !== "succeeded")?.step_name || "";
}
export default function TaskDetailCard({
payload,
session,
loading,
actionBusy,
selectedStepName,
onSelectStep,
onRetryStep,
onResetStep,
onBindFullVideo,
onOpenSessionTask,
onSessionMerge,
onSessionRebind,
}) {
const [fullVideoInput, setFullVideoInput] = useState("");
const [sessionRebindInput, setSessionRebindInput] = useState("");
const [sessionMergeInput, setSessionMergeInput] = useState("");
const task = payload?.task;
const steps = payload?.steps;
const artifacts = payload?.artifacts;
const history = payload?.history;
const context = payload?.context;
const delivery = task?.delivery_state || {};
const latestAction = history?.items?.[0];
const sessionContext = task?.session_context || context || {};
const activeStepName = selectedStepName || suggestedStepName(steps);
const splitUrl = sessionContext.video_links?.split_video_url;
const fullUrl = sessionContext.video_links?.full_video_url;
const nextAction = recommendedAction(task);
useEffect(() => {
setFullVideoInput(sessionContext.full_video_bvid || "");
}, [sessionContext.full_video_bvid, task?.id]);
useEffect(() => {
setSessionRebindInput(session?.full_video_bvid || "");
setSessionMergeInput("");
}, [session?.full_video_bvid, session?.session_key]);
if (loading) {
return (
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Task Detail</p>
<h2>Loading...</h2>
</div>
</div>
</article>
);
}
if (!payload?.task) {
return (
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Task Detail</p>
<h2>选择一个任务</h2>
</div>
</div>
</article>
);
}
return (
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Task Detail</p>
<h2>{task.title}</h2>
<p className="muted detail-lead">{actionAdvice(task)}</p>
</div>
<div className="status-row">
<StatusBadge>{taskDisplayStatus(task)}</StatusBadge>
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
<button className="nav-btn compact-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
{actionBusy === "retry" ? "重试中..." : "重试当前步骤"}
</button>
<button className="nav-btn compact-btn strong-btn" onClick={() => onResetStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
{actionBusy === "reset" ? "重置中..." : "重置到此步骤"}
</button>
</div>
</div>
<div className="detail-grid">
<section className="detail-card">
<h3>Recommended Next Step</h3>
<SummaryRow label="Action" value={nextAction.label} />
<p className="muted">{nextAction.detail}</p>
<div className="row-actions" style={{ marginTop: 12 }}>
{nextAction.action === "retry" ? (
<button className="nav-btn compact-btn strong-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
{actionBusy === "retry" ? "重试中..." : nextAction.label}
</button>
) : splitUrl ? (
<a className="nav-btn compact-btn strong-btn" href={splitUrl} target="_blank" rel="noreferrer">打开当前结果</a>
) : null}
</div>
</section>
<section className="detail-card">
<h3>Current State</h3>
<SummaryRow label="Task ID" value={task.id} />
<SummaryRow label="Current Step" value={currentStepLabel(task, steps?.items || [])} />
<SummaryRow label="Updated" value={formatDate(task.updated_at)} />
<SummaryRow label="Next Retry" value={formatDate(task.retry_state?.next_retry_at)} />
<SummaryRow label="Split Comment" value={deliveryLabel(delivery.split_comment || "pending")} />
<SummaryRow label="Full Timeline" value={deliveryLabel(delivery.full_video_timeline_comment || "pending")} />
<SummaryRow label="Cleanup" value={deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present")} />
</section>
<section className="detail-card">
<h3>Latest Action</h3>
{latestAction ? (
<>
<SummaryRow label="Action" value={latestAction.action_name} />
<SummaryRow label="Status" value={latestAction.status} />
<SummaryRow label="Summary" value={latestAction.summary || "-"} />
</>
) : (
<p className="muted">暂无动作记录</p>
)}
</section>
</div>
<div className="detail-grid">
<section className="detail-card">
<h3>Delivery & Context</h3>
<SummaryRow label="Split BV" value={sessionContext.split_bvid || "-"} />
<SummaryRow label="Full BV" value={sessionContext.full_video_bvid || "-"} />
<SummaryRow label="Session Key" value={sessionContext.session_key || "-"} />
<SummaryRow label="Streamer" value={sessionContext.streamer || "-"} />
<SummaryRow label="Context Source" value={sessionContext.context_source || "-"} />
<div className="row-actions" style={{ marginTop: 12 }}>
{splitUrl ? <a className="nav-btn compact-btn" href={splitUrl} target="_blank" rel="noreferrer">打开分P</a> : null}
{fullUrl ? <a className="nav-btn compact-btn" href={fullUrl} target="_blank" rel="noreferrer">打开完整版</a> : null}
</div>
<div className="bind-block">
<label className="muted">绑定完整版 BV</label>
<input value={fullVideoInput} onChange={(event) => setFullVideoInput(event.target.value)} placeholder="BV1..." />
<div className="row-actions">
<button
className="nav-btn compact-btn strong-btn"
onClick={() => onBindFullVideo?.(fullVideoInput.trim())}
disabled={actionBusy}
>
{actionBusy === "bind_full_video" ? "绑定中..." : "绑定完整版 BV"}
</button>
</div>
</div>
</section>
<section className="detail-card">
<h3>Steps</h3>
<div className="list-stack">
{steps?.items?.map((step) => (
<button
type="button"
key={step.step_name}
className={activeStepName === step.step_name ? "list-row selectable active" : "list-row selectable"}
onClick={() => onSelectStep?.(step.step_name)}
>
<span>{step.step_name}</span>
<StatusBadge>{step.status}</StatusBadge>
</button>
))}
</div>
{activeStepName ? (
<div className="selected-step-note">
当前选中 step: <strong>{activeStepName}</strong>
</div>
) : null}
</section>
<section className="detail-card">
<h3>Artifacts</h3>
<div className="list-stack">
{artifacts?.items?.slice(0, 8).map((artifact) => (
<div key={`${artifact.artifact_type}:${artifact.path}`} className="list-row">
<span>{artifact.artifact_type}</span>
<span className="muted">{artifact.path}</span>
</div>
))}
</div>
</section>
</div>
<div className="detail-grid">
<section className="detail-card session-card-full">
<h3>Session Workspace</h3>
{!session?.session_key ? (
<p className="muted">当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息</p>
) : (
<>
<SummaryRow label="Session Key" value={session.session_key} />
<SummaryRow label="Task Count" value={String(session.task_count || 0)} />
<SummaryRow label="Session Full BV" value={session.full_video_bvid || "-"} />
<div className="bind-block">
<label className="muted">整个 Session 重绑 BV</label>
<input value={sessionRebindInput} onChange={(event) => setSessionRebindInput(event.target.value)} placeholder="BV1..." />
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={() => onSessionRebind?.(sessionRebindInput.trim())}
disabled={actionBusy}
>
{actionBusy === "session_rebind" ? "重绑中..." : "Session 重绑 BV"}
</button>
</div>
</div>
<div className="bind-block">
<label className="muted">合并任务到当前 Session</label>
<input value={sessionMergeInput} onChange={(event) => setSessionMergeInput(event.target.value)} placeholder="输入 task id用逗号分隔" />
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={() => onSessionMerge?.(sessionMergeInput)}
disabled={actionBusy}
>
{actionBusy === "session_merge" ? "合并中..." : "合并到当前 Session"}
</button>
</div>
</div>
<div className="list-stack">
{(session.tasks || []).map((item) => (
<button
key={item.id}
type="button"
className="list-row selectable"
onClick={() => onOpenSessionTask?.(item.id)}
>
<span>{item.title}</span>
<StatusBadge>{taskDisplayStatus(item)}</StatusBadge>
</button>
))}
</div>
</>
)}
</section>
</div>
</article>
);
}
import { useEffect, useState } from "react";
import StatusBadge from "./StatusBadge.jsx";
import {
actionAdvice,
attentionLabel,
currentStepLabel,
deliveryLabel,
formatDate,
summarizeAttention,
summarizeDelivery,
recommendedAction,
taskDisplayStatus,
} from "../lib/format.js";
function SummaryRow({ label, value }) {
return (
<div className="detail-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function suggestedStepName(steps) {
const items = steps?.items || [];
const retryable = items.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status));
return retryable?.step_name || items.find((step) => step.status !== "succeeded")?.step_name || "";
}
export default function TaskDetailCard({
payload,
session,
loading,
actionBusy,
selectedStepName,
onSelectStep,
onRetryStep,
onResetStep,
onBindFullVideo,
onOpenSessionTask,
onSessionMerge,
onSessionRebind,
}) {
const [fullVideoInput, setFullVideoInput] = useState("");
const [sessionRebindInput, setSessionRebindInput] = useState("");
const [sessionMergeInput, setSessionMergeInput] = useState("");
const task = payload?.task;
const steps = payload?.steps;
const artifacts = payload?.artifacts;
const history = payload?.history;
const context = payload?.context;
const delivery = task?.delivery_state || {};
const latestAction = history?.items?.[0];
const sessionContext = task?.session_context || context || {};
const activeStepName = selectedStepName || suggestedStepName(steps);
const splitUrl = sessionContext.video_links?.split_video_url;
const fullUrl = sessionContext.video_links?.full_video_url;
const nextAction = recommendedAction(task);
useEffect(() => {
setFullVideoInput(sessionContext.full_video_bvid || "");
}, [sessionContext.full_video_bvid, task?.id]);
useEffect(() => {
setSessionRebindInput(session?.full_video_bvid || "");
setSessionMergeInput("");
}, [session?.full_video_bvid, session?.session_key]);
if (loading) {
return (
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Task Detail</p>
<h2>Loading...</h2>
</div>
</div>
</article>
);
}
if (!payload?.task) {
return (
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Task Detail</p>
<h2>选择一个任务</h2>
</div>
</div>
</article>
);
}
return (
<article className="panel detail-panel">
<div className="panel-head">
<div>
<p className="eyebrow">Task Detail</p>
<h2>{task.title}</h2>
<p className="muted detail-lead">{actionAdvice(task)}</p>
</div>
<div className="status-row">
<StatusBadge>{taskDisplayStatus(task)}</StatusBadge>
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
<button className="nav-btn compact-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
{actionBusy === "retry" ? "重试中..." : "重试当前步骤"}
</button>
<button className="nav-btn compact-btn strong-btn" onClick={() => onResetStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
{actionBusy === "reset" ? "重置中..." : "重置到此步骤"}
</button>
</div>
</div>
<div className="detail-grid">
<section className="detail-card">
<h3>Recommended Next Step</h3>
<SummaryRow label="Action" value={nextAction.label} />
<p className="muted">{nextAction.detail}</p>
<div className="row-actions" style={{ marginTop: 12 }}>
{nextAction.action === "retry" ? (
<button className="nav-btn compact-btn strong-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
{actionBusy === "retry" ? "重试中..." : nextAction.label}
</button>
) : splitUrl ? (
<a className="nav-btn compact-btn strong-btn" href={splitUrl} target="_blank" rel="noreferrer">打开当前结果</a>
) : null}
</div>
</section>
<section className="detail-card">
<h3>Current State</h3>
<SummaryRow label="Task ID" value={task.id} />
<SummaryRow label="Current Step" value={currentStepLabel(task, steps?.items || [])} />
<SummaryRow label="Updated" value={formatDate(task.updated_at)} />
<SummaryRow label="Next Retry" value={formatDate(task.retry_state?.next_retry_at)} />
<SummaryRow label="Split Comment" value={deliveryLabel(delivery.split_comment || "pending")} />
<SummaryRow label="Full Timeline" value={deliveryLabel(delivery.full_video_timeline_comment || "pending")} />
<SummaryRow label="Cleanup" value={deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present")} />
</section>
<section className="detail-card">
<h3>Latest Action</h3>
{latestAction ? (
<>
<SummaryRow label="Action" value={latestAction.action_name} />
<SummaryRow label="Status" value={latestAction.status} />
<SummaryRow label="Summary" value={latestAction.summary || "-"} />
</>
) : (
<p className="muted">暂无动作记录</p>
)}
</section>
</div>
<div className="detail-grid">
<section className="detail-card">
<h3>Delivery & Context</h3>
<SummaryRow label="Split BV" value={sessionContext.split_bvid || "-"} />
<SummaryRow label="Full BV" value={sessionContext.full_video_bvid || "-"} />
<SummaryRow label="Session Key" value={sessionContext.session_key || "-"} />
<SummaryRow label="Streamer" value={sessionContext.streamer || "-"} />
<SummaryRow label="Context Source" value={sessionContext.context_source || "-"} />
<div className="row-actions" style={{ marginTop: 12 }}>
{splitUrl ? <a className="nav-btn compact-btn" href={splitUrl} target="_blank" rel="noreferrer">打开分P</a> : null}
{fullUrl ? <a className="nav-btn compact-btn" href={fullUrl} target="_blank" rel="noreferrer">打开完整版</a> : null}
</div>
<div className="bind-block">
<label className="muted">绑定完整版 BV</label>
<input value={fullVideoInput} onChange={(event) => setFullVideoInput(event.target.value)} placeholder="BV1..." />
<div className="row-actions">
<button
className="nav-btn compact-btn strong-btn"
onClick={() => onBindFullVideo?.(fullVideoInput.trim())}
disabled={actionBusy}
>
{actionBusy === "bind_full_video" ? "绑定中..." : "绑定完整版 BV"}
</button>
</div>
</div>
</section>
<section className="detail-card">
<h3>Steps</h3>
<div className="list-stack">
{steps?.items?.map((step) => (
<button
type="button"
key={step.step_name}
className={activeStepName === step.step_name ? "list-row selectable active" : "list-row selectable"}
onClick={() => onSelectStep?.(step.step_name)}
>
<span>{step.step_name}</span>
<StatusBadge>{step.status}</StatusBadge>
</button>
))}
</div>
{activeStepName ? (
<div className="selected-step-note">
当前选中 step: <strong>{activeStepName}</strong>
</div>
) : null}
</section>
<section className="detail-card">
<h3>Artifacts</h3>
<div className="list-stack">
{artifacts?.items?.slice(0, 8).map((artifact) => (
<div key={`${artifact.artifact_type}:${artifact.path}`} className="list-row">
<span>{artifact.artifact_type}</span>
<span className="muted">{artifact.path}</span>
</div>
))}
</div>
</section>
</div>
<div className="detail-grid">
<section className="detail-card session-card-full">
<h3>Session Workspace</h3>
{!session?.session_key ? (
<p className="muted">当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息</p>
) : (
<>
<SummaryRow label="Session Key" value={session.session_key} />
<SummaryRow label="Task Count" value={String(session.task_count || 0)} />
<SummaryRow label="Session Full BV" value={session.full_video_bvid || "-"} />
<div className="bind-block">
<label className="muted">整个 Session 重绑 BV</label>
<input value={sessionRebindInput} onChange={(event) => setSessionRebindInput(event.target.value)} placeholder="BV1..." />
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={() => onSessionRebind?.(sessionRebindInput.trim())}
disabled={actionBusy}
>
{actionBusy === "session_rebind" ? "重绑中..." : "Session 重绑 BV"}
</button>
</div>
</div>
<div className="bind-block">
<label className="muted">合并任务到当前 Session</label>
<input value={sessionMergeInput} onChange={(event) => setSessionMergeInput(event.target.value)} placeholder="输入 task id用逗号分隔" />
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={() => onSessionMerge?.(sessionMergeInput)}
disabled={actionBusy}
>
{actionBusy === "session_merge" ? "合并中..." : "合并到当前 Session"}
</button>
</div>
</div>
<div className="list-stack">
{(session.tasks || []).map((item) => (
<button
key={item.id}
type="button"
className="list-row selectable"
onClick={() => onOpenSessionTask?.(item.id)}
>
<span>{item.title}</span>
<StatusBadge>{taskDisplayStatus(item)}</StatusBadge>
</button>
))}
</div>
</>
)}
</section>
</div>
</article>
);
}

View File

@ -1,89 +1,89 @@
import StatusBadge from "./StatusBadge.jsx";
import {
attentionLabel,
currentStepLabel,
deliveryLabel,
formatDate,
summarizeAttention,
summarizeDelivery,
taskDisplayStatus,
taskPrimaryActionLabel,
} from "../lib/format.js";
function deliveryStateLabel(task) {
const delivery = task.delivery_state || {};
return {
splitComment: deliveryLabel(delivery.split_comment || "pending"),
fullComment: deliveryLabel(delivery.full_video_timeline_comment || "pending"),
cleanup: deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present"),
};
}
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
return (
<div className="task-cards-grid">
{tasks.map((task) => {
const delivery = deliveryStateLabel(task);
return (
<button
key={task.id}
type="button"
className={selectedTaskId === task.id ? "task-card active" : "task-card"}
onClick={() => onSelectTask(task.id)}
onMouseEnter={() => onSelectTask?.(task.id, { prefetch: true })}
>
<div className="task-card-head">
<StatusBadge>{taskDisplayStatus(task)}</StatusBadge>
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
</div>
<div>
<div className="task-title">{task.title}</div>
<div className="task-subtitle">{currentStepLabel(task)}</div>
</div>
<div className="task-card-metrics">
<div className="task-metric">
<span>纯享评论</span>
<strong>{delivery.splitComment}</strong>
</div>
<div className="task-metric">
<span>主视频评论</span>
<strong>{delivery.fullComment}</strong>
</div>
<div className="task-metric">
<span>清理</span>
<strong>{delivery.cleanup}</strong>
</div>
<div className="task-metric">
<span>下次重试</span>
<strong>{formatDate(task.retry_state?.next_retry_at)}</strong>
</div>
</div>
<div className="task-card-foot">
<div className="task-subtitle">更新于 {formatDate(task.updated_at)}</div>
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={(event) => {
event.stopPropagation();
onSelectTask(task.id);
}}
>
打开
</button>
<button
className="nav-btn compact-btn strong-btn"
onClick={(event) => {
event.stopPropagation();
onRunTask?.(task.id);
}}
>
{taskPrimaryActionLabel(task)}
</button>
</div>
</div>
</button>
);
})}
</div>
);
}
import StatusBadge from "./StatusBadge.jsx";
import {
attentionLabel,
currentStepLabel,
deliveryLabel,
formatDate,
summarizeAttention,
summarizeDelivery,
taskDisplayStatus,
taskPrimaryActionLabel,
} from "../lib/format.js";
function deliveryStateLabel(task) {
const delivery = task.delivery_state || {};
return {
splitComment: deliveryLabel(delivery.split_comment || "pending"),
fullComment: deliveryLabel(delivery.full_video_timeline_comment || "pending"),
cleanup: deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present"),
};
}
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
return (
<div className="task-cards-grid">
{tasks.map((task) => {
const delivery = deliveryStateLabel(task);
return (
<button
key={task.id}
type="button"
className={selectedTaskId === task.id ? "task-card active" : "task-card"}
onClick={() => onSelectTask(task.id)}
onMouseEnter={() => onSelectTask?.(task.id, { prefetch: true })}
>
<div className="task-card-head">
<StatusBadge>{taskDisplayStatus(task)}</StatusBadge>
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
</div>
<div>
<div className="task-title">{task.title}</div>
<div className="task-subtitle">{currentStepLabel(task)}</div>
</div>
<div className="task-card-metrics">
<div className="task-metric">
<span>纯享评论</span>
<strong>{delivery.splitComment}</strong>
</div>
<div className="task-metric">
<span>主视频评论</span>
<strong>{delivery.fullComment}</strong>
</div>
<div className="task-metric">
<span>清理</span>
<strong>{delivery.cleanup}</strong>
</div>
<div className="task-metric">
<span>下次重试</span>
<strong>{formatDate(task.retry_state?.next_retry_at)}</strong>
</div>
</div>
<div className="task-card-foot">
<div className="task-subtitle">更新于 {formatDate(task.updated_at)}</div>
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={(event) => {
event.stopPropagation();
onSelectTask(task.id);
}}
>
打开
</button>
<button
className="nav-btn compact-btn strong-btn"
onClick={(event) => {
event.stopPropagation();
onRunTask?.(task.id);
}}
>
{taskPrimaryActionLabel(task)}
</button>
</div>
</div>
</button>
);
})}
</div>
);
}

View File

@ -1,142 +1,142 @@
export function statusClass(status) {
if (["collection_synced", "published", "done", "resolved", "present"].includes(status)) return "good";
if (["failed_manual"].includes(status)) return "hot";
if (["failed_retryable", "pending", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
return "";
}
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 summarizeAttention(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 "running";
return "stable";
}
export function attentionLabel(value) {
return {
manual_now: "需人工",
retry_now: "立即重试",
waiting_retry: "等待重试",
running: "处理中",
stable: "正常",
}[value] || value;
}
export function summarizeDelivery(delivery = {}) {
if (delivery.split_comment === "pending" || delivery.full_video_timeline_comment === "pending") return "pending_comment";
if (delivery.source_video_present === false || delivery.split_videos_present === false) return "cleanup_removed";
return "stable";
}
export function deliveryLabel(value) {
return {
done: "已发送",
pending: "待处理",
present: "保留",
removed: "已清理",
cleanup_removed: "已清理视频",
pending_comment: "评论待完成",
stable: "正常",
}[value] || value;
}
export function taskDisplayStatus(task) {
if (!task) return "-";
if (task.status === "failed_manual") return "需人工处理";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
if (task.status === "failed_retryable") return "等待自动重试";
return {
created: "已接收",
transcribed: "已转录",
songs_detected: "已识歌",
split_done: "已切片",
published: "已上传",
commented: "评论完成",
collection_synced: "已完成",
running: "处理中",
}[task.status] || task.status || "-";
}
export function stepLabel(stepName) {
return {
ingest: "接收视频",
transcribe: "转录字幕",
song_detect: "识别歌曲",
split: "切分分P",
publish: "上传分P",
comment: "发布评论",
collection_a: "加入完整版合集",
collection_b: "加入分P合集",
}[stepName] || stepName || "-";
}
export function currentStepLabel(task, steps = []) {
const running = steps.find((step) => step.status === "running");
if (running) return stepLabel(running.step_name);
if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)} · ${taskDisplayStatus(task)}`;
const pending = steps.find((step) => step.status === "pending");
if (pending) return stepLabel(pending.step_name);
return {
created: "转录字幕",
transcribed: "识别歌曲",
songs_detected: "切分分P",
split_done: "上传分P",
published: "评论与合集",
commented: "同步合集",
collection_synced: "链路完成",
}[task?.status] || "-";
}
export function taskPrimaryActionLabel(task) {
if (!task) return "执行";
if (task.status === "failed_manual") return "人工重跑";
if (task.retry_state?.retry_due) return "立即重试";
if (task.status === "failed_retryable") return "继续处理";
if (task.status === "collection_synced") return "查看";
return "执行";
}
export function actionAdvice(task) {
if (!task) return "";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
return "B站通常需要一段时间完成转码和审核系统会自动重试评论。";
}
if (task.status === "failed_retryable") {
return "当前错误可自动恢复,等到重试时间或手工触发即可。";
}
if (task.status === "failed_manual") {
return "先看错误信息,再决定是重试步骤还是绑定完整版 BV。";
}
if (task.status === "collection_synced") {
return "链路已完成可以直接打开分P或完整版链接检查结果。";
}
return "系统会继续推进后续步骤,必要时可在这里手工干预。";
}
export function recommendedAction(task) {
if (!task) return { label: "查看任务", detail: "先打开详情,确认当前步骤和最近动作。", action: "open" };
if (task.status === "failed_manual") {
return { label: "处理失败步骤", detail: "这是需要人工介入的任务,优先查看错误并决定是否重试。", action: "retry" };
}
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
return { label: "等待平台可见", detail: "B站通常需要转码和审核暂时不需要人工操作。", action: "wait" };
}
if (task.retry_state?.retry_due) {
return { label: "立即重试", detail: "已经到达重试窗口,可以立即推进当前步骤。", action: "retry" };
}
if (task.status === "published") {
return { label: "检查评论与合集", detail: "上传已经完成,下一步是确认评论和合集同步。", action: "open" };
}
if (task.status === "collection_synced") {
return { label: "检查最终结果", detail: "链路已经完成,可直接打开视频或做清理确认。", action: "open" };
}
return { label: "继续观察", detail: "当前任务仍在正常推进,必要时可手工执行一轮。", action: "open" };
}
export function statusClass(status) {
if (["collection_synced", "published", "done", "resolved", "present"].includes(status)) return "good";
if (["failed_manual"].includes(status)) return "hot";
if (["failed_retryable", "pending", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
return "";
}
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 summarizeAttention(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 "running";
return "stable";
}
export function attentionLabel(value) {
return {
manual_now: "需人工",
retry_now: "立即重试",
waiting_retry: "等待重试",
running: "处理中",
stable: "正常",
}[value] || value;
}
export function summarizeDelivery(delivery = {}) {
if (delivery.split_comment === "pending" || delivery.full_video_timeline_comment === "pending") return "pending_comment";
if (delivery.source_video_present === false || delivery.split_videos_present === false) return "cleanup_removed";
return "stable";
}
export function deliveryLabel(value) {
return {
done: "已发送",
pending: "待处理",
present: "保留",
removed: "已清理",
cleanup_removed: "已清理视频",
pending_comment: "评论待完成",
stable: "正常",
}[value] || value;
}
export function taskDisplayStatus(task) {
if (!task) return "-";
if (task.status === "failed_manual") return "需人工处理";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
if (task.status === "failed_retryable") return "等待自动重试";
return {
created: "已接收",
transcribed: "已转录",
songs_detected: "已识歌",
split_done: "已切片",
published: "已上传",
commented: "评论完成",
collection_synced: "已完成",
running: "处理中",
}[task.status] || task.status || "-";
}
export function stepLabel(stepName) {
return {
ingest: "接收视频",
transcribe: "转录字幕",
song_detect: "识别歌曲",
split: "切分分P",
publish: "上传分P",
comment: "发布评论",
collection_a: "加入完整版合集",
collection_b: "加入分P合集",
}[stepName] || stepName || "-";
}
export function currentStepLabel(task, steps = []) {
const running = steps.find((step) => step.status === "running");
if (running) return stepLabel(running.step_name);
if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)} · ${taskDisplayStatus(task)}`;
const pending = steps.find((step) => step.status === "pending");
if (pending) return stepLabel(pending.step_name);
return {
created: "转录字幕",
transcribed: "识别歌曲",
songs_detected: "切分分P",
split_done: "上传分P",
published: "评论与合集",
commented: "同步合集",
collection_synced: "链路完成",
}[task?.status] || "-";
}
export function taskPrimaryActionLabel(task) {
if (!task) return "执行";
if (task.status === "failed_manual") return "人工重跑";
if (task.retry_state?.retry_due) return "立即重试";
if (task.status === "failed_retryable") return "继续处理";
if (task.status === "collection_synced") return "查看";
return "执行";
}
export function actionAdvice(task) {
if (!task) return "";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
return "B站通常需要一段时间完成转码和审核系统会自动重试评论。";
}
if (task.status === "failed_retryable") {
return "当前错误可自动恢复,等到重试时间或手工触发即可。";
}
if (task.status === "failed_manual") {
return "先看错误信息,再决定是重试步骤还是绑定完整版 BV。";
}
if (task.status === "collection_synced") {
return "链路已完成可以直接打开分P或完整版链接检查结果。";
}
return "系统会继续推进后续步骤,必要时可在这里手工干预。";
}
export function recommendedAction(task) {
if (!task) return { label: "查看任务", detail: "先打开详情,确认当前步骤和最近动作。", action: "open" };
if (task.status === "failed_manual") {
return { label: "处理失败步骤", detail: "这是需要人工介入的任务,优先查看错误并决定是否重试。", action: "retry" };
}
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
return { label: "等待平台可见", detail: "B站通常需要转码和审核暂时不需要人工操作。", action: "wait" };
}
if (task.retry_state?.retry_due) {
return { label: "立即重试", detail: "已经到达重试窗口,可以立即推进当前步骤。", action: "retry" };
}
if (task.status === "published") {
return { label: "检查评论与合集", detail: "上传已经完成,下一步是确认评论和合集同步。", action: "open" };
}
if (task.status === "collection_synced") {
return { label: "检查最终结果", detail: "链路已经完成,可直接打开视频或做清理确认。", action: "open" };
}
return { label: "继续观察", detail: "当前任务仍在正常推进,必要时可手工执行一轮。", action: "open" };
}

View File

@ -1,11 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,34 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const proxiedPaths = [
"/health",
"/doctor",
"/tasks",
"/settings",
"/runtime",
"/history",
"/logs",
"/modules",
"/scheduler",
"/worker",
"/stage",
];
export default defineConfig({
base: "/ui/",
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: Object.fromEntries(
proxiedPaths.map((path) => [
path,
{
target: "http://127.0.0.1:8787",
changeOrigin: false,
},
]),
),
},
});
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const proxiedPaths = [
"/health",
"/doctor",
"/tasks",
"/settings",
"/runtime",
"/history",
"/logs",
"/modules",
"/scheduler",
"/worker",
"/stage",
];
export default defineConfig({
base: "/ui/",
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: Object.fromEntries(
proxiedPaths.map((path) => [
path,
{
target: "http://127.0.0.1:8787",
changeOrigin: false,
},
]),
),
},
});

View File

@ -1,19 +1,19 @@
[project]
name = "biliup-next"
version = "0.1.0"
description = "Next-generation control-plane-first biliup pipeline"
requires-python = ">=3.11"
dependencies = [
"requests>=2.32.0",
"groq>=0.18.0",
]
[project.scripts]
biliup-next = "biliup_next.app.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "biliup-next"
version = "0.1.0"
description = "Next-generation control-plane-first biliup pipeline"
requires-python = ">=3.11"
dependencies = [
"requests>=2.32.0",
"groq>=0.18.0",
]
[project.scripts]
biliup-next = "biliup_next.app.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -25,3 +25,11 @@ cd /home/theshy/biliup/biliup-next
- `upload_config.json` <- `upload_config.example.json`
它们只用于占位能保证项目进入可配置 doctor的状态但不代表上传链路已经可用
`upload_config.json` 同时控制
- 纯享版投稿标题简介动态标签`template`
- 纯享版和完整版置顶评论格式`comment_template`
- 文件名解析规则`filename_patterns`
Docker 部署时这个目录通常会作为 `./runtime:/app/runtime` 挂载到容器内镜像更新不会覆盖已有 `upload_config.json`所以修改评论动态简介格式时应直接改宿主机上的 `runtime/upload_config.json`

View File

@ -1,9 +1,9 @@
{
"cookie_info": {
"cookies": []
},
"token_info": {
"access_token": "",
"refresh_token": ""
}
}
{
"cookie_info": {
"cookies": []
},
"token_info": {
"access_token": "",
"refresh_token": ""
}
}

View File

@ -1,5 +1,95 @@
{
"line": "AUTO",
"limit": 3,
"threads": 3
"comment": "B站投稿配置文件 - 根据您的需要修改模板内容",
"upload_settings": {
"tid": 31,
"copyright": 1,
"source": "王海颖好听的歌声分享",
"cover": ""
},
"template": {
"title": "【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌",
"description": "{streamer} {date} 歌曲纯享版。\n\n完整歌单与时间轴见置顶评论。\n直播完整版{current_full_video_link}\n上次直播{previous_full_video_link}\n\n本视频为歌曲纯享切片适合只听歌曲。",
"tag": "可爱,聒噪的王海颖,王海颖,宸哥ovo,好听的歌声,吉他弹唱,纯享版,唱歌,音乐",
"dynamic": "{streamer} {date} 歌曲纯享版已发布。完整歌单见置顶评论。\n直播完整版{current_full_video_link}\n上次直播{previous_full_video_link}"
},
"comment_template": {
"split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享{previous_pure_video_link} (上一场歌曲纯享版)",
"full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版{current_pure_video_link} (只听歌曲看这里)\n上次完整版{previous_full_video_link} (上一场完整录播)",
"split_part_header": "P{part_index}:",
"full_part_header": "P{part_index}:",
"split_song_line": "{song_index}. {title}{artist_suffix}",
"split_text_song_line": "{song_index}. {song_text}",
"full_timeline_line": "{song_index}. {line_text}"
},
"streamers": {
"王海颖": {
"display_name": "王海颖",
"tags": "可爱,聒噪的王海颖,王海颖,宸哥ovo,好听的歌声,吉他弹唱,纯享版,唱歌,音乐"
},
"示例主播": {
"display_name": "示例主播",
"tags": "示例,标签1,标签2,唱歌,音乐"
}
},
"quotes": [
{
"text": "此心安处是吾乡。",
"author": "苏轼《定风波·南海归赠王定国侍人寓娘》"
},
{
"text": "山重水复疑无路,柳暗花明又一村。",
"author": "陆游《游山西村》"
},
{
"text": "长风破浪会有时,直挂云帆济沧海。",
"author": "李白《行路难·其一》"
}
],
"filename_patterns": {
"comment": "从文件名提取信息的正则表达式模式 - 按优先级从高到低排列",
"patterns": [
{
"name": "主播名唱歌录播 日期 时间",
"regex": "^(?P<streamer>.+?)唱歌录播 (?P<month>\\d{2})月(?P<day>\\d{2})日 (?P<hour>\\d{2})时(?P<minute>\\d{2})分",
"date_format": "{month}月{day}日 {hour}时{minute}分",
"example": "王海颖唱歌录播 01月28日 22时06分"
},
{
"name": "日期 时间 主播名 唱歌录播",
"regex": "^(?P<month>\\d{2})月(?P<day>\\d{2})日 (?P<hour>\\d{2})时(?P<minute>\\d{2})分 (?P<streamer>.+?)唱歌录播",
"date_format": "{month}月{day}日 {hour}时{minute}分",
"example": "01月25日 09时20分 王海颖唱歌录播"
},
{
"name": "主播名唱歌录播: 年月日 时分 [BV号]",
"regex": "^(?P<streamer>.+?)唱歌录播[:] (?P<year>\\d{4})年(?P<month>\\d{2})月(?P<day>\\d{2})日 (?P<hour>\\d{2})时(?P<minute>\\d{2})分 \\[(?P<video_id>BV[A-Za-z0-9]+)\\]",
"date_format": "{month}月{day}日 {hour}时{minute}分",
"example": "王海颖唱歌录播: 2026年01月22日 22时09分 [BV1wEzcBqEhW]"
},
{
"name": "主播名 日期 时分 [BV号]",
"regex": "^(?P<streamer>.+?) (?P<month>\\d{2})月(?P<day>\\d{2})日 (?P<hour>\\d{2})点(?P<minute>\\d{2})分 \\[(?P<video_id>BV[A-Za-z0-9]+)\\]",
"date_format": "{month}月{day}日 {hour}点{minute}分",
"example": "王海颖 01月25日 02点24分 [BV1KCzQBpEXC]"
},
{
"name": "主播名_日期",
"regex": "^(?P<streamer>.+?)_(?P<date>\\d{1,2}月\\d{1,2}日)",
"date_format": "{date}",
"example": "王海颖_1月20日"
},
{
"name": "主播名_完整日期",
"regex": "^(?P<streamer>.+?)_(?P<year>\\d{4})-(?P<month>\\d{2})-(?P<day>\\d{2})",
"date_format": "{month}月{day}日",
"example": "王海颖_2026-01-20"
},
{
"name": "主播名_描述",
"regex": "^(?P<streamer>.+?)_(?P<desc>.+)",
"date_format": "{desc}",
"example": "测试搬运_前15分钟"
}
]
}
}

View File

@ -0,0 +1,28 @@
#!/usr/bin/env sh
set -eu
mkdir -p config runtime/codex data/workspace/stage data/workspace/session data/workspace/backup
if [ ! -f .env ]; then
cp .env.example .env
echo "created .env from .env.example"
fi
if [ ! -f config/settings.json ]; then
cp config/settings.docker.example.json config/settings.json
echo "created config/settings.json from config/settings.docker.example.json"
fi
if [ ! -f runtime/cookies.json ]; then
cp runtime/cookies.example.json runtime/cookies.json
echo "created runtime/cookies.json placeholder"
fi
if [ ! -f runtime/upload_config.json ]; then
cp runtime/upload_config.example.json runtime/upload_config.json
echo "created runtime/upload_config.json placeholder"
fi
if [ ! -x runtime/biliup ]; then
echo "warning: runtime/biliup is missing or not executable; publish will fail until you provide it" >&2
fi

View File

@ -1,35 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
LOG_FILE="${1:?log file required}"
MAX_BYTES="${2:-20971520}"
BACKUPS="${3:-5}"
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
rotate_logs() {
local size
size="$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)"
if [[ "$size" -lt "$MAX_BYTES" ]]; then
return
fi
local index
for ((index=BACKUPS; index>=1; index--)); do
if [[ -f "${LOG_FILE}.${index}" ]]; then
if [[ "$index" -eq "$BACKUPS" ]]; then
rm -f "${LOG_FILE}.${index}"
else
mv "${LOG_FILE}.${index}" "${LOG_FILE}.$((index + 1))"
fi
fi
done
mv "$LOG_FILE" "${LOG_FILE}.1"
: > "$LOG_FILE"
}
while IFS= read -r line || [[ -n "$line" ]]; do
rotate_logs
printf '%s\n' "$line" | tee -a "$LOG_FILE"
done
#!/usr/bin/env bash
set -euo pipefail
LOG_FILE="${1:?log file required}"
MAX_BYTES="${2:-20971520}"
BACKUPS="${3:-5}"
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
rotate_logs() {
local size
size="$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)"
if [[ "$size" -lt "$MAX_BYTES" ]]; then
return
fi
local index
for ((index=BACKUPS; index>=1; index--)); do
if [[ -f "${LOG_FILE}.${index}" ]]; then
if [[ "$index" -eq "$BACKUPS" ]]; then
rm -f "${LOG_FILE}.${index}"
else
mv "${LOG_FILE}.${index}" "${LOG_FILE}.$((index + 1))"
fi
fi
done
mv "$LOG_FILE" "${LOG_FILE}.1"
: > "$LOG_FILE"
}
while IFS= read -r line || [[ -n "$line" ]]; do
rotate_logs
printf '%s\n' "$line" | tee -a "$LOG_FILE"
done

View File

@ -4,3 +4,4 @@ Version: 0.1.0
Summary: Next-generation control-plane-first biliup pipeline
Requires-Python: >=3.11
Requires-Dist: requests>=2.32.0
Requires-Dist: groq>=0.18.0

View File

@ -10,7 +10,19 @@ 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/control_plane_get_dispatcher.py
src/biliup_next/app/control_plane_post_dispatcher.py
src/biliup_next/app/dashboard.py
src/biliup_next/app/retry_meta.py
src/biliup_next/app/scheduler.py
src/biliup_next/app/serializers.py
src/biliup_next/app/session_delivery_service.py
src/biliup_next/app/task_actions.py
src/biliup_next/app/task_audit.py
src/biliup_next/app/task_control_service.py
src/biliup_next/app/task_engine.py
src/biliup_next/app/task_policies.py
src/biliup_next/app/task_runner.py
src/biliup_next/app/worker.py
src/biliup_next/core/config.py
src/biliup_next/core/errors.py
@ -18,25 +30,56 @@ 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_asset_sync.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/storage_guard.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/workspace_cleanup.py
src/biliup_next/infra/workspace_paths.py
src/biliup_next/infra/adapters/bilibili_api.py
src/biliup_next/infra/adapters/biliup_cli.py
src/biliup_next/infra/adapters/codex_cli.py
src/biliup_next/infra/adapters/full_video_locator.py
src/biliup_next/infra/adapters/qwen_cli.py
src/biliup_next/infra/adapters/yt_dlp.py
src/biliup_next/modules/collection/service.py
src/biliup_next/modules/collection/providers/bilibili_collection.py
src/biliup_next/modules/comment/service.py
src/biliup_next/modules/comment/providers/bilibili_top_comment.py
src/biliup_next/modules/ingest/service.py
src/biliup_next/modules/ingest/providers/bilibili_url.py
src/biliup_next/modules/ingest/providers/local_file.py
src/biliup_next/modules/publish/service.py
src/biliup_next/modules/publish/providers/biliup_cli.py
src/biliup_next/modules/song_detect/service.py
src/biliup_next/modules/song_detect/providers/codex.py
src/biliup_next/modules/song_detect/providers/common.py
src/biliup_next/modules/song_detect/providers/qwen_cli.py
src/biliup_next/modules/split/service.py
src/biliup_next/modules/split/providers/ffmpeg_copy.py
src/biliup_next/modules/transcribe/service.py
src/biliup_next/modules/transcribe/providers/groq.py
tests/test_api_server.py
tests/test_bilibili_top_comment_provider.py
tests/test_biliup_cli_publish_provider.py
tests/test_control_plane_get_dispatcher.py
tests/test_control_plane_post_dispatcher.py
tests/test_ingest_bilibili_url.py
tests/test_ingest_session_grouping.py
tests/test_publish_service.py
tests/test_retry_meta.py
tests/test_serializers.py
tests/test_session_delivery_service.py
tests/test_settings_service.py
tests/test_song_detect_providers.py
tests/test_task_actions.py
tests/test_task_control_service.py
tests/test_task_engine.py
tests/test_task_policies.py
tests/test_task_repository_sqlite.py
tests/test_task_runner.py

View File

@ -1 +1,2 @@
requests>=2.32.0
groq>=0.18.0

View File

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

View File

@ -1,450 +1,450 @@
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()
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()

View File

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

View File

@ -1,124 +1,124 @@
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")
create_task_parser.add_argument("--source-type", choices=["local_file", "bilibili_url"], default="local_file")
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":
ensure_initialized()
print(json.dumps({"ok": True}, 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()
settings = dict(state["settings"]["ingest"])
settings.update(state["settings"]["paths"])
if args.source_type == "bilibili_url":
settings["provider"] = "bilibili_url"
task = state["ingest_service"].create_task_from_url(args.source, settings)
else:
task = state["ingest_service"].create_task_from_file(Path(args.source), settings)
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()
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")
create_task_parser.add_argument("--source-type", choices=["local_file", "bilibili_url"], default="local_file")
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":
ensure_initialized()
print(json.dumps({"ok": True}, 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()
settings = dict(state["settings"]["ingest"])
settings.update(state["settings"]["paths"])
if args.source_type == "bilibili_url":
settings["provider"] = "bilibili_url"
task = state["ingest_service"].create_task_from_url(args.source, settings)
else:
task = state["ingest_service"].create_task_from_file(Path(args.source), settings)
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

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

View File

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

View File

@ -1,356 +1,356 @@
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="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>
<section class="panel">
<div class="panel-head">
<h3>Session Workspace</h3>
<div class="button-row">
<button id="refreshSessionBtn" class="secondary compact">刷新 Session</button>
</div>
</div>
<div id="sessionWorkspaceState" class="task-workspace-state show">当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息。</div>
<div id="sessionPanel" class="summary-card session-panel"></div>
</section>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Steps</h3></div>
<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>
"""
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="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>
<section class="panel">
<div class="panel-head">
<h3>Session Workspace</h3>
<div class="button-row">
<button id="refreshSessionBtn" class="secondary compact">刷新 Session</button>
</div>
</div>
<div id="sessionWorkspaceState" class="task-workspace-state show">当前任务如果已绑定 session_key这里会显示同场片段和完整版绑定信息。</div>
<div id="sessionPanel" class="summary-card session-panel"></div>
</section>
<div class="panel-grid two-up">
<section class="panel">
<div class="panel-head"><h3>Steps</h3></div>
<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

@ -1,55 +1,77 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from __future__ import annotations
from datetime import datetime, timedelta, timezone
STEP_SETTINGS_GROUP = {
"transcribe": "transcribe",
"song_detect": "song_detect",
"publish": "publish",
"comment": "comment",
}
def parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def retry_schedule_seconds(
settings: dict[str, object],
*,
count_key: str,
backoff_key: str,
default_count: int,
default_backoff: int,
) -> list[int]:
raw_schedule = settings.get("retry_schedule_minutes")
if isinstance(raw_schedule, list):
schedule: list[int] = []
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(count_key, default_count)
retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else default_count
retry_count = max(retry_count, 0)
retry_backoff = settings.get(backoff_key, default_backoff)
retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else default_backoff
retry_backoff = max(retry_backoff, 0)
return [retry_backoff] * retry_count
def parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def retry_schedule_seconds(
settings: dict[str, object],
*,
count_key: str,
backoff_key: str,
default_count: int,
default_backoff: int,
) -> list[int]:
raw_schedule = settings.get("retry_schedule_minutes")
if isinstance(raw_schedule, list):
schedule: list[int] = []
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(count_key, default_count)
retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else default_count
retry_count = max(retry_count, 0)
retry_backoff = settings.get(backoff_key, default_backoff)
retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else default_backoff
retry_backoff = max(retry_backoff, 0)
return [retry_backoff] * retry_count
def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds(
settings,
count_key="retry_count",
backoff_key="retry_backoff_seconds",
default_count=5,
default_backoff=300,
)
def transcribe_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds(
settings,
count_key="retry_count",
backoff_key="retry_backoff_seconds",
default_count=5,
default_count=3,
default_backoff=300,
)
def song_detect_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds(
settings,
count_key="retry_count",
backoff_key="retry_backoff_seconds",
default_count=3,
default_backoff=300,
)
@ -58,60 +80,64 @@ def comment_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds(
settings,
count_key="max_retries",
backoff_key="base_delay_seconds",
default_count=5,
default_backoff=180,
)
def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def]
if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0:
return None
step_name = getattr(step, "step_name", None)
settings_group = STEP_SETTINGS_GROUP.get(step_name)
if settings_group is None:
return None
group_settings = settings_by_group.get(settings_group, {})
if not isinstance(group_settings, dict):
group_settings = {}
if step_name == "publish":
backoff_key="base_delay_seconds",
default_count=5,
default_backoff=180,
)
def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def]
if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0:
return None
step_name = getattr(step, "step_name", None)
settings_group = STEP_SETTINGS_GROUP.get(step_name)
if settings_group is None:
return None
group_settings = settings_by_group.get(settings_group, {})
if not isinstance(group_settings, dict):
group_settings = {}
if step_name == "transcribe":
schedule = transcribe_retry_schedule_seconds(group_settings)
elif step_name == "song_detect":
schedule = song_detect_retry_schedule_seconds(group_settings)
elif step_name == "publish":
schedule = publish_retry_schedule_seconds(group_settings)
elif step_name == "comment":
schedule = comment_retry_schedule_seconds(group_settings)
else:
return None
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(),
}
else:
return None
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

@ -1,181 +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"])
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

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

View File

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

View File

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

View File

@ -1,61 +1,61 @@
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 loadTasksPayload(limit = 100) {
return fetchJson(`/tasks?limit=${limit}`);
}
export async function loadTaskPayload(taskId) {
const [task, steps, artifacts, history, timeline, context] = await Promise.all([
fetchJson(`/tasks/${taskId}`),
fetchJson(`/tasks/${taskId}/steps`),
fetchJson(`/tasks/${taskId}/artifacts`),
fetchJson(`/tasks/${taskId}/history`),
fetchJson(`/tasks/${taskId}/timeline`),
fetchJson(`/tasks/${taskId}/context`).catch(() => null),
]);
return { task, steps, artifacts, history, timeline, context };
}
export async function loadSessionPayload(sessionKey) {
return fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`);
}
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 loadTasksPayload(limit = 100) {
return fetchJson(`/tasks?limit=${limit}`);
}
export async function loadTaskPayload(taskId) {
const [task, steps, artifacts, history, timeline, context] = await Promise.all([
fetchJson(`/tasks/${taskId}`),
fetchJson(`/tasks/${taskId}/steps`),
fetchJson(`/tasks/${taskId}/artifacts`),
fetchJson(`/tasks/${taskId}/history`),
fetchJson(`/tasks/${taskId}/timeline`),
fetchJson(`/tasks/${taskId}/context`).catch(() => null),
]);
return { task, steps, artifacts, history, timeline, context };
}
export async function loadSessionPayload(sessionKey) {
return fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`);
}

View File

@ -1,16 +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);
});
}
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

@ -1,15 +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);
}
}
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

@ -1,23 +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);
});
}
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

@ -1,15 +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);
}
}
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

@ -1,11 +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;
}
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

@ -1,46 +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>
`;
}
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

@ -1,19 +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);
}
}
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

@ -1,19 +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>
`;
}
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

@ -1,27 +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);
});
}
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

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

View File

@ -1,34 +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);
});
}
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

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

View File

@ -1,22 +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);
});
}
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

@ -1,320 +1,320 @@
import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } 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,
setCurrentSession,
setTaskDetailStatus,
setTaskListLoading,
state,
} from "./state.js";
import { settingsFieldKey, showBanner, withButtonBusy } 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";
import { renderSessionPanel } from "./components/session-panel.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);
}, {
onBindFullVideo: async (currentTaskId, fullVideoBvid) => {
const button = document.getElementById("bindFullVideoBtn");
const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn");
return;
}
await withButtonBusy(button, "绑定中…", async () => {
try {
await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }),
});
await refreshSelectedTaskOnly(currentTaskId);
showBanner(`已绑定完整版 BV: ${bvid}`, "ok");
} catch (err) {
showBanner(`绑定完整版失败: ${err}`, "err");
}
});
},
onOpenSession: async (sessionKey) => {
if (!sessionKey) {
showBanner("当前任务没有可用的 session_key", "warn");
return;
}
try {
await loadSessionDetail(sessionKey);
} catch (err) {
showBanner(`读取 Session 失败: ${err}`, "err");
}
},
});
await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null);
setTaskDetailStatus("ready");
renderTaskWorkspaceState("ready");
} catch (err) {
const message = `任务详情加载失败: ${err}`;
setTaskDetailStatus("error", message);
renderTaskWorkspaceState("error", message);
throw err;
}
}
async function loadSessionDetail(sessionKey) {
if (!sessionKey) {
setCurrentSession(null);
renderSessionPanel(null);
return;
}
const session = await loadSessionPayload(sessionKey);
setCurrentSession(session);
renderSessionPanel(session, {
onSelectTask: async (taskId) => {
if (!taskId) return;
taskSelectHandler(taskId);
},
onRebind: async (currentSessionKey, fullVideoBvid) => {
const button = document.getElementById("sessionRebindBtn");
const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn");
return;
}
await withButtonBusy(button, "重绑中…", async () => {
try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }),
});
await refreshSelectedTaskOnly();
showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok");
} catch (err) {
showBanner(`Session 重绑失败: ${err}`, "err");
}
});
},
onMerge: async (currentSessionKey, rawTaskIds) => {
const button = document.getElementById("sessionMergeBtn");
const taskIds = String(rawTaskIds || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (!taskIds.length) {
showBanner("请先输入至少一个 task id", "warn");
return;
}
await withButtonBusy(button, "合并中…", async () => {
try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_ids: taskIds }),
});
await refreshSelectedTaskOnly();
showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok");
} catch (err) {
showBanner(`Session 合并失败: ${err}`, "err");
}
});
},
});
}
async function refreshTaskListOnly() {
const payload = await loadTasksPayload(100);
state.currentTasks = payload.items || [];
renderTasks(taskSelectHandler, taskRowActionHandler);
}
async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) {
if (!taskId) return;
await refreshTaskListOnly();
await loadTaskDetail(taskId);
}
function taskSelectHandler(taskId) {
setSelectedTask(taskId);
setSelectedStep(null);
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 refreshSelectedTaskOnly(taskId);
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,
refreshSelectedTaskOnly,
refreshLog,
handleSettingsFieldChange,
});
initRouter((route) => {
handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err"));
});
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));
import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } 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,
setCurrentSession,
setTaskDetailStatus,
setTaskListLoading,
state,
} from "./state.js";
import { settingsFieldKey, showBanner, withButtonBusy } 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";
import { renderSessionPanel } from "./components/session-panel.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);
}, {
onBindFullVideo: async (currentTaskId, fullVideoBvid) => {
const button = document.getElementById("bindFullVideoBtn");
const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn");
return;
}
await withButtonBusy(button, "绑定中…", async () => {
try {
await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }),
});
await refreshSelectedTaskOnly(currentTaskId);
showBanner(`已绑定完整版 BV: ${bvid}`, "ok");
} catch (err) {
showBanner(`绑定完整版失败: ${err}`, "err");
}
});
},
onOpenSession: async (sessionKey) => {
if (!sessionKey) {
showBanner("当前任务没有可用的 session_key", "warn");
return;
}
try {
await loadSessionDetail(sessionKey);
} catch (err) {
showBanner(`读取 Session 失败: ${err}`, "err");
}
},
});
await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null);
setTaskDetailStatus("ready");
renderTaskWorkspaceState("ready");
} catch (err) {
const message = `任务详情加载失败: ${err}`;
setTaskDetailStatus("error", message);
renderTaskWorkspaceState("error", message);
throw err;
}
}
async function loadSessionDetail(sessionKey) {
if (!sessionKey) {
setCurrentSession(null);
renderSessionPanel(null);
return;
}
const session = await loadSessionPayload(sessionKey);
setCurrentSession(session);
renderSessionPanel(session, {
onSelectTask: async (taskId) => {
if (!taskId) return;
taskSelectHandler(taskId);
},
onRebind: async (currentSessionKey, fullVideoBvid) => {
const button = document.getElementById("sessionRebindBtn");
const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn");
return;
}
await withButtonBusy(button, "重绑中…", async () => {
try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }),
});
await refreshSelectedTaskOnly();
showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok");
} catch (err) {
showBanner(`Session 重绑失败: ${err}`, "err");
}
});
},
onMerge: async (currentSessionKey, rawTaskIds) => {
const button = document.getElementById("sessionMergeBtn");
const taskIds = String(rawTaskIds || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (!taskIds.length) {
showBanner("请先输入至少一个 task id", "warn");
return;
}
await withButtonBusy(button, "合并中…", async () => {
try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_ids: taskIds }),
});
await refreshSelectedTaskOnly();
showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok");
} catch (err) {
showBanner(`Session 合并失败: ${err}`, "err");
}
});
},
});
}
async function refreshTaskListOnly() {
const payload = await loadTasksPayload(100);
state.currentTasks = payload.items || [];
renderTasks(taskSelectHandler, taskRowActionHandler);
}
async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) {
if (!taskId) return;
await refreshTaskListOnly();
await loadTaskDetail(taskId);
}
function taskSelectHandler(taskId) {
setSelectedTask(taskId);
setSelectedStep(null);
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 refreshSelectedTaskOnly(taskId);
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,
refreshSelectedTaskOnly,
refreshLog,
handleSettingsFieldChange,
});
initRouter((route) => {
handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err"));
});
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));

View File

@ -1,18 +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";
}
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

@ -1,22 +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();
}
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

@ -1,96 +1,96 @@
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: "",
currentSession: null,
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 setCurrentSession(session) {
state.currentSession = session;
}
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;
}
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: "",
currentSession: null,
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 setCurrentSession(session) {
state.currentSession = session;
}
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

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

View File

@ -1,52 +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;
}
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

@ -1,98 +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);
});
}
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

@ -1,162 +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();
}
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();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,19 +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(),
)
)
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

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

View File

@ -1,190 +1,199 @@
from __future__ import annotations
from datetime import datetime
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]
running = next((step for step in steps.values() if step.status == "running"), None)
if running is not None:
return running.step_name
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is not None:
return failed.step_name
if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}:
return "transcribe"
if task.status == "transcribed":
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"
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]
running = next((step for step in steps.values() if step.status == "running"), None)
if running is not None:
return running.step_name
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is not None:
return failed.step_name
if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}:
return "transcribe"
if task.status == "transcribed":
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")})
step_settings_group = {
"transcribe": "transcribe",
"song_detect": "song_detect",
"publish": "publish",
"comment": "comment",
}.get(step.step_name)
settings_by_group = {}
if step_settings_group is not None and step_settings_group in state["settings"]:
settings_by_group[step_settings_group] = settings_for(state, step_settings_group)
meta = retry_meta_for_step(step, settings_by_group)
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 any(step.status == "running" for step in steps.values()):
return None, None
if task.status == "failed_retryable":
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is None:
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"}:
session_publish = _session_publish_gate(task.id, state)
if session_publish is not None:
if session_publish["session_published"]:
return "publish", None
if not session_publish["is_anchor"]:
return None, None
if not session_publish["all_split_ready"]:
return None, None
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 _session_publish_gate(task_id: str, state: dict[str, object]) -> dict[str, object] | None:
repo = state.get("repo")
if repo is None or not hasattr(repo, "get_task_context"):
return None
context = repo.get_task_context(task_id)
if context is None or not context.session_key or context.session_key.startswith("task:"):
return None
contexts = list(repo.list_task_contexts_by_session_key(context.session_key))
if len(contexts) <= 1:
return None
ordered = sorted(
contexts,
key=lambda item: (
_parse_dt(item.segment_started_at),
item.source_title or item.task_id,
),
)
anchor_id = ordered[0].task_id
sibling_tasks = [repo.get_task(item.task_id) for item in ordered]
session_published = any(
sibling is not None and sibling.status in {"published", "commented", "collection_synced"}
for sibling in sibling_tasks
)
all_split_ready = all(
sibling is not None and sibling.status in {"split_done", "published", "commented", "collection_synced"}
for sibling in sibling_tasks
)
return {
"is_anchor": task_id == anchor_id,
"session_published": session_published,
"all_split_ready": all_split_ready,
}
def _parse_dt(value: str | None) -> datetime:
if not value:
return datetime.max
try:
return datetime.fromisoformat(value)
except ValueError:
return datetime.max
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}
"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 any(step.status == "running" for step in steps.values()):
return None, None
if task.status == "failed_retryable":
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is None:
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"}:
session_publish = _session_publish_gate(task.id, state)
if session_publish is not None:
if session_publish["session_published"]:
return "publish", None
if not session_publish["is_anchor"]:
return None, None
if not session_publish["all_split_ready"]:
return None, None
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 _session_publish_gate(task_id: str, state: dict[str, object]) -> dict[str, object] | None:
repo = state.get("repo")
if repo is None or not hasattr(repo, "get_task_context"):
return None
context = repo.get_task_context(task_id)
if context is None or not context.session_key or context.session_key.startswith("task:"):
return None
contexts = list(repo.list_task_contexts_by_session_key(context.session_key))
if len(contexts) <= 1:
return None
ordered = sorted(
contexts,
key=lambda item: (
_parse_dt(item.segment_started_at),
item.source_title or item.task_id,
),
)
anchor_id = ordered[0].task_id
sibling_tasks = [repo.get_task(item.task_id) for item in ordered]
session_published = any(
sibling is not None and sibling.status in {"published", "commented", "collection_synced"}
for sibling in sibling_tasks
)
all_split_ready = all(
sibling is not None and sibling.status in {"split_done", "published", "commented", "collection_synced"}
for sibling in sibling_tasks
)
return {
"is_anchor": task_id == anchor_id,
"session_published": session_published,
"all_split_ready": all_split_ready,
}
def _parse_dt(value: str | None) -> datetime:
if not value:
return datetime.max
try:
return datetime.fromisoformat(value)
except ValueError:
return datetime.max
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 == "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}

View File

@ -2,81 +2,95 @@ from __future__ import annotations
from biliup_next.app.retry_meta import comment_retry_schedule_seconds
from biliup_next.app.retry_meta import publish_retry_schedule_seconds
from biliup_next.app.retry_meta import song_detect_retry_schedule_seconds
from biliup_next.app.retry_meta import transcribe_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)
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_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 == "transcribe":
schedule = transcribe_retry_schedule_seconds(settings_for(state, "transcribe"))
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
if exc.retryable and step_name == "song_detect":
schedule = song_detect_retry_schedule_seconds(settings_for(state, "song_detect"))
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
if exc.retryable and step_name == "publish":
publish_settings = settings_for(state, "publish")
if exc.code == "PUBLISH_RATE_LIMITED":
custom_schedule = publish_settings.get("rate_limit_retry_schedule_minutes")
if isinstance(custom_schedule, list) and custom_schedule:
schedule = [max(0, int(minutes)) * 60 for minutes in custom_schedule]
else:
schedule = publish_retry_schedule_seconds(publish_settings)
else:
schedule = publish_retry_schedule_seconds(publish_settings)
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
if exc.retryable and step_name == "comment":
schedule = comment_retry_schedule_seconds(settings_for(state, "comment"))
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
failed_at = utc_now_iso()
repo.update_step_status(
task.id,
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,
}
schedule = [max(0, int(minutes)) * 60 for minutes in custom_schedule]
else:
schedule = publish_retry_schedule_seconds(publish_settings)
else:
schedule = publish_retry_schedule_seconds(publish_settings)
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
if exc.retryable and step_name == "comment":
schedule = comment_retry_schedule_seconds(settings_for(state, "comment"))
if next_retry_count > len(schedule):
next_status = "failed_manual"
else:
next_retry_delay_seconds = schedule[next_retry_count - 1]
failed_at = utc_now_iso()
repo.update_step_status(
task.id,
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

@ -1,98 +1,98 @@
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
from biliup_next.infra.task_reset import STATUS_BEFORE_STEP
def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]:
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,
)
target_status = STATUS_BEFORE_STEP.get(reset_step, "created")
repo.update_task_status(task_id, target_status, utc_now_iso())
processed.append({"task_id": task_id, "step": reset_step, "reset": True})
record_task_action(repo, task_id, "retry_step", "ok", f"step reset to pending: {reset_step}", {"step_name": reset_step})
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
claimed_at = utc_now_iso()
if not repo.claim_step_running(task.id, step_name, started_at=claimed_at):
processed.append(
{
"task_id": task.id,
"step": step_name,
"skipped": True,
"reason": "step_already_claimed",
}
)
return {"processed": processed}
repo.update_task_status(task.id, "running", claimed_at)
payload = execute_step(state, task.id, step_name)
if current_task.status == "failed_retryable":
payload["retry"] = True
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"])
except Exception as exc:
unexpected = ModuleError(
code="UNHANDLED_EXCEPTION",
message=f"unexpected error: {exc}",
retryable=False,
)
failure = resolve_failure(task, repo, state, unexpected)
processed.append(failure["payload"])
record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"])
return {"processed": processed}
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
from biliup_next.infra.task_reset import STATUS_BEFORE_STEP
def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]:
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,
)
target_status = STATUS_BEFORE_STEP.get(reset_step, "created")
repo.update_task_status(task_id, target_status, utc_now_iso())
processed.append({"task_id": task_id, "step": reset_step, "reset": True})
record_task_action(repo, task_id, "retry_step", "ok", f"step reset to pending: {reset_step}", {"step_name": reset_step})
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
claimed_at = utc_now_iso()
if not repo.claim_step_running(task.id, step_name, started_at=claimed_at):
processed.append(
{
"task_id": task.id,
"step": step_name,
"skipped": True,
"reason": "step_already_claimed",
}
)
return {"processed": processed}
repo.update_task_status(task.id, "running", claimed_at)
payload = execute_step(state, task.id, step_name)
if current_task.status == "failed_retryable":
payload["retry"] = True
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"])
except Exception as exc:
unexpected = ModuleError(
code="UNHANDLED_EXCEPTION",
message=f"unexpected error: {exc}",
retryable=False,
)
failure = resolve_failure(task, repo, state, unexpected)
processed.append(failure["payload"])
record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"])
return {"processed": processed}

View File

@ -1,30 +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)
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)

Some files were not shown because too many files have changed in this diff Show More