feat: package docker deployment and publish flow
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
49
.env.example
Normal 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
31
.gitignore
vendored
@ -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/
|
||||
|
||||
226
DELIVERY.md
226
DELIVERY.md
@ -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
61
Dockerfile
Normal 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"]
|
||||
43
README.md
43
README.md
@ -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
176
README_DEPLOY.md
Normal 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.
|
||||
127
config/settings.docker.example.json
Normal file
127
config/settings.docker.example.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
74
docker-compose.yml
Normal 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
|
||||
@ -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 作为主状态存储
|
||||
- 是否引入事件总线
|
||||
- 插件机制如何注册
|
||||
- 管理台采用什么技术栈
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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 和稳定领域模型之上
|
||||
|
||||
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
@ -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 文件
|
||||
|
||||
@ -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 归并后面再完善,用户也已经能在前端完整处理问题。
|
||||
|
||||
@ -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 可以完整观察任务
|
||||
- 旧脚本不再是主入口
|
||||
|
||||
@ -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 命令细节
|
||||
|
||||
@ -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 管理
|
||||
- 等边界稳定后,再考虑开放外部插件目录
|
||||
|
||||
@ -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 模型
|
||||
- 规范日志和审计字段
|
||||
|
||||
## 备注
|
||||
|
||||
- 这份路线图描述的是“距离专业化还有哪些结构性工作”,不是说当前系统不可用。
|
||||
- 当前项目已经具备正确方向;接下来的重点是把设计哲学继续固化为代码边界、测试制度和运维约束。
|
||||
|
||||
321
docs/publish-output-examples.md
Normal file
321
docs/publish-output-examples.md
Normal 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` 中完整视频标题与任务标题标准化后相等。
|
||||
@ -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:将问题整理为本改造计划,按阶段拆分,并确定先做状态一致性与运行稳定性。
|
||||
|
||||
@ -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 主状态来源
|
||||
|
||||
@ -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 个测试全部通过。
|
||||
|
||||
108
docs/vision.md
108
docs/vision.md
@ -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. 逐步切换生产流量,最终替换旧脚本体系。
|
||||
|
||||
@ -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`
|
||||
- 高频任务操作已改为局部刷新
|
||||
- 旧原生控制台仍保留作回退路径
|
||||
|
||||
@ -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>
|
||||
|
||||
3630
frontend/package-lock.json
generated
3630
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
1772
frontend/src/App.jsx
1772
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" };
|
||||
}
|
||||
|
||||
@ -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
@ -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,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"cookie_info": {
|
||||
"cookies": []
|
||||
},
|
||||
"token_info": {
|
||||
"access_token": "",
|
||||
"refresh_token": ""
|
||||
}
|
||||
}
|
||||
{
|
||||
"cookie_info": {
|
||||
"cookies": []
|
||||
},
|
||||
"token_info": {
|
||||
"access_token": "",
|
||||
"refresh_token": ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -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分钟"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
28
scripts/init-docker-config.sh
Normal file
28
scripts/init-docker-config.sh
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -1 +1,2 @@
|
||||
requests>=2.32.0
|
||||
groq>=0.18.0
|
||||
|
||||
@ -1 +1 @@
|
||||
"""biliup-next package."""
|
||||
"""biliup-next package."""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
)
|
||||
|
||||
@ -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>
|
||||
"""
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)}`);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
Reference in New Issue
Block a user