feat: package docker deployment and publish flow

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

19
.dockerignore Normal file
View File

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

49
.env.example Normal file
View File

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

31
.gitignore vendored
View File

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

View File

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

61
Dockerfile Normal file
View File

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

View File

@ -59,6 +59,10 @@ bash setup.sh
- `docs/cold-start-checklist.md` - `docs/cold-start-checklist.md`
发布流程、输出文案和评论示例见:
- `docs/publish-output-examples.md`
浏览器访问: 浏览器访问:
```text ```text
@ -192,6 +196,29 @@ cd /home/theshy/biliup/biliup-next
- 内容按 `P1/P2/P3` 分组 - 内容按 `P1/P2/P3` 分组
- 依赖 `full_video_bvid.txt` 或通过标题匹配解析到完整版 BV - 依赖 `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` - `cleanup.delete_source_video_after_collection_synced = false`
@ -201,11 +228,14 @@ cd /home/theshy/biliup/biliup-next
## Full Video BV Input ## Full Video BV Input
完整版 `BV` 目前支持 3 种来源: 完整版 `BV` 目前支持 4 种来源:
- `stage/*.meta.json` 中的 `full_video_bvid` - `stage/*.meta.json` 中的 `full_video_bvid`
- 前端 / API 手工绑定 - 前端 / API 手工绑定
- webhook`POST /webhooks/full-video-uploaded` - webhook`POST /webhooks/full-video-uploaded`
- `biliup list` 标题匹配,包含 `开放浏览``审核中` 状态
只要完整版上传后已经生成 BV即使仍在审核中也可以被用于纯享版简介、动态和评论互链。
推荐 webhook 负载: 推荐 webhook 负载:
@ -320,3 +350,14 @@ curl -X POST http://127.0.0.1:8787/tasks \
- `ingest.provider = bilibili_url` - `ingest.provider = bilibili_url`
- `ingest.yt_dlp_cmd = yt-dlp` - `ingest.yt_dlp_cmd = yt-dlp`
## Docker Compose Deployment
如果希望用容器方式一键运行 API 和 worker请参考 [README_DEPLOY.md](README_DEPLOY.md)。
快速入口:
```bash
./scripts/init-docker-config.sh
docker compose up -d --build
```

176
README_DEPLOY.md Normal file
View File

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

View File

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

View File

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

View File

@ -229,6 +229,16 @@
"description": "用于调用 Groq 转录 API。", "description": "用于调用 Groq 转录 API。",
"sensitive": true "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": { "ffmpeg_bin": {
"type": "string", "type": "string",
"default": "ffmpeg", "default": "ffmpeg",
@ -238,10 +248,66 @@
}, },
"max_file_size_mb": { "max_file_size_mb": {
"type": "integer", "type": "integer",
"default": 23, "default": 12,
"title": "Max File Size MB", "title": "Max File Size MB",
"ui_order": 40, "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": { "song_detect": {
@ -275,6 +341,30 @@
"title": "Poll Interval Seconds", "title": "Poll Interval Seconds",
"ui_order": 30, "ui_order": 30,
"minimum": 1 "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": { "split": {
@ -375,9 +465,9 @@
"rate_limit_retry_schedule_minutes": { "rate_limit_retry_schedule_minutes": {
"type": "array", "type": "array",
"default": [ "default": [
15,
30, 30,
60, 60
120
], ],
"title": "Rate Limit Retry Schedule Minutes", "title": "Rate Limit Retry Schedule Minutes",
"ui_order": 70, "ui_order": 70,

View File

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

74
docker-compose.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -155,6 +155,60 @@ User edits config
- `base_delay_seconds` - `base_delay_seconds`
- `poll_interval_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 ### collection
- `enabled` - `enabled`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,19 @@ src/biliup_next.egg-info/top_level.txt
src/biliup_next/app/api_server.py src/biliup_next/app/api_server.py
src/biliup_next/app/bootstrap.py src/biliup_next/app/bootstrap.py
src/biliup_next/app/cli.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/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/app/worker.py
src/biliup_next/core/config.py src/biliup_next/core/config.py
src/biliup_next/core/errors.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/providers.py
src/biliup_next/core/registry.py src/biliup_next/core/registry.py
src/biliup_next/infra/db.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/log_reader.py
src/biliup_next/infra/plugin_loader.py src/biliup_next/infra/plugin_loader.py
src/biliup_next/infra/runtime_doctor.py src/biliup_next/infra/runtime_doctor.py
src/biliup_next/infra/stage_importer.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/systemd_runtime.py
src/biliup_next/infra/task_repository.py src/biliup_next/infra/task_repository.py
src/biliup_next/infra/task_reset.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/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/service.py
src/biliup_next/modules/collection/providers/bilibili_collection.py src/biliup_next/modules/collection/providers/bilibili_collection.py
src/biliup_next/modules/comment/service.py src/biliup_next/modules/comment/service.py
src/biliup_next/modules/comment/providers/bilibili_top_comment.py src/biliup_next/modules/comment/providers/bilibili_top_comment.py
src/biliup_next/modules/ingest/service.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/ingest/providers/local_file.py
src/biliup_next/modules/publish/service.py src/biliup_next/modules/publish/service.py
src/biliup_next/modules/publish/providers/biliup_cli.py src/biliup_next/modules/publish/providers/biliup_cli.py
src/biliup_next/modules/song_detect/service.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/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/service.py
src/biliup_next/modules/split/providers/ffmpeg_copy.py src/biliup_next/modules/split/providers/ffmpeg_copy.py
src/biliup_next/modules/transcribe/service.py src/biliup_next/modules/transcribe/service.py
src/biliup_next/modules/transcribe/providers/groq.py src/biliup_next/modules/transcribe/providers/groq.py
tests/test_api_server.py
tests/test_bilibili_top_comment_provider.py
tests/test_biliup_cli_publish_provider.py
tests/test_control_plane_get_dispatcher.py
tests/test_control_plane_post_dispatcher.py
tests/test_ingest_bilibili_url.py
tests/test_ingest_session_grouping.py
tests/test_publish_service.py
tests/test_retry_meta.py
tests/test_serializers.py
tests/test_session_delivery_service.py
tests/test_settings_service.py
tests/test_song_detect_providers.py
tests/test_task_actions.py
tests/test_task_control_service.py
tests/test_task_engine.py
tests/test_task_policies.py
tests/test_task_repository_sqlite.py
tests/test_task_runner.py

View File

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

View File

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

View File

@ -1,450 +1,450 @@
from __future__ import annotations from __future__ import annotations
import cgi import cgi
import json import json
import mimetypes import mimetypes
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse 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 bind_full_video_action
from biliup_next.app.task_actions import merge_session_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 receive_full_video_webhook
from biliup_next.app.task_actions import rebind_session_full_video_action 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 reset_to_step_action
from biliup_next.app.task_actions import retry_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.task_actions import run_task_action
from biliup_next.app.bootstrap import ensure_initialized from biliup_next.app.bootstrap import ensure_initialized
from biliup_next.app.bootstrap import reset_initialized_state from biliup_next.app.bootstrap import reset_initialized_state
from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher
from biliup_next.app.dashboard import render_dashboard_html from biliup_next.app.dashboard import render_dashboard_html
from biliup_next.app.control_plane_post_dispatcher import ControlPlanePostDispatcher 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.retry_meta import retry_meta_for_step
from biliup_next.app.scheduler import build_scheduler_preview from biliup_next.app.scheduler import build_scheduler_preview
from biliup_next.app.serializers import ControlPlaneSerializer from biliup_next.app.serializers import ControlPlaneSerializer
from biliup_next.app.worker import run_once from biliup_next.app.worker import run_once
from biliup_next.core.config import SettingsService from biliup_next.core.config import SettingsService
from biliup_next.core.models import ActionRecord, utc_now_iso from biliup_next.core.models import ActionRecord, utc_now_iso
from biliup_next.infra.log_reader import LogReader from biliup_next.infra.log_reader import LogReader
from biliup_next.infra.runtime_doctor import RuntimeDoctor from biliup_next.infra.runtime_doctor import RuntimeDoctor
from biliup_next.infra.stage_importer import StageImporter from biliup_next.infra.stage_importer import StageImporter
from biliup_next.infra.storage_guard import mb_to_bytes from biliup_next.infra.storage_guard import mb_to_bytes
from biliup_next.infra.systemd_runtime import SystemdRuntime from biliup_next.infra.systemd_runtime import SystemdRuntime
class ApiHandler(BaseHTTPRequestHandler): class ApiHandler(BaseHTTPRequestHandler):
server_version = "biliup-next/0.1" server_version = "biliup-next/0.1"
@staticmethod @staticmethod
def _attention_state(task_payload: dict[str, object]) -> str: def _attention_state(task_payload: dict[str, object]) -> str:
if task_payload.get("status") == "failed_manual": if task_payload.get("status") == "failed_manual":
return "manual_now" return "manual_now"
retry_state = task_payload.get("retry_state") retry_state = task_payload.get("retry_state")
if isinstance(retry_state, dict) and retry_state.get("retry_due"): if isinstance(retry_state, dict) and retry_state.get("retry_due"):
return "retry_now" return "retry_now"
if task_payload.get("status") == "failed_retryable" and isinstance(retry_state, dict) and retry_state.get("next_retry_at"): if task_payload.get("status") == "failed_retryable" and isinstance(retry_state, dict) and retry_state.get("next_retry_at"):
return "waiting_retry" return "waiting_retry"
if task_payload.get("status") == "running": if task_payload.get("status") == "running":
return "running" return "running"
return "stable" return "stable"
@staticmethod @staticmethod
def _delivery_state_label(task_payload: dict[str, object]) -> str: def _delivery_state_label(task_payload: dict[str, object]) -> str:
delivery_state = task_payload.get("delivery_state") delivery_state = task_payload.get("delivery_state")
if not isinstance(delivery_state, dict): if not isinstance(delivery_state, dict):
return "stable" return "stable"
if delivery_state.get("split_comment") == "pending" or delivery_state.get("full_video_timeline_comment") == "pending": if delivery_state.get("split_comment") == "pending" or delivery_state.get("full_video_timeline_comment") == "pending":
return "pending_comment" return "pending_comment"
if delivery_state.get("source_video_present") is False or delivery_state.get("split_videos_present") is False: if delivery_state.get("source_video_present") is False or delivery_state.get("split_videos_present") is False:
return "cleanup_removed" return "cleanup_removed"
return "stable" return "stable"
def _step_payload(self, step, state: dict[str, object]) -> dict[str, object]: # type: ignore[no-untyped-def] def _step_payload(self, step, state: dict[str, object]) -> dict[str, object]: # type: ignore[no-untyped-def]
return ControlPlaneSerializer(state).step_payload(step) return ControlPlaneSerializer(state).step_payload(step)
def _serve_asset(self, asset_name: str) -> None: def _serve_asset(self, asset_name: str) -> None:
root = ensure_initialized()["root"] root = ensure_initialized()["root"]
asset_path = root / "src" / "biliup_next" / "app" / "static" / asset_name asset_path = root / "src" / "biliup_next" / "app" / "static" / asset_name
if not asset_path.exists(): if not asset_path.exists():
self._json({"error": "asset not found"}, status=HTTPStatus.NOT_FOUND) self._json({"error": "asset not found"}, status=HTTPStatus.NOT_FOUND)
return return
content_type = self._guess_content_type(asset_path) content_type = self._guess_content_type(asset_path)
self.send_response(HTTPStatus.OK) self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.end_headers() self.end_headers()
self.wfile.write(asset_path.read_bytes()) self.wfile.write(asset_path.read_bytes())
def _guess_content_type(self, path: Path) -> str: def _guess_content_type(self, path: Path) -> str:
guessed, _ = mimetypes.guess_type(path.name) guessed, _ = mimetypes.guess_type(path.name)
if guessed: if guessed:
if guessed.startswith("text/") or guessed in {"application/javascript", "application/json"}: if guessed.startswith("text/") or guessed in {"application/javascript", "application/json"}:
return f"{guessed}; charset=utf-8" return f"{guessed}; charset=utf-8"
return guessed return guessed
return "application/octet-stream" return "application/octet-stream"
def _frontend_dist_dir(self) -> Path: def _frontend_dist_dir(self) -> Path:
root = ensure_initialized()["root"] root = ensure_initialized()["root"]
return root / "frontend" / "dist" return root / "frontend" / "dist"
def _frontend_dist_ready(self) -> bool: def _frontend_dist_ready(self) -> bool:
dist = self._frontend_dist_dir() dist = self._frontend_dist_dir()
return (dist / "index.html").exists() return (dist / "index.html").exists()
def _serve_frontend_dist(self, parsed_path: str) -> bool: def _serve_frontend_dist(self, parsed_path: str) -> bool:
dist = self._frontend_dist_dir() dist = self._frontend_dist_dir()
if not (dist / "index.html").exists(): if not (dist / "index.html").exists():
return False return False
if parsed_path in {"/", "/ui", "/ui/"}: if parsed_path in {"/", "/ui", "/ui/"}:
self._html((dist / "index.html").read_text(encoding="utf-8")) self._html((dist / "index.html").read_text(encoding="utf-8"))
return True return True
if parsed_path.startswith("/assets/"): if parsed_path.startswith("/assets/"):
relative = parsed_path.removeprefix("/") relative = parsed_path.removeprefix("/")
asset_path = dist / relative asset_path = dist / relative
if asset_path.exists() and asset_path.is_file(): if asset_path.exists() and asset_path.is_file():
body = asset_path.read_bytes() body = asset_path.read_bytes()
self.send_response(HTTPStatus.OK) self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", self._guess_content_type(asset_path)) self.send_header("Content-Type", self._guess_content_type(asset_path))
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
return True return True
if not parsed_path.startswith("/ui/"): if not parsed_path.startswith("/ui/"):
return False return False
relative = parsed_path.removeprefix("/ui/") relative = parsed_path.removeprefix("/ui/")
asset_path = dist / relative asset_path = dist / relative
if asset_path.exists() and asset_path.is_file(): if asset_path.exists() and asset_path.is_file():
body = asset_path.read_bytes() body = asset_path.read_bytes()
self.send_response(HTTPStatus.OK) self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", self._guess_content_type(asset_path)) self.send_header("Content-Type", self._guess_content_type(asset_path))
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
return True return True
if "." not in Path(relative).name: if "." not in Path(relative).name:
self._html((dist / "index.html").read_text(encoding="utf-8")) self._html((dist / "index.html").read_text(encoding="utf-8"))
return True return True
self._json({"error": "frontend asset not found"}, status=HTTPStatus.NOT_FOUND) self._json({"error": "frontend asset not found"}, status=HTTPStatus.NOT_FOUND)
return True return True
def do_GET(self) -> None: # noqa: N802 def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path) parsed = urlparse(self.path)
if (parsed.path == "/" or parsed.path.startswith("/ui") or parsed.path.startswith("/assets/")) and self._serve_frontend_dist(parsed.path): if (parsed.path == "/" or parsed.path.startswith("/ui") or parsed.path.startswith("/assets/")) and self._serve_frontend_dist(parsed.path):
return return
if not self._check_auth(parsed.path): if not self._check_auth(parsed.path):
return return
if parsed.path.startswith("/assets/"): if parsed.path.startswith("/assets/"):
self._serve_asset(parsed.path.removeprefix("/assets/")) self._serve_asset(parsed.path.removeprefix("/assets/"))
return return
if parsed.path == "/classic": if parsed.path == "/classic":
self._html(render_dashboard_html()) self._html(render_dashboard_html())
return return
if parsed.path == "/": if parsed.path == "/":
self._html(render_dashboard_html()) self._html(render_dashboard_html())
return return
if parsed.path == "/health": if parsed.path == "/health":
self._json({"ok": True}) self._json({"ok": True})
return return
state = ensure_initialized() state = ensure_initialized()
get_dispatcher = ControlPlaneGetDispatcher( get_dispatcher = ControlPlaneGetDispatcher(
state, state,
attention_state_fn=self._attention_state, attention_state_fn=self._attention_state,
delivery_state_label_fn=self._delivery_state_label, delivery_state_label_fn=self._delivery_state_label,
build_scheduler_preview_fn=build_scheduler_preview, build_scheduler_preview_fn=build_scheduler_preview,
settings_service_factory=SettingsService, settings_service_factory=SettingsService,
) )
if parsed.path == "/settings": if parsed.path == "/settings":
body, status = get_dispatcher.handle_settings() body, status = get_dispatcher.handle_settings()
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/settings/schema": if parsed.path == "/settings/schema":
body, status = get_dispatcher.handle_settings_schema() body, status = get_dispatcher.handle_settings_schema()
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/doctor": if parsed.path == "/doctor":
doctor = RuntimeDoctor(ensure_initialized()["root"]) doctor = RuntimeDoctor(ensure_initialized()["root"])
self._json(doctor.run()) self._json(doctor.run())
return return
if parsed.path == "/runtime/services": if parsed.path == "/runtime/services":
self._json(SystemdRuntime().list_services()) self._json(SystemdRuntime().list_services())
return return
if parsed.path == "/scheduler/preview": if parsed.path == "/scheduler/preview":
body, status = get_dispatcher.handle_scheduler_preview() body, status = get_dispatcher.handle_scheduler_preview()
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/logs": if parsed.path == "/logs":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
name = query.get("name", [None])[0] name = query.get("name", [None])[0]
if not name: if not name:
self._json(LogReader().list_logs()) self._json(LogReader().list_logs())
return return
lines = int(query.get("lines", ["200"])[0]) lines = int(query.get("lines", ["200"])[0])
contains = query.get("contains", [None])[0] contains = query.get("contains", [None])[0]
self._json(LogReader().tail(name, lines, contains)) self._json(LogReader().tail(name, lines, contains))
return return
if parsed.path == "/history": if parsed.path == "/history":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
limit = int(query.get("limit", ["100"])[0]) limit = int(query.get("limit", ["100"])[0])
task_id = query.get("task_id", [None])[0] task_id = query.get("task_id", [None])[0]
action_name = query.get("action_name", [None])[0] action_name = query.get("action_name", [None])[0]
status = query.get("status", [None])[0] status = query.get("status", [None])[0]
body, http_status = get_dispatcher.handle_history( body, http_status = get_dispatcher.handle_history(
limit=limit, limit=limit,
task_id=task_id, task_id=task_id,
action_name=action_name, action_name=action_name,
status=status, status=status,
) )
self._json(body, status=http_status) self._json(body, status=http_status)
return return
if parsed.path == "/modules": if parsed.path == "/modules":
body, status = get_dispatcher.handle_modules() body, status = get_dispatcher.handle_modules()
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/tasks": if parsed.path == "/tasks":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
limit = int(query.get("limit", ["100"])[0]) limit = int(query.get("limit", ["100"])[0])
offset = int(query.get("offset", ["0"])[0]) offset = int(query.get("offset", ["0"])[0])
status = query.get("status", [None])[0] status = query.get("status", [None])[0]
search = query.get("search", [None])[0] search = query.get("search", [None])[0]
sort = query.get("sort", ["updated_desc"])[0] sort = query.get("sort", ["updated_desc"])[0]
attention = query.get("attention", [None])[0] attention = query.get("attention", [None])[0]
delivery = query.get("delivery", [None])[0] delivery = query.get("delivery", [None])[0]
body, http_status = get_dispatcher.handle_tasks( body, http_status = get_dispatcher.handle_tasks(
limit=limit, limit=limit,
offset=offset, offset=offset,
status=status, status=status,
search=search, search=search,
sort=sort, sort=sort,
attention=attention, attention=attention,
delivery=delivery, delivery=delivery,
) )
self._json(body, status=http_status) self._json(body, status=http_status)
return return
if parsed.path.startswith("/sessions/"): if parsed.path.startswith("/sessions/"):
parts = [unquote(p) for p in parsed.path.split("/") if p] parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 2: if len(parts) == 2:
body, status = get_dispatcher.handle_session(parts[1]) body, status = get_dispatcher.handle_session(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path.startswith("/tasks/"): if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p] parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 2: if len(parts) == 2:
body, status = get_dispatcher.handle_task(parts[1]) body, status = get_dispatcher.handle_task(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 3 and parts[2] == "steps": if len(parts) == 3 and parts[2] == "steps":
body, status = get_dispatcher.handle_task_steps(parts[1]) body, status = get_dispatcher.handle_task_steps(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 3 and parts[2] == "context": if len(parts) == 3 and parts[2] == "context":
body, status = get_dispatcher.handle_task_context(parts[1]) body, status = get_dispatcher.handle_task_context(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 3 and parts[2] == "artifacts": if len(parts) == 3 and parts[2] == "artifacts":
body, status = get_dispatcher.handle_task_artifacts(parts[1]) body, status = get_dispatcher.handle_task_artifacts(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 3 and parts[2] == "history": if len(parts) == 3 and parts[2] == "history":
body, status = get_dispatcher.handle_task_history(parts[1]) body, status = get_dispatcher.handle_task_history(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 3 and parts[2] == "timeline": if len(parts) == 3 and parts[2] == "timeline":
body, status = get_dispatcher.handle_task_timeline(parts[1]) body, status = get_dispatcher.handle_task_timeline(parts[1])
self._json(body, status=status) self._json(body, status=status)
return return
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND) self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None: # noqa: N802 def do_PUT(self) -> None: # noqa: N802
parsed = urlparse(self.path) parsed = urlparse(self.path)
if not self._check_auth(parsed.path): if not self._check_auth(parsed.path):
return return
if parsed.path != "/settings": if parsed.path != "/settings":
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND) self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
return return
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
root = ensure_initialized()["root"] root = ensure_initialized()["root"]
service = SettingsService(root) service = SettingsService(root)
service.save_staged_from_redacted(payload) service.save_staged_from_redacted(payload)
service.promote_staged() service.promote_staged()
reset_initialized_state() reset_initialized_state()
ensure_initialized() ensure_initialized()
self._json({"ok": True}) self._json({"ok": True})
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path) parsed = urlparse(self.path)
if not self._check_auth(parsed.path): if not self._check_auth(parsed.path):
return return
state = ensure_initialized() state = ensure_initialized()
dispatcher = ControlPlanePostDispatcher( dispatcher = ControlPlanePostDispatcher(
state, state,
bind_full_video_action=bind_full_video_action, bind_full_video_action=bind_full_video_action,
merge_session_action=merge_session_action, merge_session_action=merge_session_action,
receive_full_video_webhook=receive_full_video_webhook, receive_full_video_webhook=receive_full_video_webhook,
rebind_session_full_video_action=rebind_session_full_video_action, rebind_session_full_video_action=rebind_session_full_video_action,
reset_to_step_action=reset_to_step_action, reset_to_step_action=reset_to_step_action,
retry_step_action=retry_step_action, retry_step_action=retry_step_action,
run_task_action=run_task_action, run_task_action=run_task_action,
run_once=run_once, run_once=run_once,
stage_importer_factory=StageImporter, stage_importer_factory=StageImporter,
systemd_runtime_factory=SystemdRuntime, systemd_runtime_factory=SystemdRuntime,
) )
if parsed.path == "/webhooks/full-video-uploaded": if parsed.path == "/webhooks/full-video-uploaded":
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_webhook_full_video(payload) body, status = dispatcher.handle_webhook_full_video(payload)
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path != "/tasks": if parsed.path != "/tasks":
if parsed.path.startswith("/sessions/"): if parsed.path.startswith("/sessions/"):
parts = [unquote(p) for p in parsed.path.split("/") if p] parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "merge": if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "merge":
session_key = parts[1] session_key = parts[1]
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_session_merge(session_key, payload) body, status = dispatcher.handle_session_merge(session_key, payload)
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "rebind": if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "rebind":
session_key = parts[1] session_key = parts[1]
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_session_rebind(session_key, payload) body, status = dispatcher.handle_session_rebind(session_key, payload)
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path.startswith("/tasks/"): if parsed.path.startswith("/tasks/"):
parts = [unquote(p) for p in parsed.path.split("/") if p] 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": if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "bind-full-video":
task_id = parts[1] task_id = parts[1]
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_bind_full_video(task_id, payload) body, status = dispatcher.handle_bind_full_video(task_id, payload)
self._json(body, status=status) self._json(body, status=status)
return return
if len(parts) == 4 and parts[0] == "tasks" and parts[2] == "actions": if len(parts) == 4 and parts[0] == "tasks" and parts[2] == "actions":
task_id = parts[1] task_id = parts[1]
action = parts[3] action = parts[3]
if action in {"run", "retry-step", "reset-to-step"}: if action in {"run", "retry-step", "reset-to-step"}:
payload = {} payload = {}
if action != "run": if action != "run":
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_task_action(task_id, action, payload) body, status = dispatcher.handle_task_action(task_id, action, payload)
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/worker/run-once": if parsed.path == "/worker/run-once":
body, status = dispatcher.handle_worker_run_once() body, status = dispatcher.handle_worker_run_once()
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path.startswith("/runtime/services/"): if parsed.path.startswith("/runtime/services/"):
parts = [unquote(p) for p in parsed.path.split("/") if p] parts = [unquote(p) for p in parsed.path.split("/") if p]
if len(parts) == 4 and parts[0] == "runtime" and parts[1] == "services": if len(parts) == 4 and parts[0] == "runtime" and parts[1] == "services":
body, status = dispatcher.handle_runtime_service_action(parts[2], parts[3]) body, status = dispatcher.handle_runtime_service_action(parts[2], parts[3])
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/stage/import": if parsed.path == "/stage/import":
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_stage_import(payload) body, status = dispatcher.handle_stage_import(payload)
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/stage/upload": if parsed.path == "/stage/upload":
content_type = self.headers.get("Content-Type", "") content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type: if "multipart/form-data" not in content_type:
self._json({"error": "content-type must be multipart/form-data"}, status=HTTPStatus.BAD_REQUEST) self._json({"error": "content-type must be multipart/form-data"}, status=HTTPStatus.BAD_REQUEST)
return return
form = cgi.FieldStorage( form = cgi.FieldStorage(
fp=self.rfile, fp=self.rfile,
headers=self.headers, headers=self.headers,
environ={ environ={
"REQUEST_METHOD": "POST", "REQUEST_METHOD": "POST",
"CONTENT_TYPE": content_type, "CONTENT_TYPE": content_type,
"CONTENT_LENGTH": self.headers.get("Content-Length", "0"), "CONTENT_LENGTH": self.headers.get("Content-Length", "0"),
}, },
) )
file_item = form["file"] if "file" in form else None file_item = form["file"] if "file" in form else None
body, status = dispatcher.handle_stage_upload(file_item) body, status = dispatcher.handle_stage_upload(file_item)
self._json(body, status=status) self._json(body, status=status)
return return
if parsed.path == "/scheduler/run-once": if parsed.path == "/scheduler/run-once":
body, status = dispatcher.handle_scheduler_run_once() body, status = dispatcher.handle_scheduler_run_once()
self._json(body, status=status) self._json(body, status=status)
return return
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND) self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
return return
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
body, status = dispatcher.handle_create_task(payload) body, status = dispatcher.handle_create_task(payload)
self._json(body, status=status) self._json(body, status=status)
def log_message(self, format: str, *args) -> None: # noqa: A003 def log_message(self, format: str, *args) -> None: # noqa: A003
return return
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None: def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def _html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None: def _html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None:
body = html.encode("utf-8") body = html.encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def _record_action(self, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None: def _record_action(self, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None:
state = ensure_initialized() state = ensure_initialized()
state["repo"].add_action_record( state["repo"].add_action_record(
ActionRecord( ActionRecord(
id=None, id=None,
task_id=task_id, task_id=task_id,
action_name=action_name, action_name=action_name,
status=status, status=status,
summary=summary, summary=summary,
details_json=json.dumps(details, ensure_ascii=False), details_json=json.dumps(details, ensure_ascii=False),
created_at=utc_now_iso(), created_at=utc_now_iso(),
) )
) )
def _check_auth(self, path: str) -> bool: def _check_auth(self, path: str) -> bool:
if path in {"/", "/health", "/ui", "/ui/", "/classic"} or path.startswith("/assets/") or path.startswith("/ui/assets/"): if path in {"/", "/health", "/ui", "/ui/", "/classic"} or path.startswith("/assets/") or path.startswith("/ui/assets/"):
return True return True
state = ensure_initialized() state = ensure_initialized()
expected = str(state["settings"]["runtime"].get("control_token", "")).strip() expected = str(state["settings"]["runtime"].get("control_token", "")).strip()
if not expected: if not expected:
return True return True
provided = self.headers.get("X-Biliup-Token", "").strip() provided = self.headers.get("X-Biliup-Token", "").strip()
if provided == expected: if provided == expected:
return True return True
self._json({"error": "unauthorized"}, status=HTTPStatus.UNAUTHORIZED) self._json({"error": "unauthorized"}, status=HTTPStatus.UNAUTHORIZED)
return False return False
def serve(host: str, port: int) -> None: def serve(host: str, port: int) -> None:
ensure_initialized() ensure_initialized()
server = ThreadingHTTPServer((host, port), ApiHandler) server = ThreadingHTTPServer((host, port), ApiHandler)
print(f"biliup-next api listening on http://{host}:{port}") print(f"biliup-next api listening on http://{host}:{port}")
server.serve_forever() server.serve_forever()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,55 +1,77 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
STEP_SETTINGS_GROUP = { STEP_SETTINGS_GROUP = {
"transcribe": "transcribe",
"song_detect": "song_detect",
"publish": "publish", "publish": "publish",
"comment": "comment", "comment": "comment",
} }
def parse_iso(value: str | None) -> datetime | None: def parse_iso(value: str | None) -> datetime | None:
if not value: if not value:
return None return None
try: try:
return datetime.fromisoformat(value) return datetime.fromisoformat(value)
except ValueError: except ValueError:
return None return None
def retry_schedule_seconds( def retry_schedule_seconds(
settings: dict[str, object], settings: dict[str, object],
*, *,
count_key: str, count_key: str,
backoff_key: str, backoff_key: str,
default_count: int, default_count: int,
default_backoff: int, default_backoff: int,
) -> list[int]: ) -> list[int]:
raw_schedule = settings.get("retry_schedule_minutes") raw_schedule = settings.get("retry_schedule_minutes")
if isinstance(raw_schedule, list): if isinstance(raw_schedule, list):
schedule: list[int] = [] schedule: list[int] = []
for item in raw_schedule: for item in raw_schedule:
if isinstance(item, int) and not isinstance(item, bool) and item >= 0: if isinstance(item, int) and not isinstance(item, bool) and item >= 0:
schedule.append(item * 60) schedule.append(item * 60)
if schedule: if schedule:
return schedule return schedule
retry_count = settings.get(count_key, default_count) 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 = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else default_count
retry_count = max(retry_count, 0) retry_count = max(retry_count, 0)
retry_backoff = settings.get(backoff_key, default_backoff) 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 = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else default_backoff
retry_backoff = max(retry_backoff, 0) retry_backoff = max(retry_backoff, 0)
return [retry_backoff] * retry_count return [retry_backoff] * retry_count
def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]: 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( return retry_schedule_seconds(
settings, settings,
count_key="retry_count", count_key="retry_count",
backoff_key="retry_backoff_seconds", 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, default_backoff=300,
) )
@ -58,60 +80,64 @@ def comment_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
return retry_schedule_seconds( return retry_schedule_seconds(
settings, settings,
count_key="max_retries", count_key="max_retries",
backoff_key="base_delay_seconds", backoff_key="base_delay_seconds",
default_count=5, default_count=5,
default_backoff=180, default_backoff=180,
) )
def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def] 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: if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0:
return None return None
step_name = getattr(step, "step_name", None) step_name = getattr(step, "step_name", None)
settings_group = STEP_SETTINGS_GROUP.get(step_name) settings_group = STEP_SETTINGS_GROUP.get(step_name)
if settings_group is None: if settings_group is None:
return None return None
group_settings = settings_by_group.get(settings_group, {}) group_settings = settings_by_group.get(settings_group, {})
if not isinstance(group_settings, dict): if not isinstance(group_settings, dict):
group_settings = {} group_settings = {}
if step_name == "publish": 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) schedule = publish_retry_schedule_seconds(group_settings)
elif step_name == "comment": elif step_name == "comment":
schedule = comment_retry_schedule_seconds(group_settings) schedule = comment_retry_schedule_seconds(group_settings)
else: else:
return None return None
attempt_index = step.retry_count - 1 attempt_index = step.retry_count - 1
if attempt_index >= len(schedule): if attempt_index >= len(schedule):
return { return {
"retry_due": False, "retry_due": False,
"retry_exhausted": True, "retry_exhausted": True,
"retry_wait_seconds": None, "retry_wait_seconds": None,
"retry_remaining_seconds": None, "retry_remaining_seconds": None,
"next_retry_at": None, "next_retry_at": None,
} }
wait_seconds = schedule[attempt_index] wait_seconds = schedule[attempt_index]
reference = parse_iso(getattr(step, "finished_at", None)) or parse_iso(getattr(step, "started_at", None)) reference = parse_iso(getattr(step, "finished_at", None)) or parse_iso(getattr(step, "started_at", None))
if reference is None: if reference is None:
return { return {
"retry_due": True, "retry_due": True,
"retry_exhausted": False, "retry_exhausted": False,
"retry_wait_seconds": wait_seconds, "retry_wait_seconds": wait_seconds,
"retry_remaining_seconds": 0, "retry_remaining_seconds": 0,
"next_retry_at": datetime.now(timezone.utc).isoformat(), "next_retry_at": datetime.now(timezone.utc).isoformat(),
} }
next_retry_at = reference + timedelta(seconds=wait_seconds) next_retry_at = reference + timedelta(seconds=wait_seconds)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
remaining_seconds = max(int((next_retry_at - now).total_seconds()), 0) remaining_seconds = max(int((next_retry_at - now).total_seconds()), 0)
return { return {
"retry_due": now >= next_retry_at, "retry_due": now >= next_retry_at,
"retry_exhausted": False, "retry_exhausted": False,
"retry_wait_seconds": wait_seconds, "retry_wait_seconds": wait_seconds,
"retry_remaining_seconds": remaining_seconds, "retry_remaining_seconds": remaining_seconds,
"next_retry_at": next_retry_at.isoformat(), "next_retry_at": next_retry_at.isoformat(),
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import { escapeHtml } from "../utils.js"; import { escapeHtml } from "../utils.js";
export function renderDoctor(checks) { export function renderDoctor(checks) {
const wrap = document.getElementById("doctorChecks"); const wrap = document.getElementById("doctorChecks");
wrap.innerHTML = ""; wrap.innerHTML = "";
for (const check of checks) { for (const check of checks) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "row-card"; row.className = "row-card";
row.innerHTML = ` 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="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> <div class="muted-note">${escapeHtml(check.detail)}</div>
`; `;
wrap.appendChild(row); wrap.appendChild(row);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,22 @@
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js"; import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
export function renderTimelineList(timeline) { export function renderTimelineList(timeline) {
const timelineWrap = document.getElementById("timelineList"); const timelineWrap = document.getElementById("timelineList");
timelineWrap.innerHTML = ""; timelineWrap.innerHTML = "";
timeline.items.forEach((item) => { timeline.items.forEach((item) => {
const retryNote = item.retry_state?.next_retry_at 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>` ? `<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"); const row = document.createElement("div");
row.className = "timeline-card"; row.className = "timeline-card";
row.innerHTML = ` 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-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">
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div> <div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div> <div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
${retryNote} ${retryNote}
</div> </div>
`; `;
timelineWrap.appendChild(row); timelineWrap.appendChild(row);
}); });
} }

View File

@ -1,320 +1,320 @@
import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } from "./api.js"; import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } from "./api.js";
import { bindActions } from "./actions.js"; import { bindActions } from "./actions.js";
import { currentRoute, initRouter, navigate } from "./router.js"; import { currentRoute, initRouter, navigate } from "./router.js";
import { import {
clearSettingsFieldState, clearSettingsFieldState,
markSettingsFieldDirty, markSettingsFieldDirty,
setOverviewData, setOverviewData,
setSettingsFieldError, setSettingsFieldError,
setLogs, setLogs,
setLogListLoading, setLogListLoading,
setSelectedLog, setSelectedLog,
setSelectedStep, setSelectedStep,
setSelectedTask, setSelectedTask,
setCurrentSession, setCurrentSession,
setTaskDetailStatus, setTaskDetailStatus,
setTaskListLoading, setTaskListLoading,
state, state,
} from "./state.js"; } from "./state.js";
import { settingsFieldKey, showBanner, withButtonBusy } from "./utils.js"; import { settingsFieldKey, showBanner, withButtonBusy } from "./utils.js";
import { import {
renderDoctor, renderDoctor,
renderModules, renderModules,
renderRecentActions, renderRecentActions,
renderSchedulerQueue, renderSchedulerQueue,
renderServices, renderServices,
renderShellStats, renderShellStats,
} from "./views/overview.js"; } from "./views/overview.js";
import { renderLogContent, renderLogsList } from "./views/logs.js"; import { renderLogContent, renderLogsList } from "./views/logs.js";
import { renderSettingsForm } from "./views/settings.js"; import { renderSettingsForm } from "./views/settings.js";
import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js"; import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js";
import { renderSessionPanel } from "./components/session-panel.js"; import { renderSessionPanel } from "./components/session-panel.js";
async function refreshLog() { async function refreshLog() {
const name = state.selectedLogName; const name = state.selectedLogName;
if (!name) return; if (!name) return;
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`; let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) { if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) {
const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId); const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId);
if (currentTask?.title) { if (currentTask?.title) {
url += `&contains=${encodeURIComponent(currentTask.title)}`; url += `&contains=${encodeURIComponent(currentTask.title)}`;
} }
} }
const payload = await fetchJson(url); const payload = await fetchJson(url);
renderLogContent(payload); renderLogContent(payload);
} }
async function selectLog(name) { async function selectLog(name) {
setSelectedLog(name); setSelectedLog(name);
renderLogsList(state.currentLogs, refreshLog, selectLog); renderLogsList(state.currentLogs, refreshLog, selectLog);
await refreshLog(); await refreshLog();
} }
async function loadTaskDetail(taskId) { async function loadTaskDetail(taskId) {
setTaskDetailStatus("loading"); setTaskDetailStatus("loading");
renderTaskWorkspaceState("loading"); renderTaskWorkspaceState("loading");
try { try {
const payload = await loadTaskPayload(taskId); const payload = await loadTaskPayload(taskId);
renderTaskDetail(payload, async (stepName) => { renderTaskDetail(payload, async (stepName) => {
setSelectedStep(stepName); setSelectedStep(stepName);
await loadTaskDetail(taskId); await loadTaskDetail(taskId);
}, { }, {
onBindFullVideo: async (currentTaskId, fullVideoBvid) => { onBindFullVideo: async (currentTaskId, fullVideoBvid) => {
const button = document.getElementById("bindFullVideoBtn"); const button = document.getElementById("bindFullVideoBtn");
const bvid = String(fullVideoBvid || "").trim(); const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) { if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn"); showBanner("请输入合法的 BV 号", "warn");
return; return;
} }
await withButtonBusy(button, "绑定中…", async () => { await withButtonBusy(button, "绑定中…", async () => {
try { try {
await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, { await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }), body: JSON.stringify({ full_video_bvid: bvid }),
}); });
await refreshSelectedTaskOnly(currentTaskId); await refreshSelectedTaskOnly(currentTaskId);
showBanner(`已绑定完整版 BV: ${bvid}`, "ok"); showBanner(`已绑定完整版 BV: ${bvid}`, "ok");
} catch (err) { } catch (err) {
showBanner(`绑定完整版失败: ${err}`, "err"); showBanner(`绑定完整版失败: ${err}`, "err");
} }
}); });
}, },
onOpenSession: async (sessionKey) => { onOpenSession: async (sessionKey) => {
if (!sessionKey) { if (!sessionKey) {
showBanner("当前任务没有可用的 session_key", "warn"); showBanner("当前任务没有可用的 session_key", "warn");
return; return;
} }
try { try {
await loadSessionDetail(sessionKey); await loadSessionDetail(sessionKey);
} catch (err) { } catch (err) {
showBanner(`读取 Session 失败: ${err}`, "err"); showBanner(`读取 Session 失败: ${err}`, "err");
} }
}, },
}); });
await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null); await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null);
setTaskDetailStatus("ready"); setTaskDetailStatus("ready");
renderTaskWorkspaceState("ready"); renderTaskWorkspaceState("ready");
} catch (err) { } catch (err) {
const message = `任务详情加载失败: ${err}`; const message = `任务详情加载失败: ${err}`;
setTaskDetailStatus("error", message); setTaskDetailStatus("error", message);
renderTaskWorkspaceState("error", message); renderTaskWorkspaceState("error", message);
throw err; throw err;
} }
} }
async function loadSessionDetail(sessionKey) { async function loadSessionDetail(sessionKey) {
if (!sessionKey) { if (!sessionKey) {
setCurrentSession(null); setCurrentSession(null);
renderSessionPanel(null); renderSessionPanel(null);
return; return;
} }
const session = await loadSessionPayload(sessionKey); const session = await loadSessionPayload(sessionKey);
setCurrentSession(session); setCurrentSession(session);
renderSessionPanel(session, { renderSessionPanel(session, {
onSelectTask: async (taskId) => { onSelectTask: async (taskId) => {
if (!taskId) return; if (!taskId) return;
taskSelectHandler(taskId); taskSelectHandler(taskId);
}, },
onRebind: async (currentSessionKey, fullVideoBvid) => { onRebind: async (currentSessionKey, fullVideoBvid) => {
const button = document.getElementById("sessionRebindBtn"); const button = document.getElementById("sessionRebindBtn");
const bvid = String(fullVideoBvid || "").trim(); const bvid = String(fullVideoBvid || "").trim();
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) { if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
showBanner("请输入合法的 BV 号", "warn"); showBanner("请输入合法的 BV 号", "warn");
return; return;
} }
await withButtonBusy(button, "重绑中…", async () => { await withButtonBusy(button, "重绑中…", async () => {
try { try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, { await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: bvid }), body: JSON.stringify({ full_video_bvid: bvid }),
}); });
await refreshSelectedTaskOnly(); await refreshSelectedTaskOnly();
showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok"); showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok");
} catch (err) { } catch (err) {
showBanner(`Session 重绑失败: ${err}`, "err"); showBanner(`Session 重绑失败: ${err}`, "err");
} }
}); });
}, },
onMerge: async (currentSessionKey, rawTaskIds) => { onMerge: async (currentSessionKey, rawTaskIds) => {
const button = document.getElementById("sessionMergeBtn"); const button = document.getElementById("sessionMergeBtn");
const taskIds = String(rawTaskIds || "") const taskIds = String(rawTaskIds || "")
.split(",") .split(",")
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
if (!taskIds.length) { if (!taskIds.length) {
showBanner("请先输入至少一个 task id", "warn"); showBanner("请先输入至少一个 task id", "warn");
return; return;
} }
await withButtonBusy(button, "合并中…", async () => { await withButtonBusy(button, "合并中…", async () => {
try { try {
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, { await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_ids: taskIds }), body: JSON.stringify({ task_ids: taskIds }),
}); });
await refreshSelectedTaskOnly(); await refreshSelectedTaskOnly();
showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok"); showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok");
} catch (err) { } catch (err) {
showBanner(`Session 合并失败: ${err}`, "err"); showBanner(`Session 合并失败: ${err}`, "err");
} }
}); });
}, },
}); });
} }
async function refreshTaskListOnly() { async function refreshTaskListOnly() {
const payload = await loadTasksPayload(100); const payload = await loadTasksPayload(100);
state.currentTasks = payload.items || []; state.currentTasks = payload.items || [];
renderTasks(taskSelectHandler, taskRowActionHandler); renderTasks(taskSelectHandler, taskRowActionHandler);
} }
async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) { async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) {
if (!taskId) return; if (!taskId) return;
await refreshTaskListOnly(); await refreshTaskListOnly();
await loadTaskDetail(taskId); await loadTaskDetail(taskId);
} }
function taskSelectHandler(taskId) { function taskSelectHandler(taskId) {
setSelectedTask(taskId); setSelectedTask(taskId);
setSelectedStep(null); setSelectedStep(null);
navigate("tasks", taskId); navigate("tasks", taskId);
renderTasks(taskSelectHandler, taskRowActionHandler); renderTasks(taskSelectHandler, taskRowActionHandler);
return loadTaskDetail(taskId); return loadTaskDetail(taskId);
} }
async function taskRowActionHandler(action, taskId) { async function taskRowActionHandler(action, taskId) {
if (action !== "run") return; if (action !== "run") return;
try { try {
const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" }); const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" });
await refreshSelectedTaskOnly(taskId); await refreshSelectedTaskOnly(taskId);
showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok"); showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok");
} catch (err) { } catch (err) {
showBanner(`任务执行失败: ${err}`, "err"); showBanner(`任务执行失败: ${err}`, "err");
} }
} }
function handleSettingsFieldChange(event) { function handleSettingsFieldChange(event) {
const input = event.target; const input = event.target;
const group = input.dataset.group; const group = input.dataset.group;
const field = input.dataset.field; const field = input.dataset.field;
const fieldSchema = state.currentSettingsSchema.groups[group][field]; const fieldSchema = state.currentSettingsSchema.groups[group][field];
const key = settingsFieldKey(group, field); const key = settingsFieldKey(group, field);
let value; let value;
if (fieldSchema.type === "boolean") value = input.checked; if (fieldSchema.type === "boolean") value = input.checked;
else if (fieldSchema.type === "integer") { else if (fieldSchema.type === "integer") {
value = Number(input.value); value = Number(input.value);
if (input.value === "" || Number.isNaN(value)) { if (input.value === "" || Number.isNaN(value)) {
state.currentSettings[group] ??= {}; state.currentSettings[group] ??= {};
state.currentSettings[group][field] = input.value; state.currentSettings[group][field] = input.value;
markSettingsFieldDirty(key, true); markSettingsFieldDirty(key, true);
setSettingsFieldError(key, "必须填写整数"); setSettingsFieldError(key, "必须填写整数");
renderSettingsForm(handleSettingsFieldChange); renderSettingsForm(handleSettingsFieldChange);
return; return;
} }
} }
else if (fieldSchema.type === "array") { else if (fieldSchema.type === "array") {
try { try {
value = JSON.parse(input.value || "[]"); value = JSON.parse(input.value || "[]");
if (!Array.isArray(value)) throw new Error("not array"); if (!Array.isArray(value)) throw new Error("not array");
} catch { } catch {
markSettingsFieldDirty(key, true); markSettingsFieldDirty(key, true);
setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`); setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`);
renderSettingsForm(handleSettingsFieldChange); renderSettingsForm(handleSettingsFieldChange);
return; return;
} }
} else value = input.value; } else value = input.value;
if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) { if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) {
markSettingsFieldDirty(key, true); markSettingsFieldDirty(key, true);
setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`); setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`);
renderSettingsForm(handleSettingsFieldChange); renderSettingsForm(handleSettingsFieldChange);
return; return;
} }
markSettingsFieldDirty(key, true); markSettingsFieldDirty(key, true);
setSettingsFieldError(key, ""); setSettingsFieldError(key, "");
if (!state.currentSettings[group]) state.currentSettings[group] = {}; if (!state.currentSettings[group]) state.currentSettings[group] = {};
state.currentSettings[group][field] = value; state.currentSettings[group][field] = value;
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2); document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
renderSettingsForm(handleSettingsFieldChange); renderSettingsForm(handleSettingsFieldChange);
} }
async function loadOverview() { async function loadOverview() {
setTaskListLoading(true); setTaskListLoading(true);
setLogListLoading(true); setLogListLoading(true);
renderTasks(taskSelectHandler, taskRowActionHandler); renderTasks(taskSelectHandler, taskRowActionHandler);
const payload = await loadOverviewPayload(); const payload = await loadOverviewPayload();
setOverviewData({ setOverviewData({
tasks: payload.tasks.items, tasks: payload.tasks.items,
settings: payload.settings, settings: payload.settings,
settingsSchema: payload.settingsSchema, settingsSchema: payload.settingsSchema,
}); });
clearSettingsFieldState(); clearSettingsFieldState();
setTaskListLoading(false); setTaskListLoading(false);
renderShellStats(payload); renderShellStats(payload);
renderSettingsForm(handleSettingsFieldChange); renderSettingsForm(handleSettingsFieldChange);
renderTasks(taskSelectHandler, taskRowActionHandler); renderTasks(taskSelectHandler, taskRowActionHandler);
renderModules(payload.modules.items); renderModules(payload.modules.items);
renderDoctor(payload.doctor.checks); renderDoctor(payload.doctor.checks);
renderSchedulerQueue(payload.scheduler); renderSchedulerQueue(payload.scheduler);
renderServices(payload.services.items, async (serviceId, action) => { renderServices(payload.services.items, async (serviceId, action) => {
if (["stop", "restart"].includes(action)) { if (["stop", "restart"].includes(action)) {
const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`); const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`);
if (!ok) return; if (!ok) return;
} }
try { try {
const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" }); const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
await loadOverview(); await loadOverview();
showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn"); showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn");
} catch (err) { } catch (err) {
showBanner(`service 操作失败: ${err}`, "err"); showBanner(`service 操作失败: ${err}`, "err");
} }
}); });
setLogs(payload.logs.items); setLogs(payload.logs.items);
setLogListLoading(false); setLogListLoading(false);
if (!state.selectedLogName && payload.logs.items.length) { if (!state.selectedLogName && payload.logs.items.length) {
setSelectedLog(payload.logs.items[0].name); setSelectedLog(payload.logs.items[0].name);
} }
renderLogsList(payload.logs.items, refreshLog, selectLog); renderLogsList(payload.logs.items, refreshLog, selectLog);
renderRecentActions(payload.history.items); renderRecentActions(payload.history.items);
const route = currentRoute(); const route = currentRoute();
const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId); const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId);
if (route.view === "tasks" && routeTaskExists) { if (route.view === "tasks" && routeTaskExists) {
setSelectedTask(route.taskId); setSelectedTask(route.taskId);
} else if (!state.selectedTaskId && state.currentTasks.length) { } else if (!state.selectedTaskId && state.currentTasks.length) {
setSelectedTask(state.currentTasks[0].id); setSelectedTask(state.currentTasks[0].id);
} }
if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId); if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId);
else { else {
setTaskDetailStatus("idle"); setTaskDetailStatus("idle");
renderTaskWorkspaceState("idle"); renderTaskWorkspaceState("idle");
} }
} }
async function handleRouteChange(route) { async function handleRouteChange(route) {
if (route.view !== "tasks") return; if (route.view !== "tasks") return;
if (!route.taskId) { if (!route.taskId) {
if (state.selectedTaskId) navigate("tasks", state.selectedTaskId); if (state.selectedTaskId) navigate("tasks", state.selectedTaskId);
return; return;
} }
if (!state.currentTasks.length) return; if (!state.currentTasks.length) return;
if (!state.currentTasks.some((item) => item.id === route.taskId)) return; if (!state.currentTasks.some((item) => item.id === route.taskId)) return;
if (state.selectedTaskId !== route.taskId) { if (state.selectedTaskId !== route.taskId) {
setSelectedTask(route.taskId); setSelectedTask(route.taskId);
setSelectedStep(null); setSelectedStep(null);
renderTasks(taskSelectHandler, taskRowActionHandler); renderTasks(taskSelectHandler, taskRowActionHandler);
await loadTaskDetail(route.taskId); await loadTaskDetail(route.taskId);
} }
} }
bindActions({ bindActions({
loadOverview, loadOverview,
loadTaskDetail, loadTaskDetail,
refreshSelectedTaskOnly, refreshSelectedTaskOnly,
refreshLog, refreshLog,
handleSettingsFieldChange, handleSettingsFieldChange,
}); });
initRouter((route) => { initRouter((route) => {
handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err")); handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err"));
}); });
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err")); loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));

View File

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

View File

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

View File

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

View File

@ -1,157 +1,157 @@
import { state } from "./state.js"; import { state } from "./state.js";
let bannerTimer = null; let bannerTimer = null;
export function statusClass(status) { export function statusClass(status) {
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good"; if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
if (["done", "resolved", "present"].includes(status)) return "good"; if (["done", "resolved", "present"].includes(status)) return "good";
if (["pending", "unresolved"].includes(status)) return "warn"; if (["pending", "unresolved"].includes(status)) return "warn";
if (["removed", "disabled"].includes(status)) return ""; if (["removed", "disabled"].includes(status)) return "";
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot"; if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn"; if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
return ""; return "";
} }
export function showBanner(message, kind) { export function showBanner(message, kind) {
const el = document.getElementById("banner"); const el = document.getElementById("banner");
el.textContent = message; el.textContent = message;
el.className = `banner show ${kind}`; el.className = `banner show ${kind}`;
if (bannerTimer) window.clearTimeout(bannerTimer); if (bannerTimer) window.clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => { bannerTimer = window.setTimeout(() => {
el.className = "banner"; el.className = "banner";
el.textContent = ""; el.textContent = "";
}, kind === "err" ? 6000 : 3200); }, kind === "err" ? 6000 : 3200);
} }
export function escapeHtml(text) { export function escapeHtml(text) {
return String(text) return String(text)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;"); .replaceAll(">", "&gt;");
} }
export function formatDate(value) { export function formatDate(value) {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false }); return date.toLocaleString("zh-CN", { hour12: false });
} }
export function formatDuration(seconds) { export function formatDuration(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) return "-"; if (seconds == null || Number.isNaN(Number(seconds))) return "-";
const total = Math.max(0, Number(seconds)); const total = Math.max(0, Number(seconds));
const h = Math.floor(total / 3600); const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60); const m = Math.floor((total % 3600) / 60);
const s = total % 60; const s = total % 60;
if (h > 0) return `${h}h ${m}m ${s}s`; if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`; if (m > 0) return `${m}m ${s}s`;
return `${s}s`; return `${s}s`;
} }
export function syncSettingsEditorFromState() { export function syncSettingsEditorFromState() {
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2); document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
} }
export function getGroupOrder(groupName) { export function getGroupOrder(groupName) {
return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999); return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999);
} }
export function compareFieldEntries(a, b) { export function compareFieldEntries(a, b) {
const orderA = Number(a[1].ui_order || 9999); const orderA = Number(a[1].ui_order || 9999);
const orderB = Number(b[1].ui_order || 9999); const orderB = Number(b[1].ui_order || 9999);
if (orderA !== orderB) return orderA - orderB; if (orderA !== orderB) return orderA - orderB;
return String(a[0]).localeCompare(String(b[0])); return String(a[0]).localeCompare(String(b[0]));
} }
export function settingsFieldKey(group, field) { export function settingsFieldKey(group, field) {
return `${group}.${field}`; return `${group}.${field}`;
} }
export function taskDisplayStatus(task) { export function taskDisplayStatus(task) {
if (!task) return "-"; if (!task) return "-";
if (task.status === "failed_manual") 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" && task.retry_state?.step_name === "comment") return "等待B站可见";
if (task.status === "failed_retryable") return "等待自动重试"; if (task.status === "failed_retryable") return "等待自动重试";
return { return {
created: "已接收", created: "已接收",
transcribed: "已转录", transcribed: "已转录",
songs_detected: "已识歌", songs_detected: "已识歌",
split_done: "已切片", split_done: "已切片",
published: "已上传", published: "已上传",
commented: "评论完成", commented: "评论完成",
collection_synced: "已完成", collection_synced: "已完成",
running: "处理中", running: "处理中",
}[task.status] || task.status || "-"; }[task.status] || task.status || "-";
} }
export function taskPrimaryActionLabel(task) { export function taskPrimaryActionLabel(task) {
if (!task) return "执行"; if (!task) return "执行";
if (task.status === "failed_manual") return "人工重跑"; if (task.status === "failed_manual") return "人工重跑";
if (task.retry_state?.retry_due) return "立即重试"; if (task.retry_state?.retry_due) return "立即重试";
if (task.status === "failed_retryable") return "继续等待"; if (task.status === "failed_retryable") return "继续等待";
if (task.status === "collection_synced") return "查看结果"; if (task.status === "collection_synced") return "查看结果";
return "执行"; return "执行";
} }
export function taskCurrentStep(task, steps = []) { export function taskCurrentStep(task, steps = []) {
const running = steps.find((step) => step.status === "running"); const running = steps.find((step) => step.status === "running");
if (running) return stepLabel(running.step_name); if (running) return stepLabel(running.step_name);
if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)}: ${taskDisplayStatus(task)}`; if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)}: ${taskDisplayStatus(task)}`;
const pending = steps.find((step) => step.status === "pending"); const pending = steps.find((step) => step.status === "pending");
if (pending) return stepLabel(pending.step_name); if (pending) return stepLabel(pending.step_name);
return { return {
created: "转录字幕", created: "转录字幕",
transcribed: "识别歌曲", transcribed: "识别歌曲",
songs_detected: "切分分P", songs_detected: "切分分P",
split_done: "上传分P", split_done: "上传分P",
published: "评论与合集", published: "评论与合集",
commented: "同步合集", commented: "同步合集",
collection_synced: "链路完成", collection_synced: "链路完成",
}[task?.status] || "-"; }[task?.status] || "-";
} }
export function stepLabel(stepName) { export function stepLabel(stepName) {
return { return {
ingest: "接收视频", ingest: "接收视频",
transcribe: "转录字幕", transcribe: "转录字幕",
song_detect: "识别歌曲", song_detect: "识别歌曲",
split: "切分分P", split: "切分分P",
publish: "上传分P", publish: "上传分P",
comment: "发布评论", comment: "发布评论",
collection_a: "加入完整版合集", collection_a: "加入完整版合集",
collection_b: "加入分P合集", collection_b: "加入分P合集",
}[stepName] || stepName || "-"; }[stepName] || stepName || "-";
} }
export function actionAdvice(task) { export function actionAdvice(task) {
if (!task) return ""; if (!task) return "";
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") { if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
return "B站通常需要一段时间完成转码和审核系统会自动重试评论。"; return "B站通常需要一段时间完成转码和审核系统会自动重试评论。";
} }
if (task.status === "failed_retryable") { if (task.status === "failed_retryable") {
return "当前错误可自动恢复,等到重试时间或手工触发即可。"; return "当前错误可自动恢复,等到重试时间或手工触发即可。";
} }
if (task.status === "failed_manual") { if (task.status === "failed_manual") {
return "这个任务需要人工判断,先看错误信息,再决定是重试当前步骤还是绑定完整版 BV。"; return "这个任务需要人工判断,先看错误信息,再决定是重试当前步骤还是绑定完整版 BV。";
} }
if (task.status === "collection_synced") { if (task.status === "collection_synced") {
return "链路已完成可以直接打开分P链接检查结果。"; return "链路已完成可以直接打开分P链接检查结果。";
} }
return "系统会继续推进后续步骤,必要时可在这里手工干预。"; return "系统会继续推进后续步骤,必要时可在这里手工干预。";
} }
export async function withButtonBusy(button, loadingText, fn) { export async function withButtonBusy(button, loadingText, fn) {
if (!button) return fn(); if (!button) return fn();
const originalHtml = button.innerHTML; const originalHtml = button.innerHTML;
const originalDisabled = button.disabled; const originalDisabled = button.disabled;
button.disabled = true; button.disabled = true;
button.classList.add("is-busy"); button.classList.add("is-busy");
if (loadingText) button.textContent = loadingText; if (loadingText) button.textContent = loadingText;
try { try {
return await fn(); return await fn();
} finally { } finally {
button.disabled = originalDisabled; button.disabled = originalDisabled;
button.classList.remove("is-busy"); button.classList.remove("is-busy");
button.innerHTML = originalHtml; button.innerHTML = originalHtml;
} }
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,19 +1,19 @@
from __future__ import annotations from __future__ import annotations
import json import json
from biliup_next.core.models import ActionRecord, utc_now_iso 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] 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( repo.add_action_record(
ActionRecord( ActionRecord(
id=None, id=None,
task_id=task_id, task_id=task_id,
action_name=action_name, action_name=action_name,
status=status, status=status,
summary=summary, summary=summary,
details_json=json.dumps(details, ensure_ascii=False), details_json=json.dumps(details, ensure_ascii=False),
created_at=utc_now_iso(), created_at=utc_now_iso(),
) )
) )

View File

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

View File

@ -1,190 +1,199 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from biliup_next.app.retry_meta import retry_meta_for_step from biliup_next.app.retry_meta import retry_meta_for_step
def settings_for(state: dict[str, object], group: str) -> dict[str, object]: def settings_for(state: dict[str, object], group: str) -> dict[str, object]:
settings = dict(state["settings"][group]) settings = dict(state["settings"][group])
settings.update(state["settings"]["paths"]) settings.update(state["settings"]["paths"])
if group == "comment" and "collection" in state["settings"]: if group == "comment" and "collection" in state["settings"]:
collection_settings = state["settings"]["collection"] collection_settings = state["settings"]["collection"]
if "allow_fuzzy_full_video_match" in collection_settings: if "allow_fuzzy_full_video_match" in collection_settings:
settings.setdefault("allow_fuzzy_full_video_match", collection_settings["allow_fuzzy_full_video_match"]) settings.setdefault("allow_fuzzy_full_video_match", collection_settings["allow_fuzzy_full_video_match"])
if group == "collection" and "cleanup" in state["settings"]: if group == "collection" and "cleanup" in state["settings"]:
settings.update(state["settings"]["cleanup"]) settings.update(state["settings"]["cleanup"])
if "publish" in state["settings"]: if "publish" in state["settings"]:
publish_settings = state["settings"]["publish"] publish_settings = state["settings"]["publish"]
if "biliup_path" in publish_settings: if "biliup_path" in publish_settings:
settings.setdefault("biliup_path", publish_settings["biliup_path"]) settings.setdefault("biliup_path", publish_settings["biliup_path"])
if "cookie_file" in publish_settings: if "cookie_file" in publish_settings:
settings.setdefault("cookie_file", publish_settings["cookie_file"]) settings.setdefault("cookie_file", publish_settings["cookie_file"])
return settings return settings
def infer_error_step_name(task, steps: dict[str, object]) -> str: # type: ignore[no-untyped-def] 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) running = next((step for step in steps.values() if step.status == "running"), None)
if running is not None: if running is not None:
return running.step_name return running.step_name
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None) failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is not None: if failed is not None:
return failed.step_name return failed.step_name
if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}: if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}:
return "transcribe" return "transcribe"
if task.status == "transcribed": if task.status == "transcribed":
return "song_detect" return "song_detect"
if task.status == "songs_detected": if task.status == "songs_detected":
return "split" return "split"
if task.status in {"published", "collection_synced"}: if task.status in {"published", "collection_synced"}:
if steps.get("comment") and steps["comment"].status in {"running", "pending", "failed_retryable"}: if steps.get("comment") and steps["comment"].status in {"running", "pending", "failed_retryable"}:
return "comment" return "comment"
if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}: if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}:
return "collection_a" return "collection_a"
return "collection_b" return "collection_b"
if task.status == "commented": if task.status == "commented":
if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}: if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}:
return "collection_a" return "collection_a"
return "collection_b" return "collection_b"
return "publish" return "publish"
def retry_wait_payload(task_id: str, step, state: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def] 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": if step.status != "failed_retryable":
return None 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"]: if meta is None or meta["retry_due"]:
return None return None
return { return {
"task_id": task_id, "task_id": task_id,
"step": step.step_name, "step": step.step_name,
"waiting_for_retry": True, "waiting_for_retry": True,
"remaining_seconds": meta["retry_remaining_seconds"], "remaining_seconds": meta["retry_remaining_seconds"],
"retry_count": step.retry_count, "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] 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()): if any(step.status == "running" for step in steps.values()):
return None, None return None, None
if task.status == "failed_retryable": if task.status == "failed_retryable":
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None) failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
if failed is None: if failed is None:
return None, None return None, None
wait_payload = retry_wait_payload(task.id, failed, state) wait_payload = retry_wait_payload(task.id, failed, state)
if wait_payload is not None: if wait_payload is not None:
return None, wait_payload return None, wait_payload
return failed.step_name, None return failed.step_name, None
if task.status == "created": if task.status == "created":
step = steps.get("transcribe") step = steps.get("transcribe")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "transcribe", None return "transcribe", None
if task.status == "transcribed": if task.status == "transcribed":
step = steps.get("song_detect") step = steps.get("song_detect")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "song_detect", None return "song_detect", None
if task.status == "songs_detected": if task.status == "songs_detected":
step = steps.get("split") step = steps.get("split")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "split", None return "split", None
if task.status == "split_done": if task.status == "split_done":
step = steps.get("publish") step = steps.get("publish")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
session_publish = _session_publish_gate(task.id, state) session_publish = _session_publish_gate(task.id, state)
if session_publish is not None: if session_publish is not None:
if session_publish["session_published"]: if session_publish["session_published"]:
return "publish", None return "publish", None
if not session_publish["is_anchor"]: if not session_publish["is_anchor"]:
return None, None return None, None
if not session_publish["all_split_ready"]: if not session_publish["all_split_ready"]:
return None, None return None, None
return "publish", None return "publish", None
if task.status in {"published", "collection_synced"}: if task.status in {"published", "collection_synced"}:
if state["settings"]["comment"].get("enabled", True): if state["settings"]["comment"].get("enabled", True):
step = steps.get("comment") step = steps.get("comment")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "comment", None return "comment", None
if state["settings"]["collection"].get("enabled", True): if state["settings"]["collection"].get("enabled", True):
step = steps.get("collection_a") step = steps.get("collection_a")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "collection_a", None return "collection_a", None
step = steps.get("collection_b") step = steps.get("collection_b")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "collection_b", None return "collection_b", None
if task.status == "commented" and state["settings"]["collection"].get("enabled", True): if task.status == "commented" and state["settings"]["collection"].get("enabled", True):
step = steps.get("collection_a") step = steps.get("collection_a")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "collection_a", None return "collection_a", None
step = steps.get("collection_b") step = steps.get("collection_b")
if step and step.status in {"pending", "failed_retryable"}: if step and step.status in {"pending", "failed_retryable"}:
return "collection_b", None return "collection_b", None
return None, None return None, None
def _session_publish_gate(task_id: str, state: dict[str, object]) -> dict[str, object] | None: def _session_publish_gate(task_id: str, state: dict[str, object]) -> dict[str, object] | None:
repo = state.get("repo") repo = state.get("repo")
if repo is None or not hasattr(repo, "get_task_context"): if repo is None or not hasattr(repo, "get_task_context"):
return None return None
context = repo.get_task_context(task_id) context = repo.get_task_context(task_id)
if context is None or not context.session_key or context.session_key.startswith("task:"): if context is None or not context.session_key or context.session_key.startswith("task:"):
return None return None
contexts = list(repo.list_task_contexts_by_session_key(context.session_key)) contexts = list(repo.list_task_contexts_by_session_key(context.session_key))
if len(contexts) <= 1: if len(contexts) <= 1:
return None return None
ordered = sorted( ordered = sorted(
contexts, contexts,
key=lambda item: ( key=lambda item: (
_parse_dt(item.segment_started_at), _parse_dt(item.segment_started_at),
item.source_title or item.task_id, item.source_title or item.task_id,
), ),
) )
anchor_id = ordered[0].task_id anchor_id = ordered[0].task_id
sibling_tasks = [repo.get_task(item.task_id) for item in ordered] sibling_tasks = [repo.get_task(item.task_id) for item in ordered]
session_published = any( session_published = any(
sibling is not None and sibling.status in {"published", "commented", "collection_synced"} sibling is not None and sibling.status in {"published", "commented", "collection_synced"}
for sibling in sibling_tasks for sibling in sibling_tasks
) )
all_split_ready = all( all_split_ready = all(
sibling is not None and sibling.status in {"split_done", "published", "commented", "collection_synced"} sibling is not None and sibling.status in {"split_done", "published", "commented", "collection_synced"}
for sibling in sibling_tasks for sibling in sibling_tasks
) )
return { return {
"is_anchor": task_id == anchor_id, "is_anchor": task_id == anchor_id,
"session_published": session_published, "session_published": session_published,
"all_split_ready": all_split_ready, "all_split_ready": all_split_ready,
} }
def _parse_dt(value: str | None) -> datetime: def _parse_dt(value: str | None) -> datetime:
if not value: if not value:
return datetime.max return datetime.max
try: try:
return datetime.fromisoformat(value) return datetime.fromisoformat(value)
except ValueError: except ValueError:
return datetime.max return datetime.max
def execute_step(state: dict[str, object], task_id: str, step_name: str) -> dict[str, object]: def execute_step(state: dict[str, object], task_id: str, step_name: str) -> dict[str, object]:
if step_name == "transcribe": if step_name == "transcribe":
artifact = state["transcribe_service"].run(task_id, settings_for(state, "transcribe")) artifact = state["transcribe_service"].run(task_id, settings_for(state, "transcribe"))
return {"task_id": task_id, "step": "transcribe", "artifact": artifact.path} return {"task_id": task_id, "step": "transcribe", "artifact": artifact.path}
if step_name == "song_detect": if step_name == "song_detect":
songs_json, songs_txt = state["song_detect_service"].run(task_id, settings_for(state, "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} return {"task_id": task_id, "step": "song_detect", "songs_json": songs_json.path, "songs_txt": songs_txt.path}
if step_name == "split": if step_name == "split":
clips = state["split_service"].run(task_id, settings_for(state, "split")) clips = state["split_service"].run(task_id, settings_for(state, "split"))
return {"task_id": task_id, "step": "split", "clip_count": len(clips)} return {"task_id": task_id, "step": "split", "clip_count": len(clips)}
if step_name == "publish": if step_name == "publish":
publish_record = state["publish_service"].run(task_id, settings_for(state, "publish")) publish_record = state["publish_service"].run(task_id, settings_for(state, "publish"))
return {"task_id": task_id, "step": "publish", "bvid": publish_record.bvid} return {"task_id": task_id, "step": "publish", "bvid": publish_record.bvid}
if step_name == "comment": if step_name == "comment":
comment_result = state["comment_service"].run(task_id, settings_for(state, "comment")) comment_result = state["comment_service"].run(task_id, settings_for(state, "comment"))
return {"task_id": task_id, "step": "comment", "result": comment_result} return {"task_id": task_id, "step": "comment", "result": comment_result}
if step_name == "collection_a": if step_name == "collection_a":
collection_result = state["collection_service"].run(task_id, "a", settings_for(state, "collection")) collection_result = state["collection_service"].run(task_id, "a", settings_for(state, "collection"))
return {"task_id": task_id, "step": "collection_a", "result": collection_result} return {"task_id": task_id, "step": "collection_a", "result": collection_result}
if step_name == "collection_b": if step_name == "collection_b":
collection_result = state["collection_service"].run(task_id, "b", settings_for(state, "collection")) collection_result = state["collection_service"].run(task_id, "b", settings_for(state, "collection"))
return {"task_id": task_id, "step": "collection_b", "result": collection_result} return {"task_id": task_id, "step": "collection_b", "result": collection_result}

View File

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

View File

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

View File

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

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