init biliup-next
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
data/
|
||||||
|
config/settings.staged.json
|
||||||
|
|
||||||
|
systemd/rendered/
|
||||||
|
|
||||||
|
runtime/cookies.json
|
||||||
|
runtime/upload_config.json
|
||||||
|
runtime/biliup
|
||||||
|
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
104
DELIVERY.md
Normal file
104
DELIVERY.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# biliup-next Delivery Checklist
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
`biliup-next` 当前已经是一个可独立安装、可运行、可通过控制台运维的本地项目。
|
||||||
|
|
||||||
|
当前交付范围包括:
|
||||||
|
|
||||||
|
- 独立 Python 包
|
||||||
|
- 本地 `.venv` 初始化
|
||||||
|
- SQLite 状态存储
|
||||||
|
- 隔离 workspace
|
||||||
|
- `worker` / `api` 运行脚本
|
||||||
|
- `systemd` 安装脚本
|
||||||
|
- Web 控制台
|
||||||
|
- 主链路:
|
||||||
|
- `stage`
|
||||||
|
- `ingest`
|
||||||
|
- `transcribe`
|
||||||
|
- `song_detect`
|
||||||
|
- `split`
|
||||||
|
- `publish`
|
||||||
|
- `comment`
|
||||||
|
- `collection`
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- `ffmpeg`
|
||||||
|
- `ffprobe`
|
||||||
|
- `codex`
|
||||||
|
- `biliup`
|
||||||
|
- 上层项目仍需提供:
|
||||||
|
- `../cookies.json`
|
||||||
|
- `../upload_config.json`
|
||||||
|
- `../.env` 中的运行时路径配置
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
如需把父项目中的运行资产复制到本地:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
./.venv/bin/biliup-next sync-legacy-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
./.venv/bin/biliup-next doctor
|
||||||
|
./.venv/bin/biliup-next init-workspace
|
||||||
|
bash smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:
|
||||||
|
|
||||||
|
- `doctor.ok = true`
|
||||||
|
- `data/workspace/stage`
|
||||||
|
- `data/workspace/backup`
|
||||||
|
- `data/workspace/session`
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
手动方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash run-worker.sh
|
||||||
|
bash run-api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd 方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash install-systemd.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Control Plane
|
||||||
|
|
||||||
|
- URL: `http://127.0.0.1:8787/`
|
||||||
|
- 健康检查:`/health`
|
||||||
|
- 可选认证:`runtime.control_token`
|
||||||
|
|
||||||
|
## Repository Hygiene
|
||||||
|
|
||||||
|
这些内容不应提交:
|
||||||
|
|
||||||
|
- `.venv/`
|
||||||
|
- `data/`
|
||||||
|
- `systemd/rendered/`
|
||||||
|
- `config/settings.staged.json`
|
||||||
|
|
||||||
|
## Known Limits
|
||||||
|
|
||||||
|
- 当前仍复用父项目中的 `cookies.json` / `upload_config.json` / `biliup`
|
||||||
|
- 当前 provider 仍有 legacy adapter
|
||||||
|
- 当前控制台认证是单 token,本地可用,但不等于完整权限系统
|
||||||
212
README.md
Normal file
212
README.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# biliup-next
|
||||||
|
|
||||||
|
`biliup-next` 是对当前项目的并行重构版本。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 不破坏旧项目运行
|
||||||
|
- 先完成控制面和核心模型
|
||||||
|
- 再逐步迁移转录、识歌、切歌、上传、评论、合集模块
|
||||||
|
|
||||||
|
## Current Scope
|
||||||
|
|
||||||
|
当前已实现:
|
||||||
|
|
||||||
|
- 文档基线
|
||||||
|
- 配置系统骨架
|
||||||
|
- SQLite 存储骨架
|
||||||
|
- 任务模型与任务仓库
|
||||||
|
- 最小 HTTP API
|
||||||
|
- 基础 CLI
|
||||||
|
- 隔离 workspace 运行
|
||||||
|
- `stage -> ingest -> transcribe -> song_detect -> split -> publish -> comment -> collection` 主链路
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup
|
||||||
|
PYTHONPATH=biliup-next/src python -m biliup_next.app.cli init
|
||||||
|
PYTHONPATH=biliup-next/src python -m biliup_next.app.cli doctor
|
||||||
|
PYTHONPATH=biliup-next/src python -m biliup_next.app.cli init-workspace
|
||||||
|
PYTHONPATH=biliup-next/src python -m biliup_next.app.cli worker --interval 5
|
||||||
|
PYTHONPATH=biliup-next/src python -m biliup_next.app.cli serve --host 127.0.0.1 --port 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐先执行一键初始化:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
它会完成:
|
||||||
|
|
||||||
|
- 创建 `biliup-next/.venv`
|
||||||
|
- `pip install -e .`
|
||||||
|
- 初始化隔离 workspace
|
||||||
|
- 尝试把父项目中的 `cookies.json` / `upload_config.json` / `biliup` 同步到 `biliup-next/runtime/`
|
||||||
|
- 执行一次 `doctor`
|
||||||
|
- 可选安装 `systemd` 服务
|
||||||
|
|
||||||
|
浏览器访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8787/
|
||||||
|
```
|
||||||
|
|
||||||
|
React 迁移版控制台未来入口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8787/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
当 `frontend/dist/` 存在时,Python API 会自动托管这套前端;旧控制台 `/` 仍然保留。
|
||||||
|
|
||||||
|
控制台当前支持:
|
||||||
|
|
||||||
|
- 任务列表 / 步骤 / 产物查看
|
||||||
|
- `doctor` 检查查看
|
||||||
|
- 模块清单查看
|
||||||
|
- schema 驱动的 Settings 表单
|
||||||
|
- 高频参数优先展示,低频项收纳在 Advanced Settings
|
||||||
|
- 配置分组标题、排序和 featured 字段均由 `config/settings.schema.json` 控制
|
||||||
|
- `ui_widget`、placeholder 和分组说明也由 schema 决定
|
||||||
|
- `publish.retry_schedule_minutes` 支持按次配置上传重试间隔
|
||||||
|
- 默认第 1 次重试等待 15 分钟
|
||||||
|
- 第 2-5 次各等待 5 分钟
|
||||||
|
- 评论链路支持拆分:
|
||||||
|
- 纯享版 `bvid.txt` 下默认发布“无时间轴编号歌单”
|
||||||
|
- 完整版主视频默认尝试发布“带时间轴评论”
|
||||||
|
- 若找不到 `full_video_bvid.txt` 或匹配不到完整版视频,则主视频评论跳过
|
||||||
|
- 任务完成后支持可选清理:
|
||||||
|
- 删除 session 中的原始完整视频
|
||||||
|
- 删除 `split_video/` 目录中的纯享切片
|
||||||
|
- `settings.json` 原始 JSON 兜底编辑
|
||||||
|
- 敏感字段会显示为 `__BILIUP_NEXT_SECRET__`
|
||||||
|
- 保留占位符表示“不改原值”,改为空字符串才会清空
|
||||||
|
- 手动触发一轮 `worker`
|
||||||
|
- 单任务执行 / 指定 step 重试 / 重置到某一步后重跑
|
||||||
|
- 任务详情直接显示下一次重试时间、剩余等待时长和重试策略
|
||||||
|
- 控制台已重构为 `Overview / Tasks / Settings / Logs` 分区导航,任务页支持搜索、状态过滤和排序
|
||||||
|
- `systemd` 服务状态查看与 `start/stop/restart`
|
||||||
|
- 全局 Recent Actions 动作流,可按任务 / 状态 / action_name 过滤
|
||||||
|
- 系统日志查看,可按当前任务标题过滤
|
||||||
|
- 把本机现有视频导入隔离 `stage`
|
||||||
|
- 浏览器直接上传文件到隔离 `stage`
|
||||||
|
|
||||||
|
控制台操作手册见:
|
||||||
|
|
||||||
|
- `docs/control-plane-guide.md`
|
||||||
|
|
||||||
|
也可以直接使用项目内启动脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash run-worker.sh
|
||||||
|
bash run-api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后也可以直接使用 console script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
./.venv/bin/biliup-next doctor
|
||||||
|
./.venv/bin/biliup-next worker --interval 5
|
||||||
|
./.venv/bin/biliup-next serve --host 127.0.0.1 --port 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
快速冒烟测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
默认工作目录全部在 `biliup-next/data/workspace/` 下:
|
||||||
|
|
||||||
|
- `stage/`
|
||||||
|
- `backup/`
|
||||||
|
- `session/`
|
||||||
|
- `biliup_next.db`
|
||||||
|
|
||||||
|
外部依赖目前仍复用旧项目中的:
|
||||||
|
|
||||||
|
- `../cookies.json`
|
||||||
|
- `../upload_config.json`
|
||||||
|
- `../biliup`
|
||||||
|
- `../.env` 中的 `CODEX_CMD` / `FFMPEG_BIN` / `FFPROBE_BIN`
|
||||||
|
|
||||||
|
如果你希望进一步脱离父项目,可以执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
./.venv/bin/biliup-next sync-legacy-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
这会把当前可用的:
|
||||||
|
|
||||||
|
- `cookies.json`
|
||||||
|
- `upload_config.json`
|
||||||
|
- `biliup`
|
||||||
|
|
||||||
|
复制到 `biliup-next/runtime/`,并把 `settings.json` 切换到本地副本。
|
||||||
|
|
||||||
|
## Comment And Cleanup
|
||||||
|
|
||||||
|
评论默认分成两条:
|
||||||
|
|
||||||
|
- `comment.post_split_comment = true`
|
||||||
|
- 纯享版分P视频下发布编号歌单评论
|
||||||
|
- 不带时间轴
|
||||||
|
- `comment.post_full_video_timeline_comment = true`
|
||||||
|
- 尝试在完整版主视频下发布带时间轴的置顶评论
|
||||||
|
- 依赖 `full_video_bvid.txt` 或通过标题匹配解析到完整版 BV
|
||||||
|
|
||||||
|
清理默认关闭:
|
||||||
|
|
||||||
|
- `cleanup.delete_source_video_after_collection_synced = false`
|
||||||
|
- `cleanup.delete_split_videos_after_collection_synced = false`
|
||||||
|
|
||||||
|
只有在任务进入 `collection_synced` 后,才会按配置执行清理。
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
控制台支持可选 token 保护:
|
||||||
|
|
||||||
|
- 配置项:`runtime.control_token`
|
||||||
|
- 默认值为空,表示不启用认证
|
||||||
|
- 配置后,除 `/` 和 `/health` 外,其余 API 都要求请求头 `X-Biliup-Token`
|
||||||
|
- 控制台首页可在顶部输入并保存 token,浏览器会保存在本地 `localStorage`
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
模板位于:
|
||||||
|
|
||||||
|
- `systemd/biliup-next-worker.service.template`
|
||||||
|
- `systemd/biliup-next-api.service.template`
|
||||||
|
- `install-systemd.sh`
|
||||||
|
|
||||||
|
使用前把模板中的这些占位符替换成实际值:
|
||||||
|
|
||||||
|
- `__PROJECT_DIR__`
|
||||||
|
- `__USER__`
|
||||||
|
- `__GROUP__`
|
||||||
|
- `__PYTHON_BIN__`
|
||||||
|
|
||||||
|
推荐先启动 `worker`,`api` 可以单独开。
|
||||||
|
|
||||||
|
在当前机器上直接安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
bash install-systemd.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动:
|
||||||
|
|
||||||
|
- 按当前目录渲染 unit
|
||||||
|
- 安装到 `/etc/systemd/system/`
|
||||||
|
- `daemon-reload`
|
||||||
|
- `enable --now` 启动 `worker` 和 `api`
|
||||||
96
config/settings.json
Normal file
96
config/settings.json
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"database_path": "/home/theshy/biliup/biliup-next/data/workspace/biliup_next.db",
|
||||||
|
"control_token": "",
|
||||||
|
"log_level": "INFO"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"stage_dir": "/home/theshy/biliup/biliup-next/data/workspace/stage",
|
||||||
|
"backup_dir": "/home/theshy/biliup/biliup-next/data/workspace/backup",
|
||||||
|
"session_dir": "/home/theshy/biliup/biliup-next/data/workspace/session",
|
||||||
|
"cookies_file": "/home/theshy/biliup/biliup-next/runtime/cookies.json",
|
||||||
|
"upload_config_file": "/home/theshy/biliup/biliup-next/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",
|
||||||
|
"allowed_extensions": [
|
||||||
|
".mp4",
|
||||||
|
".flv",
|
||||||
|
".mkv",
|
||||||
|
".mov"
|
||||||
|
],
|
||||||
|
"stage_min_free_space_mb": 2048,
|
||||||
|
"stability_wait_seconds": 30
|
||||||
|
},
|
||||||
|
"transcribe": {
|
||||||
|
"provider": "groq",
|
||||||
|
"groq_api_key": "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",
|
||||||
|
"ffmpeg_bin": "ffmpeg",
|
||||||
|
"max_file_size_mb": 23
|
||||||
|
},
|
||||||
|
"song_detect": {
|
||||||
|
"provider": "codex",
|
||||||
|
"codex_cmd": "/home/theshy/.nvm/versions/node/v22.13.0/bin/codex",
|
||||||
|
"poll_interval_seconds": 2
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"provider": "ffmpeg_copy",
|
||||||
|
"ffmpeg_bin": "ffmpeg",
|
||||||
|
"poll_interval_seconds": 2,
|
||||||
|
"min_free_space_mb": 2048
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"provider": "biliup_cli",
|
||||||
|
"biliup_path": "/home/theshy/biliup/biliup-next/runtime/biliup",
|
||||||
|
"cookie_file": "/home/theshy/biliup/biliup-next/runtime/cookies.json",
|
||||||
|
"retry_count": 5,
|
||||||
|
"retry_schedule_minutes": [
|
||||||
|
15,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"retry_backoff_seconds": 300
|
||||||
|
},
|
||||||
|
"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": true,
|
||||||
|
"delete_split_videos_after_collection_synced": true
|
||||||
|
}
|
||||||
|
}
|
||||||
444
config/settings.schema.json
Normal file
444
config/settings.schema.json
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "biliup-next settings schema",
|
||||||
|
"group_ui": {
|
||||||
|
"runtime": { "title": "Runtime", "order": 10, "description": "控制面本身的日志、认证和数据库设置。" },
|
||||||
|
"paths": { "title": "Paths", "order": 20, "description": "隔离 workspace 与外部依赖文件路径。" },
|
||||||
|
"scheduler": { "title": "Scheduler", "order": 30, "description": "每轮扫描多少任务、按什么顺序推进。" },
|
||||||
|
"ingest": { "title": "Ingest", "order": 40, "description": "视频进入 stage 后的入口规则。" },
|
||||||
|
"transcribe": { "title": "Transcribe", "order": 50, "description": "字幕转录与音频切分相关参数。" },
|
||||||
|
"song_detect": { "title": "Song Detect", "order": 60, "description": "歌曲识别 provider 与轮询间隔。" },
|
||||||
|
"split": { "title": "Split", "order": 70, "description": "切歌输出与 FFmpeg 行为。" },
|
||||||
|
"publish": { "title": "Publish", "order": 80, "description": "上传器、cookies 与重试策略。" },
|
||||||
|
"comment": { "title": "Comment", "order": 90, "description": "置顶评论开关与重试时序。" },
|
||||||
|
"collection": { "title": "Collection", "order": 100, "description": "合集绑定、模糊匹配与排序策略。" },
|
||||||
|
"cleanup": { "title": "Cleanup", "order": 110, "description": "任务完成后清理原视频和切片视频以节省空间。" }
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"runtime": {
|
||||||
|
"database_path": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "data/workspace/biliup_next.db",
|
||||||
|
"title": "Database Path",
|
||||||
|
"ui_order": 20,
|
||||||
|
"ui_widget": "path",
|
||||||
|
"description": "SQLite database path relative to biliup-next root"
|
||||||
|
},
|
||||||
|
"control_token": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"title": "Control Token",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "secret",
|
||||||
|
"ui_placeholder": "留空则关闭控制面认证",
|
||||||
|
"description": "Optional API/dashboard auth token",
|
||||||
|
"sensitive": true
|
||||||
|
},
|
||||||
|
"log_level": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "INFO",
|
||||||
|
"title": "Log Level",
|
||||||
|
"ui_order": 30,
|
||||||
|
"enum": ["DEBUG", "INFO", "WARNING", "ERROR"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"stage_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../stage",
|
||||||
|
"title": "Stage Directory",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_widget": "path"
|
||||||
|
},
|
||||||
|
"backup_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../backup",
|
||||||
|
"title": "Backup Directory",
|
||||||
|
"ui_order": 20,
|
||||||
|
"ui_widget": "path"
|
||||||
|
},
|
||||||
|
"session_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../session",
|
||||||
|
"title": "Session Directory",
|
||||||
|
"ui_order": 30,
|
||||||
|
"ui_widget": "path"
|
||||||
|
},
|
||||||
|
"cookies_file": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../cookies.json",
|
||||||
|
"title": "Cookies File",
|
||||||
|
"ui_order": 40,
|
||||||
|
"ui_widget": "path"
|
||||||
|
},
|
||||||
|
"upload_config_file": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../upload_config.json",
|
||||||
|
"title": "Upload Config File",
|
||||||
|
"ui_order": 50,
|
||||||
|
"ui_widget": "path"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"candidate_scan_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 500,
|
||||||
|
"title": "Candidate Scan Limit",
|
||||||
|
"ui_order": 10,
|
||||||
|
"minimum": 1,
|
||||||
|
"description": "每轮调度预览时,最多读取多少个任务作为候选。"
|
||||||
|
},
|
||||||
|
"max_tasks_per_cycle": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 50,
|
||||||
|
"title": "Max Tasks Per Cycle",
|
||||||
|
"ui_order": 20,
|
||||||
|
"ui_featured": true,
|
||||||
|
"minimum": 1,
|
||||||
|
"description": "单轮 worker 最多实际推进多少个任务。"
|
||||||
|
},
|
||||||
|
"prioritize_retry_due": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Prioritize Retry Due",
|
||||||
|
"ui_order": 30,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "将已到达重试时间的任务排在普通 ready 任务前面。"
|
||||||
|
},
|
||||||
|
"oldest_first": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Oldest First",
|
||||||
|
"ui_order": 40,
|
||||||
|
"description": "同优先级下是否优先处理较早更新的任务。"
|
||||||
|
},
|
||||||
|
"status_priority": {
|
||||||
|
"type": "array",
|
||||||
|
"default": ["failed_retryable", "created", "transcribed", "songs_detected", "split_done", "published", "commented", "collection_synced"],
|
||||||
|
"title": "Status Priority",
|
||||||
|
"ui_order": 50,
|
||||||
|
"description": "调度排序时使用的状态优先级列表。越靠前优先级越高。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ingest": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "local_file",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 30
|
||||||
|
},
|
||||||
|
"min_duration_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 900,
|
||||||
|
"title": "Min Duration Seconds",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "duration_seconds",
|
||||||
|
"description": "低于该时长的视频会直接进入 backup,不进入后续流程。",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"ffprobe_bin": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "ffprobe",
|
||||||
|
"title": "FFprobe Bin",
|
||||||
|
"ui_order": 20,
|
||||||
|
"ui_widget": "command"
|
||||||
|
},
|
||||||
|
"allowed_extensions": {
|
||||||
|
"type": "array",
|
||||||
|
"default": [".mp4", ".flv", ".mkv", ".mov"],
|
||||||
|
"title": "Allowed Extensions",
|
||||||
|
"ui_order": 40
|
||||||
|
},
|
||||||
|
"stage_min_free_space_mb": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 2048,
|
||||||
|
"title": "Stage Min Free Space MB",
|
||||||
|
"ui_order": 50,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "storage_mb",
|
||||||
|
"description": "导入或上传到 stage 前要求保留的最小剩余空间。导入本地文件时还会额外加上源文件大小。",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"stability_wait_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
"title": "Stage Stability Wait Seconds",
|
||||||
|
"ui_order": 60,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "duration_seconds",
|
||||||
|
"description": "扫描 stage 时,文件最后修改后至少静默这么久才会开始处理。用于避免手动 copy 半截文件被提前接走。",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transcribe": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "groq",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 20
|
||||||
|
},
|
||||||
|
"groq_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"title": "Groq API Key",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "secret",
|
||||||
|
"ui_placeholder": "gsk_...",
|
||||||
|
"description": "用于调用 Groq 转录 API。",
|
||||||
|
"sensitive": true
|
||||||
|
},
|
||||||
|
"ffmpeg_bin": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "ffmpeg",
|
||||||
|
"title": "FFmpeg Bin",
|
||||||
|
"ui_order": 30,
|
||||||
|
"ui_widget": "command"
|
||||||
|
},
|
||||||
|
"max_file_size_mb": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 23,
|
||||||
|
"title": "Max File Size MB",
|
||||||
|
"ui_order": 40,
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"song_detect": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "codex",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 20
|
||||||
|
},
|
||||||
|
"codex_cmd": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "codex",
|
||||||
|
"title": "Codex Command",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "command",
|
||||||
|
"description": "歌曲识别时实际执行的 codex 命令或绝对路径。"
|
||||||
|
},
|
||||||
|
"poll_interval_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 2,
|
||||||
|
"title": "Poll Interval Seconds",
|
||||||
|
"ui_order": 30,
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "ffmpeg_copy",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 20
|
||||||
|
},
|
||||||
|
"ffmpeg_bin": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "ffmpeg",
|
||||||
|
"title": "FFmpeg Bin",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_widget": "command"
|
||||||
|
},
|
||||||
|
"poll_interval_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 2,
|
||||||
|
"title": "Poll Interval Seconds",
|
||||||
|
"ui_order": 30,
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"min_free_space_mb": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 2048,
|
||||||
|
"title": "Split Min Free Space MB",
|
||||||
|
"ui_order": 40,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "storage_mb",
|
||||||
|
"description": "开始切歌前要求额外保留的最小剩余空间。实际检查值为该配置加源视频大小。",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "biliup_cli",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 30
|
||||||
|
},
|
||||||
|
"biliup_path": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../biliup",
|
||||||
|
"title": "Biliup Path",
|
||||||
|
"ui_order": 20,
|
||||||
|
"ui_widget": "path"
|
||||||
|
},
|
||||||
|
"cookie_file": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "../cookies.json",
|
||||||
|
"title": "Cookie File",
|
||||||
|
"ui_order": 40,
|
||||||
|
"ui_widget": "path"
|
||||||
|
},
|
||||||
|
"retry_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 5,
|
||||||
|
"title": "Retry Count",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "上传失败后的最大重试次数。默认与重试时间表长度保持一致。",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"retry_schedule_minutes": {
|
||||||
|
"type": "array",
|
||||||
|
"default": [15, 5, 5, 5, 5],
|
||||||
|
"title": "Retry Schedule Minutes",
|
||||||
|
"ui_order": 15,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "array",
|
||||||
|
"description": "按分钟定义每次上传重试的等待时间。默认第 1 次重试等待 15 分钟,第 2-5 次各等待 5 分钟。",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retry_backoff_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 300,
|
||||||
|
"title": "Retry Backoff Seconds",
|
||||||
|
"ui_order": 50,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "duration_seconds",
|
||||||
|
"description": "旧版统一回退秒数。仅在未配置重试时间表时作为兼容兜底。",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "bilibili_top_comment",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 20
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Enabled",
|
||||||
|
"ui_order": 10
|
||||||
|
},
|
||||||
|
"max_retries": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 5,
|
||||||
|
"title": "Max Retries",
|
||||||
|
"ui_order": 30,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"base_delay_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 180,
|
||||||
|
"title": "Base Delay Seconds",
|
||||||
|
"ui_order": 40,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"poll_interval_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 10,
|
||||||
|
"title": "Poll Interval Seconds",
|
||||||
|
"ui_order": 50,
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"post_split_comment": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Post Split Comment",
|
||||||
|
"ui_order": 60,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "是否在纯享版分P视频下发布置顶歌单评论。纯享版评论默认不带时间轴。"
|
||||||
|
},
|
||||||
|
"post_full_video_timeline_comment": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Post Full Video Timeline Comment",
|
||||||
|
"ui_order": 70,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "是否尝试在完整版主视频下发布带时间轴的置顶评论。找不到完整版 BV 时会跳过。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "bilibili_collection",
|
||||||
|
"title": "Provider",
|
||||||
|
"ui_order": 30
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Enabled",
|
||||||
|
"ui_order": 20
|
||||||
|
},
|
||||||
|
"season_id_a": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 7196643,
|
||||||
|
"title": "Season ID A",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "season_id",
|
||||||
|
"description": "完整版合集 ID。",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"season_id_b": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 7196624,
|
||||||
|
"title": "Season ID B",
|
||||||
|
"ui_order": 11,
|
||||||
|
"ui_featured": true,
|
||||||
|
"ui_widget": "season_id",
|
||||||
|
"description": "纯享版合集 ID。",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"allow_fuzzy_full_video_match": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Allow Fuzzy Full Video Match",
|
||||||
|
"ui_order": 40
|
||||||
|
},
|
||||||
|
"append_collection_a_new_to_end": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Append Collection A New To End",
|
||||||
|
"ui_order": 50,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "新加入的完整版视频是否追加到合集末尾。"
|
||||||
|
},
|
||||||
|
"append_collection_b_new_to_end": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"title": "Append Collection B New To End",
|
||||||
|
"ui_order": 60,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "新加入的纯享版视频是否追加到合集末尾。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cleanup": {
|
||||||
|
"delete_source_video_after_collection_synced": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Delete Source Video After Collection Synced",
|
||||||
|
"ui_order": 10,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "任务完成并写入合集后,是否删除 session 目录中的原始完整视频。"
|
||||||
|
},
|
||||||
|
"delete_split_videos_after_collection_synced": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Delete Split Videos After Collection Synced",
|
||||||
|
"ui_order": 20,
|
||||||
|
"ui_featured": true,
|
||||||
|
"description": "任务完成并写入合集后,是否删除 split_video 目录中的纯享切片视频。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
config/settings.standalone.example.json
Normal file
59
config/settings.standalone.example.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"database_path": "data/workspace/biliup_next.db",
|
||||||
|
"control_token": "",
|
||||||
|
"log_level": "INFO"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"stage_dir": "data/workspace/stage",
|
||||||
|
"backup_dir": "data/workspace/backup",
|
||||||
|
"session_dir": "data/workspace/session",
|
||||||
|
"cookies_file": "runtime/cookies.json",
|
||||||
|
"upload_config_file": "runtime/upload_config.json"
|
||||||
|
},
|
||||||
|
"ingest": {
|
||||||
|
"provider": "local_file",
|
||||||
|
"min_duration_seconds": 900,
|
||||||
|
"ffprobe_bin": "ffprobe",
|
||||||
|
"allowed_extensions": [".mp4", ".flv", ".mkv", ".mov"]
|
||||||
|
},
|
||||||
|
"transcribe": {
|
||||||
|
"provider": "groq",
|
||||||
|
"groq_api_key": "",
|
||||||
|
"ffmpeg_bin": "ffmpeg",
|
||||||
|
"max_file_size_mb": 23
|
||||||
|
},
|
||||||
|
"song_detect": {
|
||||||
|
"provider": "codex",
|
||||||
|
"codex_cmd": "codex",
|
||||||
|
"poll_interval_seconds": 2
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"provider": "ffmpeg_copy",
|
||||||
|
"ffmpeg_bin": "ffmpeg",
|
||||||
|
"poll_interval_seconds": 2
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"provider": "biliup_cli",
|
||||||
|
"biliup_path": "runtime/biliup",
|
||||||
|
"cookie_file": "runtime/cookies.json",
|
||||||
|
"retry_count": 5,
|
||||||
|
"retry_backoff_seconds": 300
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"provider": "bilibili_top_comment",
|
||||||
|
"enabled": true,
|
||||||
|
"max_retries": 5,
|
||||||
|
"base_delay_seconds": 180,
|
||||||
|
"poll_interval_seconds": 10
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"provider": "bilibili_collection",
|
||||||
|
"enabled": true,
|
||||||
|
"season_id_a": 7196643,
|
||||||
|
"season_id_b": 7196624,
|
||||||
|
"allow_fuzzy_full_video_match": false,
|
||||||
|
"append_collection_a_new_to_end": true,
|
||||||
|
"append_collection_b_new_to_end": true
|
||||||
|
}
|
||||||
|
}
|
||||||
64
docs/adr/0001-modular-monolith.md
Normal file
64
docs/adr/0001-modular-monolith.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# ADR 0001: Use A Modular Monolith As The Target Architecture
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
当前项目由多个 Python 脚本、目录扫描逻辑、flag 文件和外部命令拼接而成。
|
||||||
|
|
||||||
|
主要问题:
|
||||||
|
|
||||||
|
- 状态不统一
|
||||||
|
- 配置不统一
|
||||||
|
- 重复逻辑多
|
||||||
|
- 扩展新功能需要继续增加脚本
|
||||||
|
- 运维和业务边界不清晰
|
||||||
|
|
||||||
|
重构目标要求:
|
||||||
|
|
||||||
|
- 可扩展
|
||||||
|
- 可配置
|
||||||
|
- 可观测
|
||||||
|
- 易部署
|
||||||
|
- 易文档化
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
新系统采用模块化单体架构,而不是:
|
||||||
|
|
||||||
|
- 继续维护脚本集合
|
||||||
|
- 直接拆成微服务
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
选择模块化单体的原因:
|
||||||
|
|
||||||
|
- 当前系统规模和团队协作模式不需要微服务
|
||||||
|
- 单机部署和本地运维是核心需求
|
||||||
|
- 统一数据库、配置和日志对当前问题最直接有效
|
||||||
|
- 模块化单体足以提供清晰边界和未来插件扩展能力
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
正面影响:
|
||||||
|
|
||||||
|
- 部署简单
|
||||||
|
- 重构成本可控
|
||||||
|
- 便于引入统一状态机和管理 API
|
||||||
|
- 后续可以逐步插件化
|
||||||
|
|
||||||
|
负面影响:
|
||||||
|
|
||||||
|
- 需要严格维持模块边界,避免重新长成“大脚本”
|
||||||
|
- 单进程内错误隔离不如微服务天然
|
||||||
|
|
||||||
|
## Follow-Up Decisions
|
||||||
|
|
||||||
|
后续还需要补充的 ADR:
|
||||||
|
|
||||||
|
- 是否使用 SQLite 作为主状态存储
|
||||||
|
- 是否引入事件总线
|
||||||
|
- 插件机制如何注册
|
||||||
|
- 管理台采用什么技术栈
|
||||||
245
docs/api/openapi.yaml
Normal file
245
docs/api/openapi.yaml
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: biliup-next Control API
|
||||||
|
version: 0.1.0
|
||||||
|
summary: 本地 worker、任务和控制台 API
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:8787
|
||||||
|
paths:
|
||||||
|
/:
|
||||||
|
get:
|
||||||
|
summary: 控制台首页
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: HTML dashboard
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
summary: 健康检查
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
/settings:
|
||||||
|
get:
|
||||||
|
summary: 获取当前设置
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 当前配置,敏感字段已掩码
|
||||||
|
put:
|
||||||
|
summary: 更新设置
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 保存成功
|
||||||
|
/settings/schema:
|
||||||
|
get:
|
||||||
|
summary: 获取 settings schema
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: schema-first UI 元数据
|
||||||
|
/doctor:
|
||||||
|
get:
|
||||||
|
summary: 运行时依赖检查
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: doctor result
|
||||||
|
/modules:
|
||||||
|
get:
|
||||||
|
summary: 查询已注册模块与 manifest
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: module list
|
||||||
|
/runtime/services:
|
||||||
|
get:
|
||||||
|
summary: 查询 systemd 服务状态
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: service list
|
||||||
|
/runtime/services/{serviceId}/{action}:
|
||||||
|
post:
|
||||||
|
summary: 执行 service start/stop/restart
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: serviceId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: path
|
||||||
|
name: action
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [start, stop, restart]
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: action accepted
|
||||||
|
/logs:
|
||||||
|
get:
|
||||||
|
summary: 查询日志列表或日志内容
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: name
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: lines
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: contains
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: log list or log content
|
||||||
|
/history:
|
||||||
|
get:
|
||||||
|
summary: 查询全局动作流
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: task_id
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: action_name
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: action records
|
||||||
|
/tasks:
|
||||||
|
get:
|
||||||
|
summary: 查询任务列表
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: task list
|
||||||
|
post:
|
||||||
|
summary: 从 source_path 手动创建任务
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: task created
|
||||||
|
/tasks/{taskId}:
|
||||||
|
get:
|
||||||
|
summary: 查询任务详情
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: task detail
|
||||||
|
/tasks/{taskId}/steps:
|
||||||
|
get:
|
||||||
|
summary: 查询任务步骤
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: task steps
|
||||||
|
/tasks/{taskId}/artifacts:
|
||||||
|
get:
|
||||||
|
summary: 查询任务产物
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: task artifacts
|
||||||
|
/tasks/{taskId}/history:
|
||||||
|
get:
|
||||||
|
summary: 查询单任务动作历史
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: task action history
|
||||||
|
/tasks/{taskId}/timeline:
|
||||||
|
get:
|
||||||
|
summary: 查询单任务时间线
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: task timeline
|
||||||
|
/tasks/{taskId}/actions/run:
|
||||||
|
post:
|
||||||
|
summary: 推进单个任务
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: accepted
|
||||||
|
/tasks/{taskId}/actions/retry-step:
|
||||||
|
post:
|
||||||
|
summary: 从指定 step 重试
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: accepted
|
||||||
|
/tasks/{taskId}/actions/reset-to-step:
|
||||||
|
post:
|
||||||
|
summary: 重置到指定 step 并重跑
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: taskId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: accepted
|
||||||
|
/worker/run-once:
|
||||||
|
post:
|
||||||
|
summary: 执行一轮 worker
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: accepted
|
||||||
|
/stage/import:
|
||||||
|
post:
|
||||||
|
summary: 从本机已有绝对路径复制到隔离 stage
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: imported
|
||||||
|
/stage/upload:
|
||||||
|
post:
|
||||||
|
summary: 上传文件到隔离 stage
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: uploaded
|
||||||
198
docs/architecture.md
Normal file
198
docs/architecture.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Architecture Style
|
||||||
|
|
||||||
|
采用模块化单体架构。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 当前规模不需要微服务
|
||||||
|
- 需要统一配置、状态和任务模型
|
||||||
|
- 需要较低部署复杂度
|
||||||
|
- 需要明确模块边界和未来插件扩展能力
|
||||||
|
|
||||||
|
## High-Level Layers
|
||||||
|
|
||||||
|
### 1. Core
|
||||||
|
|
||||||
|
核心领域层,不依赖具体外部服务。
|
||||||
|
|
||||||
|
- 领域模型
|
||||||
|
- 状态机
|
||||||
|
- 任务编排接口
|
||||||
|
- 事件定义
|
||||||
|
- 配置模型
|
||||||
|
|
||||||
|
### 2. Modules
|
||||||
|
|
||||||
|
业务模块层,每个模块只关心自己的一步能力。
|
||||||
|
|
||||||
|
- `ingest`
|
||||||
|
- `transcribe`
|
||||||
|
- `song_detect`
|
||||||
|
- `split`
|
||||||
|
- `publish`
|
||||||
|
- `comment`
|
||||||
|
- `collection`
|
||||||
|
|
||||||
|
### 3. Infra
|
||||||
|
|
||||||
|
基础设施层,对外部依赖做适配。
|
||||||
|
|
||||||
|
- 文件系统
|
||||||
|
- SQLite 存储
|
||||||
|
- Groq adapter
|
||||||
|
- Codex adapter
|
||||||
|
- FFmpeg adapter
|
||||||
|
- Bili API adapter
|
||||||
|
- biliup adapter
|
||||||
|
- 日志与审计
|
||||||
|
|
||||||
|
### 4. App
|
||||||
|
|
||||||
|
应用层,对外暴露统一运行入口。
|
||||||
|
|
||||||
|
- API Server
|
||||||
|
- Worker
|
||||||
|
- Scheduler
|
||||||
|
- CLI
|
||||||
|
- Admin Web
|
||||||
|
|
||||||
|
## Proposed Directory Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
biliup-next/
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
api/
|
||||||
|
worker/
|
||||||
|
scheduler/
|
||||||
|
cli/
|
||||||
|
core/
|
||||||
|
models/
|
||||||
|
services/
|
||||||
|
events/
|
||||||
|
state_machine/
|
||||||
|
config/
|
||||||
|
modules/
|
||||||
|
ingest/
|
||||||
|
transcribe/
|
||||||
|
song_detect/
|
||||||
|
split/
|
||||||
|
publish/
|
||||||
|
comment/
|
||||||
|
collection/
|
||||||
|
infra/
|
||||||
|
db/
|
||||||
|
fs/
|
||||||
|
adapters/
|
||||||
|
groq/
|
||||||
|
codex/
|
||||||
|
ffmpeg/
|
||||||
|
bili/
|
||||||
|
biliup/
|
||||||
|
logging/
|
||||||
|
plugins/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Components
|
||||||
|
|
||||||
|
### API Server
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 配置管理
|
||||||
|
- 任务查询
|
||||||
|
- 手动操作
|
||||||
|
- 日志聚合查询
|
||||||
|
- 模块与插件可见性展示
|
||||||
|
|
||||||
|
### Worker
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 消费任务
|
||||||
|
- 推进状态机
|
||||||
|
- 执行模块步骤
|
||||||
|
|
||||||
|
### Scheduler
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 定时扫描待补偿任务
|
||||||
|
- 定时同步外部状态
|
||||||
|
- 触发重试
|
||||||
|
|
||||||
|
## Control Plane
|
||||||
|
|
||||||
|
新系统应明确区分控制面和数据面。
|
||||||
|
|
||||||
|
### Control Plane
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 配置管理
|
||||||
|
- 模块/插件注册
|
||||||
|
- 任务可视化
|
||||||
|
- 手动操作入口
|
||||||
|
- 日志与诊断
|
||||||
|
|
||||||
|
### Data Plane
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 实际执行转录
|
||||||
|
- 实际执行识歌
|
||||||
|
- 实际执行切歌
|
||||||
|
- 实际执行上传
|
||||||
|
- 实际执行评论和合集归档
|
||||||
|
|
||||||
|
## Registry
|
||||||
|
|
||||||
|
系统内部建立统一 registry,用于注册和查找模块能力。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- 当前转录 provider
|
||||||
|
- 当前识歌 provider
|
||||||
|
- 当前上传 provider
|
||||||
|
- 当前合集策略
|
||||||
|
|
||||||
|
核心模块只依赖抽象接口和 registry,不直接依赖具体实现。
|
||||||
|
|
||||||
|
## Task Lifecycle
|
||||||
|
|
||||||
|
```text
|
||||||
|
created
|
||||||
|
-> ingested
|
||||||
|
-> transcribed
|
||||||
|
-> songs_detected
|
||||||
|
-> split_done
|
||||||
|
-> published
|
||||||
|
-> commented
|
||||||
|
-> collection_synced
|
||||||
|
-> completed
|
||||||
|
```
|
||||||
|
|
||||||
|
失败状态不结束任务,而是转入:
|
||||||
|
|
||||||
|
- `failed_retryable`
|
||||||
|
- `failed_manual`
|
||||||
|
|
||||||
|
## Data Ownership
|
||||||
|
|
||||||
|
- SQLite:任务、步骤、产物索引、配置、审计记录
|
||||||
|
- 文件系统:视频、字幕、切片、AI 输出、日志
|
||||||
|
- 外部平台:B 站稿件、评论、合集
|
||||||
|
|
||||||
|
## Key Design Rules
|
||||||
|
|
||||||
|
- 所有状态变更必须落库
|
||||||
|
- 模块间只通过领域对象和事件通信
|
||||||
|
- 外部依赖不可直接在业务模块中调用 shell 或 HTTP
|
||||||
|
- 配置统一由 `core.config` 读取
|
||||||
|
- 管理端展示的数据优先来自数据库,不直接从日志推断
|
||||||
|
- 配置系统必须 schema-first
|
||||||
|
- 插件系统必须 manifest-first
|
||||||
193
docs/config-system.md
Normal file
193
docs/config-system.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Config System
|
||||||
|
|
||||||
|
## Design Goal
|
||||||
|
|
||||||
|
配置系统不是辅助功能,而是新系统的控制面基础设施。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 所有运行参数有统一来源
|
||||||
|
- 配置可被严格校验
|
||||||
|
- 配置可被 UI、CLI、文件三种方式修改
|
||||||
|
- 配置变更可审计
|
||||||
|
- 无效配置不得直接污染运行态
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
借鉴 OpenClaw 的做法,配置系统采用 `schema-first` 设计。
|
||||||
|
|
||||||
|
核心原则:
|
||||||
|
|
||||||
|
- 所有配置项必须先声明在 schema 中
|
||||||
|
- 所有模块只能通过配置服务读取配置
|
||||||
|
- UI 表单由 schema 驱动渲染
|
||||||
|
- 保存前必须校验
|
||||||
|
- 校验失败不允许生效
|
||||||
|
- 配置有版本和变更记录
|
||||||
|
|
||||||
|
## Config Sources
|
||||||
|
|
||||||
|
### 1. Default Config
|
||||||
|
|
||||||
|
系统默认值,由代码内置或默认配置文件提供。
|
||||||
|
|
||||||
|
### 2. Active Config
|
||||||
|
|
||||||
|
当前生效的正式配置。
|
||||||
|
|
||||||
|
建议位置:
|
||||||
|
|
||||||
|
- `biliup-next/config/settings.json`
|
||||||
|
|
||||||
|
### 3. Staged Config
|
||||||
|
|
||||||
|
用户在 UI 或 CLI 中提交、但尚未生效的待校验配置。
|
||||||
|
|
||||||
|
建议位置:
|
||||||
|
|
||||||
|
- `biliup-next/config/settings.staged.json`
|
||||||
|
|
||||||
|
### 4. Schema
|
||||||
|
|
||||||
|
定义配置结构、类型、默认值、枚举范围、校验规则和 UI 元信息。
|
||||||
|
|
||||||
|
建议位置:
|
||||||
|
|
||||||
|
- `biliup-next/config/settings.schema.json`
|
||||||
|
|
||||||
|
## Config Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
User edits config
|
||||||
|
-> Write staged config
|
||||||
|
-> Validate against schema
|
||||||
|
-> Run dependency checks
|
||||||
|
-> If valid: promote to active
|
||||||
|
-> If invalid: keep current active config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Layers
|
||||||
|
|
||||||
|
### 1. Schema Validation
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- 类型
|
||||||
|
- 必填字段
|
||||||
|
- 枚举值
|
||||||
|
- 数值范围
|
||||||
|
- 字段格式
|
||||||
|
|
||||||
|
### 2. Semantic Validation
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- 路径是否存在
|
||||||
|
- 可执行文件是否可用
|
||||||
|
- season id 是否合法
|
||||||
|
- 依赖组合是否冲突
|
||||||
|
|
||||||
|
### 3. Runtime Validation
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- provider 是否可初始化
|
||||||
|
- 凭证是否存在
|
||||||
|
- 外部连接是否可用
|
||||||
|
|
||||||
|
## Suggested Config Groups
|
||||||
|
|
||||||
|
### runtime
|
||||||
|
|
||||||
|
- `workspace_dir`
|
||||||
|
- `database_path`
|
||||||
|
- `log_level`
|
||||||
|
- `scan_interval_seconds`
|
||||||
|
|
||||||
|
### paths
|
||||||
|
|
||||||
|
- `stage_dir`
|
||||||
|
- `backup_dir`
|
||||||
|
- `session_dir`
|
||||||
|
- `cookies_file`
|
||||||
|
- `upload_config_file`
|
||||||
|
|
||||||
|
### ingest
|
||||||
|
|
||||||
|
- `min_duration_seconds`
|
||||||
|
- `allowed_extensions`
|
||||||
|
|
||||||
|
### transcribe
|
||||||
|
|
||||||
|
- `provider`
|
||||||
|
- `groq_api_key`
|
||||||
|
- `max_file_size_mb`
|
||||||
|
- `ffmpeg_bin`
|
||||||
|
|
||||||
|
### song_detect
|
||||||
|
|
||||||
|
- `provider`
|
||||||
|
- `codex_cmd`
|
||||||
|
- `poll_interval_seconds`
|
||||||
|
|
||||||
|
### split
|
||||||
|
|
||||||
|
- `ffmpeg_bin`
|
||||||
|
- `poll_interval_seconds`
|
||||||
|
|
||||||
|
### publish
|
||||||
|
|
||||||
|
- `provider`
|
||||||
|
- `biliup_path`
|
||||||
|
- `cookie_file`
|
||||||
|
- `retry_count`
|
||||||
|
- `retry_schedule_minutes`
|
||||||
|
- `retry_backoff_seconds`
|
||||||
|
|
||||||
|
### comment
|
||||||
|
|
||||||
|
- `enabled`
|
||||||
|
- `max_retries`
|
||||||
|
- `base_delay_seconds`
|
||||||
|
- `poll_interval_seconds`
|
||||||
|
|
||||||
|
### collection
|
||||||
|
|
||||||
|
- `enabled`
|
||||||
|
- `season_id_a`
|
||||||
|
- `season_id_b`
|
||||||
|
- `allow_fuzzy_full_video_match`
|
||||||
|
- `append_collection_a_new_to_end`
|
||||||
|
- `append_collection_b_new_to_end`
|
||||||
|
|
||||||
|
## UI Strategy
|
||||||
|
|
||||||
|
管理台不手写业务表单,而是由 schema 驱动生成配置界面。
|
||||||
|
|
||||||
|
每个配置项除了类型信息,还应有:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `group`
|
||||||
|
- `ui:widget`
|
||||||
|
- `ui:secret`
|
||||||
|
- `ui:order`
|
||||||
|
|
||||||
|
这样可以让配置页面和底层配置保持同源。
|
||||||
|
|
||||||
|
## Audit Trail
|
||||||
|
|
||||||
|
每次配置变更都应记录:
|
||||||
|
|
||||||
|
- 修改人
|
||||||
|
- 修改时间
|
||||||
|
- 修改前值
|
||||||
|
- 修改后值
|
||||||
|
- 校验结果
|
||||||
|
- 是否已生效
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 不追求兼容任意格式的配置文件
|
||||||
|
- 不允许模块私自定义一份独立配置入口
|
||||||
|
- 不允许 UI 和代码维护两套不同的字段定义
|
||||||
476
docs/control-plane-guide.md
Normal file
476
docs/control-plane-guide.md
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
# Control Plane Guide
|
||||||
|
|
||||||
|
本文档面向 `biliup-next` 控制台的日常使用。
|
||||||
|
|
||||||
|
默认地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8787/
|
||||||
|
```
|
||||||
|
|
||||||
|
如果当前机器已经开放公网访问,也可以使用服务器 IP + `8787` 端口访问。
|
||||||
|
|
||||||
|
## 页面分区
|
||||||
|
|
||||||
|
控制台主要分成两列:
|
||||||
|
|
||||||
|
- 左侧:全局状态、服务、动作流、导入入口、任务列表、Settings
|
||||||
|
- 右侧:当前任务详情、步骤、产物、历史、时间线、模块、日志
|
||||||
|
|
||||||
|
建议的使用顺序是:
|
||||||
|
|
||||||
|
1. 先看 `Runtime`
|
||||||
|
2. 再看 `Tasks`
|
||||||
|
3. 选中一个任务后,看右侧详情
|
||||||
|
4. 如果任务异常,再看 `Logs` 和 `History`
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
这里可以看 3 个汇总指标:
|
||||||
|
|
||||||
|
- `Health`
|
||||||
|
- `Doctor`
|
||||||
|
- `Tasks`
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- `Health = OK`
|
||||||
|
- API 服务本身还活着
|
||||||
|
- `Doctor = OK`
|
||||||
|
- 关键路径、二进制和依赖文件都存在
|
||||||
|
- `Tasks`
|
||||||
|
- 当前数据库里的任务数
|
||||||
|
|
||||||
|
如果 `Doctor` 不是 `OK`,优先不要继续点任务操作,先修运行环境。
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
这里可以查看并控制:
|
||||||
|
|
||||||
|
- `biliup-next-worker.service`
|
||||||
|
- `biliup-next-api.service`
|
||||||
|
- 如果还保留旧服务,也可能看到 `biliup-python.service`
|
||||||
|
|
||||||
|
可执行操作:
|
||||||
|
|
||||||
|
- `start`
|
||||||
|
- `restart`
|
||||||
|
- `stop`
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 页面打不开时,先看 `biliup-next-api.service`
|
||||||
|
- 任务不推进时,先看 `biliup-next-worker.service`
|
||||||
|
- 不要随便再启动旧 `biliup-python.service`,除非你明确知道自己要同时跑旧链路
|
||||||
|
|
||||||
|
## Recent Actions
|
||||||
|
|
||||||
|
这里显示最近的控制面动作流,例如:
|
||||||
|
|
||||||
|
- `worker_run_once`
|
||||||
|
- `task_run`
|
||||||
|
- `retry_step`
|
||||||
|
- `reset_to_step`
|
||||||
|
- `stage_import`
|
||||||
|
- `stage_upload`
|
||||||
|
- `service_action`
|
||||||
|
|
||||||
|
可过滤:
|
||||||
|
|
||||||
|
- 仅当前任务
|
||||||
|
- `status`
|
||||||
|
- `action_name`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 判断最近有没有人工操作过任务
|
||||||
|
- 判断任务是不是被重试过
|
||||||
|
- 判断服务是不是被重启过
|
||||||
|
|
||||||
|
## Import To Stage
|
||||||
|
|
||||||
|
这里有两种入口。
|
||||||
|
|
||||||
|
### 1. 复制本机已有文件到隔离 stage
|
||||||
|
|
||||||
|
输入服务器上的绝对路径,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/theshy/video/test.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
点击:
|
||||||
|
|
||||||
|
```text
|
||||||
|
复制到隔离 Stage
|
||||||
|
```
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 服务器本地已有文件
|
||||||
|
- 想快速把已有文件丢进新系统测试
|
||||||
|
|
||||||
|
### 2. 浏览器直接上传文件
|
||||||
|
|
||||||
|
选择本地文件后点击:
|
||||||
|
|
||||||
|
```text
|
||||||
|
上传到隔离 Stage
|
||||||
|
```
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 本地电脑上的测试视频
|
||||||
|
- 不想先手动传到服务器
|
||||||
|
|
||||||
|
上传成功后,`worker` 会自动扫描 `stage` 并开始建任务。
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
这里列出任务列表。
|
||||||
|
|
||||||
|
每个任务会显示:
|
||||||
|
|
||||||
|
- 标题
|
||||||
|
- 当前状态
|
||||||
|
- 任务 ID
|
||||||
|
|
||||||
|
常见状态:
|
||||||
|
|
||||||
|
- `created`
|
||||||
|
- `transcribed`
|
||||||
|
- `songs_detected`
|
||||||
|
- `split_done`
|
||||||
|
- `published`
|
||||||
|
- `commented`
|
||||||
|
- `collection_synced`
|
||||||
|
- `failed_retryable`
|
||||||
|
- `failed_manual`
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 先选中你关心的任务
|
||||||
|
- 再看右侧 `Task Detail / Steps / Logs`
|
||||||
|
|
||||||
|
## Task Detail
|
||||||
|
|
||||||
|
显示当前选中任务的核心信息:
|
||||||
|
|
||||||
|
- `Task ID`
|
||||||
|
- `Status`
|
||||||
|
- `Title`
|
||||||
|
- `Source`
|
||||||
|
- `Created`
|
||||||
|
- `Updated`
|
||||||
|
|
||||||
|
上方有 3 个操作按钮:
|
||||||
|
|
||||||
|
- `执行当前任务`
|
||||||
|
- `重试选中 Step`
|
||||||
|
- `重置到选中 Step`
|
||||||
|
|
||||||
|
### 执行当前任务
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 手动推进当前任务一次
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 你刚改完配置
|
||||||
|
- 你不想等下一轮 worker 轮询
|
||||||
|
|
||||||
|
### 重试选中 Step
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 从当前选中的 step 重新尝试
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 某一步是临时失败
|
||||||
|
- 不需要删除后续产物
|
||||||
|
|
||||||
|
### 重置到选中 Step
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 清理该 step 之后的产物和标记
|
||||||
|
- 把任务回拨到该 step
|
||||||
|
- 然后重新执行
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 后续结果已经不可信
|
||||||
|
- 需要从某一步重新跑整段链路
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 这是破坏性动作
|
||||||
|
- 页面会要求确认
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
这里显示任务步骤列表,例如:
|
||||||
|
|
||||||
|
- `ingest`
|
||||||
|
- `transcribe`
|
||||||
|
- `song_detect`
|
||||||
|
- `split`
|
||||||
|
- `publish`
|
||||||
|
- `comment`
|
||||||
|
- `collection_a`
|
||||||
|
- `collection_b`
|
||||||
|
|
||||||
|
点击某个 step 之后:
|
||||||
|
|
||||||
|
- 这个 step 会成为“当前选中 step”
|
||||||
|
- 然后你就可以点:
|
||||||
|
- `重试选中 Step`
|
||||||
|
- `重置到选中 Step`
|
||||||
|
|
||||||
|
排查原则:
|
||||||
|
|
||||||
|
- `transcribe` 失败:先看 `Groq API Key`、`ffmpeg`
|
||||||
|
- `song_detect` 失败:先看 `codex_cmd`
|
||||||
|
- `publish` 失败:先看 `cookies.json`、`biliup`
|
||||||
|
- `collection_*` 失败:再看任务历史和日志
|
||||||
|
|
||||||
|
评论规则补充:
|
||||||
|
|
||||||
|
- `comment`
|
||||||
|
- 纯享版视频下默认发“编号歌单”,不带时间轴
|
||||||
|
- 完整版主视频下默认才发“带时间轴评论”
|
||||||
|
- 如果当前任务找不到 `full_video_bvid.txt`,也没能从最近发布列表解析出完整版 BV,主视频评论会跳过
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
这里显示任务当前已经产出的文件,例如:
|
||||||
|
|
||||||
|
- `source_video`
|
||||||
|
- `subtitle_srt`
|
||||||
|
- `songs_json`
|
||||||
|
- `songs_txt`
|
||||||
|
- `clip_video`
|
||||||
|
- `publish_bvid`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 判断任务跑到了哪一步
|
||||||
|
- 判断关键输出是否已经落盘
|
||||||
|
|
||||||
|
如果开启了 cleanup 配置,任务在 `collection_synced` 后:
|
||||||
|
|
||||||
|
- `source_video` 对应的原始视频可能会被删除
|
||||||
|
- `clip_video` 对应的 `split_video/` 目录也可能被清理
|
||||||
|
|
||||||
|
这是正常收尾行为,不代表任务失败。
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
这里是单任务动作历史。
|
||||||
|
|
||||||
|
和 `Recent Actions` 的区别:
|
||||||
|
|
||||||
|
- `Recent Actions` 看全局
|
||||||
|
- `History` 只看当前任务
|
||||||
|
|
||||||
|
可以看到:
|
||||||
|
|
||||||
|
- 动作名
|
||||||
|
- 状态
|
||||||
|
- 摘要
|
||||||
|
- 时间
|
||||||
|
- 结构化 `details_json`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 判断某次 `retry` 或 `reset` 的结果
|
||||||
|
- 判断 worker 最近对这个任务做了什么
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
|
||||||
|
这里把任务事件串成一条时间线:
|
||||||
|
|
||||||
|
- `Task Created`
|
||||||
|
- step started / finished
|
||||||
|
- artifact created
|
||||||
|
- action records
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 回放任务完整过程
|
||||||
|
- 查清楚任务到底卡在什么时候
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
这里显示当前注册的模块 / provider。
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 确认当前用的是哪套 provider
|
||||||
|
- 确认模块有没有注册成功
|
||||||
|
|
||||||
|
如果将来切 provider,这里会很有用。
|
||||||
|
|
||||||
|
## Doctor Checks
|
||||||
|
|
||||||
|
这里比顶部的 `Doctor = OK/FAIL` 更详细。
|
||||||
|
|
||||||
|
会列出:
|
||||||
|
|
||||||
|
- workspace 目录
|
||||||
|
- `cookies_file`
|
||||||
|
- `upload_config_file`
|
||||||
|
- `ffprobe`
|
||||||
|
- `ffmpeg`
|
||||||
|
- `codex_cmd`
|
||||||
|
- `biliup_path`
|
||||||
|
|
||||||
|
如果某个依赖显示 `(external)`,表示它还在用系统或父项目路径,不是 `biliup-next` 自己目录内的副本。
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
这里可以看日志文件。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
- 切换日志文件
|
||||||
|
- 刷新日志
|
||||||
|
- 按当前任务标题过滤
|
||||||
|
|
||||||
|
使用建议:
|
||||||
|
|
||||||
|
- 任务异常时,先选中任务
|
||||||
|
- 再勾选“按当前任务标题过滤”
|
||||||
|
- 然后查看相关日志
|
||||||
|
|
||||||
|
这样比直接翻整份日志快很多。
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Settings 分成两层:
|
||||||
|
|
||||||
|
- 上半部分:schema 驱动表单
|
||||||
|
- 下半部分:`Advanced JSON Editor`
|
||||||
|
|
||||||
|
### 表单区
|
||||||
|
|
||||||
|
这里适合日常参数调整,例如:
|
||||||
|
|
||||||
|
- `min_duration_seconds`
|
||||||
|
- `groq_api_key`
|
||||||
|
- `codex_cmd`
|
||||||
|
- `retry_count`
|
||||||
|
- `season_id_a`
|
||||||
|
- `season_id_b`
|
||||||
|
- `post_split_comment`
|
||||||
|
- `post_full_video_timeline_comment`
|
||||||
|
- `delete_source_video_after_collection_synced`
|
||||||
|
- `delete_split_videos_after_collection_synced`
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
- 搜索配置项
|
||||||
|
- 高频参数优先展示
|
||||||
|
- 低频参数收纳到 `Advanced Settings`
|
||||||
|
|
||||||
|
### Advanced JSON Editor
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 批量调整
|
||||||
|
- 一次改多个字段
|
||||||
|
- 需要直接编辑原始 JSON
|
||||||
|
|
||||||
|
页面上有两个同步按钮:
|
||||||
|
|
||||||
|
- `表单同步到 JSON`
|
||||||
|
- `JSON 重绘表单`
|
||||||
|
|
||||||
|
### 敏感字段规则
|
||||||
|
|
||||||
|
敏感字段会显示为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
__BILIUP_NEXT_SECRET__
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 保留占位符:不改原值
|
||||||
|
- 改成空字符串:清空原值
|
||||||
|
- 改成新的字符串:更新为新值
|
||||||
|
|
||||||
|
## 推荐操作流
|
||||||
|
|
||||||
|
### 新上传一个测试视频
|
||||||
|
|
||||||
|
1. 打开控制台
|
||||||
|
2. 在 `Import To Stage` 上传视频
|
||||||
|
3. 看 `Tasks` 是否出现新任务
|
||||||
|
4. 选中该任务
|
||||||
|
5. 观察 `Steps / Artifacts / Timeline`
|
||||||
|
6. 如需加速,点击 `执行当前任务`
|
||||||
|
|
||||||
|
### 某个任务失败
|
||||||
|
|
||||||
|
1. 选中任务
|
||||||
|
2. 看 `Task Detail` 当前状态
|
||||||
|
3. 看 `Steps` 哪一步失败
|
||||||
|
4. 看 `Logs`
|
||||||
|
5. 若只是临时失败:
|
||||||
|
- 选中 step
|
||||||
|
- 点 `重试选中 Step`
|
||||||
|
6. 若后续产物也需要重建:
|
||||||
|
- 选中 step
|
||||||
|
- 点 `重置到选中 Step`
|
||||||
|
|
||||||
|
### 修改合集或上传参数
|
||||||
|
|
||||||
|
1. 打开 `Settings`
|
||||||
|
2. 搜索目标参数,例如 `season` / `retry`
|
||||||
|
3. 修改表单
|
||||||
|
4. 点击 `保存 Settings`
|
||||||
|
5. 对目标任务执行:
|
||||||
|
- `执行当前任务`
|
||||||
|
- 或 `重置到相关 step`
|
||||||
|
|
||||||
|
### 控制评论与清理行为
|
||||||
|
|
||||||
|
1. 打开 `Settings`
|
||||||
|
2. 搜索:
|
||||||
|
- `post_split_comment`
|
||||||
|
- `post_full_video_timeline_comment`
|
||||||
|
- `delete_source_video_after_collection_synced`
|
||||||
|
- `delete_split_videos_after_collection_synced`
|
||||||
|
3. 修改后保存
|
||||||
|
4. 新任务会按新规则执行
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 如果你希望纯享版评论更适合分P浏览,保持 `post_split_comment = true`
|
||||||
|
- 如果你不希望尝试给完整版主视频发时间轴评论,可以关闭 `post_full_video_timeline_comment`
|
||||||
|
- 如果磁盘紧张,再开启 cleanup;默认建议先关闭,等确认流程稳定后再开
|
||||||
|
|
||||||
|
### 服务异常
|
||||||
|
|
||||||
|
1. 看 `Services`
|
||||||
|
2. 优先 `restart biliup-next-worker.service`
|
||||||
|
3. 如果页面自身异常,再 `restart biliup-next-api.service`
|
||||||
|
4. 重启后看:
|
||||||
|
- `Health`
|
||||||
|
- `Doctor`
|
||||||
|
- `Recent Actions`
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
当前控制台已经对公网开放时,建议立刻设置:
|
||||||
|
|
||||||
|
- `runtime.control_token`
|
||||||
|
|
||||||
|
设置后:
|
||||||
|
|
||||||
|
- 除 `/` 和 `/health` 外,其余 API 都要求 `X-Biliup-Token`
|
||||||
|
|
||||||
|
如果你通过公网访问控制台,不建议长期保持空 token。
|
||||||
238
docs/design-principles.md
Normal file
238
docs/design-principles.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# Design Principles
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
`biliup-next` 以 OpenClaw 的设计哲学为指引,但不复制它的产品形态。
|
||||||
|
|
||||||
|
本项目的核心目标不是做聊天代理系统,而是构建一个面向本地视频流水线的控制面驱动系统。
|
||||||
|
|
||||||
|
因此我们借鉴的是方法论:
|
||||||
|
|
||||||
|
- 单体优先
|
||||||
|
- 控制面优先
|
||||||
|
- 配置与扩展元数据优先
|
||||||
|
- 严格校验
|
||||||
|
- 本地优先
|
||||||
|
- 可读、可审计、可替换
|
||||||
|
|
||||||
|
## Principle 1: Modular Monolith First
|
||||||
|
|
||||||
|
系统优先采用模块化单体架构,而不是微服务。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 当前问题主要来自边界混乱,而不是部署扩展性不足
|
||||||
|
- 单机部署和 systemd 管理仍然是核心场景
|
||||||
|
- 统一配置、任务状态和日志比进程拆分更重要
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 所有模块运行在同一系统边界内
|
||||||
|
- 模块之间通过抽象接口和统一模型交互
|
||||||
|
- 不得通过随意脚本调用形成隐式耦合
|
||||||
|
|
||||||
|
## Principle 2: Control Plane First
|
||||||
|
|
||||||
|
功能模块不是系统中心,控制面才是系统中心。
|
||||||
|
|
||||||
|
控制面负责:
|
||||||
|
|
||||||
|
- 配置管理
|
||||||
|
- 任务状态管理
|
||||||
|
- 模块与插件注册
|
||||||
|
- 手动操作入口
|
||||||
|
- 日志与诊断
|
||||||
|
|
||||||
|
数据面负责:
|
||||||
|
|
||||||
|
- 执行转录
|
||||||
|
- 执行识歌
|
||||||
|
- 执行切歌
|
||||||
|
- 执行上传
|
||||||
|
- 执行评论和合集归档
|
||||||
|
|
||||||
|
任何新增功能,都必须先回答:
|
||||||
|
|
||||||
|
- 它如何进入控制面
|
||||||
|
- 它的状态如何呈现
|
||||||
|
- 它的配置如何管理
|
||||||
|
- 它失败后如何恢复
|
||||||
|
|
||||||
|
## Principle 3: Schema-First Configuration
|
||||||
|
|
||||||
|
配置必须先有 schema,再有实现和 UI。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 所有配置项先定义在 schema
|
||||||
|
- 所有配置项有默认值、校验规则和说明
|
||||||
|
- UI 基于 schema 生成
|
||||||
|
- CLI 和 API 使用同一套字段定义
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 在模块里私自增加隐藏配置常量
|
||||||
|
- UI 和代码维护不同字段名
|
||||||
|
- 配置错误仍然带病启动
|
||||||
|
|
||||||
|
## Principle 4: Manifest-First Extensibility
|
||||||
|
|
||||||
|
扩展能力先注册元数据,再执行运行时代码。
|
||||||
|
|
||||||
|
manifest 负责描述:
|
||||||
|
|
||||||
|
- 插件是谁
|
||||||
|
- 提供什么能力
|
||||||
|
- 需要什么配置
|
||||||
|
- 入口在哪
|
||||||
|
- 是否可启用
|
||||||
|
|
||||||
|
这样控制面可以在不执行插件代码时完成:
|
||||||
|
|
||||||
|
- 能力发现
|
||||||
|
- 配置渲染
|
||||||
|
- 可用性检查
|
||||||
|
- 兼容性检查
|
||||||
|
|
||||||
|
## Principle 5: Registry Over Direct Coupling
|
||||||
|
|
||||||
|
模块和插件必须通过统一 registry 接入。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- transcriber registry
|
||||||
|
- song detector registry
|
||||||
|
- publisher registry
|
||||||
|
- collection strategy registry
|
||||||
|
|
||||||
|
核心模块只依赖接口,不依赖具体 provider。
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 更换 Groq 为其他转录器不影响任务引擎
|
||||||
|
- 更换 Codex 为其他识歌器不影响控制面
|
||||||
|
- 更换 biliup 为其他上传方式不影响领域模型
|
||||||
|
|
||||||
|
## Principle 6: Local-First And Human-Readable
|
||||||
|
|
||||||
|
系统优先本地运行,本地保存,本地可读。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 主状态存储可本地访问
|
||||||
|
- 日志保存在本地
|
||||||
|
- 配置保存在本地
|
||||||
|
- 关键元数据可被开发者直接理解
|
||||||
|
|
||||||
|
这不意味着只靠文件系统。
|
||||||
|
|
||||||
|
建议做法:
|
||||||
|
|
||||||
|
- SQLite 保存结构化状态
|
||||||
|
- 文件系统保存产物
|
||||||
|
- JSON / YAML 保存配置
|
||||||
|
- 文本日志保存审计和错误
|
||||||
|
|
||||||
|
## Principle 7: Strict Validation
|
||||||
|
|
||||||
|
系统不能接受“差不多能跑”的配置和模块状态。
|
||||||
|
|
||||||
|
启动或配置变更前,应验证:
|
||||||
|
|
||||||
|
- schema 是否合法
|
||||||
|
- 可执行依赖是否存在
|
||||||
|
- 必要凭证是否存在
|
||||||
|
- provider 是否可初始化
|
||||||
|
- 插件 manifest 是否正确
|
||||||
|
|
||||||
|
失败时:
|
||||||
|
|
||||||
|
- 保留旧运行态
|
||||||
|
- 返回明确错误
|
||||||
|
- 不进入半失效状态
|
||||||
|
|
||||||
|
## Principle 8: Single Source Of Truth
|
||||||
|
|
||||||
|
任务状态必须有统一来源。
|
||||||
|
|
||||||
|
不得同时依赖:
|
||||||
|
|
||||||
|
- flag 文件推断状态
|
||||||
|
- 日志推断状态
|
||||||
|
- 目录结构推断状态
|
||||||
|
|
||||||
|
正确做法:
|
||||||
|
|
||||||
|
- 数据库记录任务状态
|
||||||
|
- 文件系统存放任务产物
|
||||||
|
- 日志记录过程和诊断
|
||||||
|
|
||||||
|
三者职责分离,不互相替代。
|
||||||
|
|
||||||
|
## Principle 9: Replaceability With Stable Core
|
||||||
|
|
||||||
|
可替换的是 provider,不可随意漂移的是核心模型。
|
||||||
|
|
||||||
|
稳定核心包括:
|
||||||
|
|
||||||
|
- Task
|
||||||
|
- TaskStep
|
||||||
|
- Artifact
|
||||||
|
- PublishRecord
|
||||||
|
- CollectionBinding
|
||||||
|
- Settings
|
||||||
|
|
||||||
|
这些模型一旦定义,应保持长期稳定,避免每新增一个模块就改核心语义。
|
||||||
|
|
||||||
|
## Principle 10: Observability Is A First-Class Feature
|
||||||
|
|
||||||
|
可观测性不是补丁,而是正式能力。
|
||||||
|
|
||||||
|
管理台必须能够回答:
|
||||||
|
|
||||||
|
- 当前有哪些任务
|
||||||
|
- 每个任务在哪一步
|
||||||
|
- 最近一次失败是什么
|
||||||
|
- 当前启用的 provider 是谁
|
||||||
|
- 当前配置是否有效
|
||||||
|
- 哪个模块健康异常
|
||||||
|
|
||||||
|
如果系统不能快速回答这些问题,说明设计不完整。
|
||||||
|
|
||||||
|
## Principle 11: Backward-Compatible Migration
|
||||||
|
|
||||||
|
重构必须与旧系统并行推进。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 原项目继续运行
|
||||||
|
- 新项目只在 `./biliup-next` 演进
|
||||||
|
- 先搭文档和骨架
|
||||||
|
- 再逐步迁移模块
|
||||||
|
- 最后切换生产入口
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 直接在旧系统里边修边重构
|
||||||
|
- 在未定义状态模型前大规模搬代码
|
||||||
|
|
||||||
|
## Principle 12: Documentation Before Expansion
|
||||||
|
|
||||||
|
任何新的模块、插件、控制面能力,在动手实现前都要先回答:
|
||||||
|
|
||||||
|
- 它属于哪个层
|
||||||
|
- 它的输入输出是什么
|
||||||
|
- 它如何配置
|
||||||
|
- 它如何注册
|
||||||
|
- 它如何被 UI 展示
|
||||||
|
- 它如何失败和恢复
|
||||||
|
|
||||||
|
如果这些问题没有写清楚,就不应进入实现阶段。
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`biliup-next` 的核心方向不是“把旧脚本改漂亮”,而是:
|
||||||
|
|
||||||
|
- 建立一个本地优先、控制面驱动、模块边界清晰的系统
|
||||||
|
- 让配置、模块、状态、操作和诊断都回到统一模型之下
|
||||||
|
- 让未来扩展建立在 schema、manifest、registry 和稳定领域模型之上
|
||||||
135
docs/domain-model.md
Normal file
135
docs/domain-model.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
# Domain Model
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
一个任务代表一条完整的视频处理链路。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "task_01",
|
||||||
|
"source_type": "local_file",
|
||||||
|
"source_path": "stage/example.mp4",
|
||||||
|
"title": "王海颖唱歌录播 03月29日 22时02分",
|
||||||
|
"status": "published",
|
||||||
|
"created_at": "2026-03-30T07:50:42+08:00",
|
||||||
|
"updated_at": "2026-03-30T07:56:13+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
- `id`: 内部唯一 ID
|
||||||
|
- `source_type`: 输入来源,例如 `local_file`
|
||||||
|
- `source_path`: 原始文件路径
|
||||||
|
- `title`: 任务显示名称
|
||||||
|
- `status`: 当前状态
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
## TaskStep
|
||||||
|
|
||||||
|
一个任务中的单个处理步骤。
|
||||||
|
|
||||||
|
### Step Names
|
||||||
|
|
||||||
|
- `ingest`
|
||||||
|
- `transcribe`
|
||||||
|
- `song_detect`
|
||||||
|
- `split`
|
||||||
|
- `publish`
|
||||||
|
- `comment`
|
||||||
|
- `collection_a`
|
||||||
|
- `collection_b`
|
||||||
|
|
||||||
|
### Step Status
|
||||||
|
|
||||||
|
- `pending`
|
||||||
|
- `running`
|
||||||
|
- `succeeded`
|
||||||
|
- `failed_retryable`
|
||||||
|
- `failed_manual`
|
||||||
|
- `skipped`
|
||||||
|
|
||||||
|
## Artifact
|
||||||
|
|
||||||
|
任务产物。
|
||||||
|
|
||||||
|
### Artifact Types
|
||||||
|
|
||||||
|
- `source_video`
|
||||||
|
- `subtitle_srt`
|
||||||
|
- `songs_json`
|
||||||
|
- `songs_txt`
|
||||||
|
- `clip_video`
|
||||||
|
- `publish_bvid`
|
||||||
|
- `comment_record`
|
||||||
|
- `collection_record`
|
||||||
|
|
||||||
|
## PublishRecord
|
||||||
|
|
||||||
|
记录上传结果。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "task_01",
|
||||||
|
"platform": "bilibili",
|
||||||
|
"aid": 123456,
|
||||||
|
"bvid": "BV1xxxx",
|
||||||
|
"title": "【王海颖 (歌曲纯享版)】_03月29日 22时02分 共18首歌",
|
||||||
|
"published_at": "2026-03-30T07:56:13+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CollectionBinding
|
||||||
|
|
||||||
|
记录视频与合集之间的绑定关系。
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
- `task_id`
|
||||||
|
- `target`
|
||||||
|
- `season_id`
|
||||||
|
- `section_id`
|
||||||
|
- `bvid`
|
||||||
|
- `status`
|
||||||
|
- `last_error`
|
||||||
|
|
||||||
|
### Target Values
|
||||||
|
|
||||||
|
- `full_video_collection`
|
||||||
|
- `song_collection`
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
统一配置项,按逻辑分组。
|
||||||
|
|
||||||
|
### Example Groups
|
||||||
|
|
||||||
|
- `runtime`
|
||||||
|
- `paths`
|
||||||
|
- `transcribe`
|
||||||
|
- `song_detect`
|
||||||
|
- `publish`
|
||||||
|
- `comment`
|
||||||
|
- `collection`
|
||||||
|
|
||||||
|
## Domain Events
|
||||||
|
|
||||||
|
### Core Events
|
||||||
|
|
||||||
|
- `TaskCreated`
|
||||||
|
- `TaskStepStarted`
|
||||||
|
- `TaskStepSucceeded`
|
||||||
|
- `TaskStepFailed`
|
||||||
|
- `ArtifactCreated`
|
||||||
|
- `PublishCompleted`
|
||||||
|
- `CommentCompleted`
|
||||||
|
- `CollectionSynced`
|
||||||
|
|
||||||
|
## State Machine Rules
|
||||||
|
|
||||||
|
- 同一时刻,一个步骤只能有一个 `running`
|
||||||
|
- 失败必须记录 `error_code` 和 `error_message`
|
||||||
|
- `published` 之前不能进入评论和合集步骤
|
||||||
|
- `songs_detected` 之前不能进入切歌步骤
|
||||||
|
- `transcribed` 之前不能进入识歌步骤
|
||||||
192
docs/migration-plan.md
Normal file
192
docs/migration-plan.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# Migration Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在不破坏原项目运行的前提下,逐步将能力迁移到 `biliup-next`。
|
||||||
|
|
||||||
|
## Migration Principles
|
||||||
|
|
||||||
|
- 原项目继续作为生产系统运行
|
||||||
|
- 新项目只在 `./biliup-next` 中演进
|
||||||
|
- 先文档、后骨架、再迁移功能
|
||||||
|
- 先控制面,后数据面
|
||||||
|
- 先兼容旧目录结构,再逐步替换旧入口
|
||||||
|
|
||||||
|
## Phase 0: Documentation Baseline
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 明确设计原则
|
||||||
|
- 明确架构分层
|
||||||
|
- 明确领域模型
|
||||||
|
- 明确配置系统和插件系统
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
- `vision.md`
|
||||||
|
- `architecture.md`
|
||||||
|
- `domain-model.md`
|
||||||
|
- `design-principles.md`
|
||||||
|
- `config-system.md`
|
||||||
|
- `plugin-system.md`
|
||||||
|
- `state-machine.md`
|
||||||
|
- `module-contracts.md`
|
||||||
|
- `migration-plan.md`
|
||||||
|
|
||||||
|
## Phase 1: Project Skeleton
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 建立 `biliup-next/src` 目录结构
|
||||||
|
- 建立基础 Python 包
|
||||||
|
- 建立最小配置系统
|
||||||
|
- 建立 SQLite 存储层
|
||||||
|
- 建立任务模型和状态模型
|
||||||
|
|
||||||
|
不做:
|
||||||
|
|
||||||
|
- 不迁移业务逻辑
|
||||||
|
- 不接管生产入口
|
||||||
|
|
||||||
|
## Phase 2: Control Plane MVP
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 提供最小 API
|
||||||
|
- 提供任务列表和配置读取能力
|
||||||
|
- 提供最小 CLI
|
||||||
|
- 提供 health / logs / settings / tasks 查询能力
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
- API server
|
||||||
|
- config service
|
||||||
|
- task repository
|
||||||
|
- runtime doctor
|
||||||
|
|
||||||
|
## Phase 3: Data Plane Adapters
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 把旧系统依赖的外部能力封装成 adapter
|
||||||
|
|
||||||
|
优先顺序:
|
||||||
|
|
||||||
|
1. `ffmpeg` adapter
|
||||||
|
2. `Groq` adapter
|
||||||
|
3. `Codex` adapter
|
||||||
|
4. `biliup` adapter
|
||||||
|
5. `Bili API` adapter
|
||||||
|
|
||||||
|
理由:
|
||||||
|
|
||||||
|
- 先封装外部依赖,后迁移业务模块,能减少后续反复返工
|
||||||
|
|
||||||
|
## Phase 4: Module Migration
|
||||||
|
|
||||||
|
按顺序迁移业务模块。
|
||||||
|
|
||||||
|
### 4.1 Ingest
|
||||||
|
|
||||||
|
- 替代 `monitor.py` 的任务创建部分
|
||||||
|
|
||||||
|
### 4.2 Transcribe
|
||||||
|
|
||||||
|
- 替代 `video2srt.py`
|
||||||
|
|
||||||
|
### 4.3 Song Detect
|
||||||
|
|
||||||
|
- 替代 `monitorSrt.py`
|
||||||
|
|
||||||
|
### 4.4 Split
|
||||||
|
|
||||||
|
- 替代 `monitorSongs.py`
|
||||||
|
|
||||||
|
### 4.5 Publish
|
||||||
|
|
||||||
|
- 替代 `upload.py`
|
||||||
|
|
||||||
|
### 4.6 Comment
|
||||||
|
|
||||||
|
- 替代 `session_top_comment.py`
|
||||||
|
|
||||||
|
### 4.7 Collection
|
||||||
|
|
||||||
|
- 替代 `add_to_collection.py`
|
||||||
|
|
||||||
|
## Phase 5: Parallel Verification
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 新旧系统并行验证
|
||||||
|
- 新系统只处理测试任务
|
||||||
|
- 对比产物、日志、状态和 B 站结果
|
||||||
|
|
||||||
|
重点检查:
|
||||||
|
|
||||||
|
- 字幕结果
|
||||||
|
- 歌曲识别结果
|
||||||
|
- 切片结果
|
||||||
|
- 上传结果
|
||||||
|
- 评论和合集结果
|
||||||
|
|
||||||
|
## Phase 6: Admin UI
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 构建本地控制台
|
||||||
|
|
||||||
|
第一版包含:
|
||||||
|
|
||||||
|
- 配置页
|
||||||
|
- 任务页
|
||||||
|
- 模块页
|
||||||
|
- 日志页
|
||||||
|
- 手动操作页
|
||||||
|
|
||||||
|
## Phase 7: Cutover
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 新系统逐步接管生产入口
|
||||||
|
|
||||||
|
顺序建议:
|
||||||
|
|
||||||
|
1. 只接管任务可视化
|
||||||
|
2. 接管配置管理
|
||||||
|
3. 接管测试任务处理
|
||||||
|
4. 接管单模块生产流量
|
||||||
|
5. 最终接管全部生产流量
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
### Risk 1: 旧系统与新系统语义不一致
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 先定义领域模型和状态机
|
||||||
|
- 迁移前写适配层,不直接照抄旧脚本行为
|
||||||
|
|
||||||
|
### Risk 2: 边迁移边污染旧项目
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 所有新内容只放在 `./biliup-next`
|
||||||
|
- 不改原项目运行入口
|
||||||
|
|
||||||
|
### Risk 3: UI 先行导致底层不稳
|
||||||
|
|
||||||
|
缓解:
|
||||||
|
|
||||||
|
- 先做控制面模型和 API
|
||||||
|
- 最后做 UI
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
迁移完成的标准不是“代码搬完”,而是:
|
||||||
|
|
||||||
|
- 新系统可以独立运行
|
||||||
|
- 配置统一管理
|
||||||
|
- 状态统一落库
|
||||||
|
- UI 可以完整观察任务
|
||||||
|
- 旧脚本不再是主入口
|
||||||
229
docs/module-contracts.md
Normal file
229
docs/module-contracts.md
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
# Module Contracts
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
定义各模块的职责边界、输入输出和契约,避免旧系统中“脚本互相读目录、互相猜状态”的耦合方式。
|
||||||
|
|
||||||
|
## Contract Principles
|
||||||
|
|
||||||
|
- 每个模块只处理一类能力
|
||||||
|
- 模块只接收明确输入,不扫描全世界
|
||||||
|
- 模块输出必须结构化
|
||||||
|
- 模块不直接操控其他模块的内部实现
|
||||||
|
- 模块不直接依赖具体 provider
|
||||||
|
|
||||||
|
## Shared Concepts
|
||||||
|
|
||||||
|
所有模块统一围绕这些对象协作:
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `TaskStep`
|
||||||
|
- `Artifact`
|
||||||
|
- `Settings`
|
||||||
|
- `ProviderRef`
|
||||||
|
|
||||||
|
## Ingest Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 接收文件输入
|
||||||
|
- 校验最小处理条件
|
||||||
|
- 创建任务
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- 本地文件路径
|
||||||
|
- 当前配置
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `source_video` artifact
|
||||||
|
|
||||||
|
### Must Not Do
|
||||||
|
|
||||||
|
- 不直接转录
|
||||||
|
- 不写上传状态
|
||||||
|
|
||||||
|
## Transcribe Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 调用转录 provider
|
||||||
|
- 生成字幕产物
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `source_video` artifact
|
||||||
|
- `transcribe` settings
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- `subtitle_srt` artifact
|
||||||
|
|
||||||
|
### Must Not Do
|
||||||
|
|
||||||
|
- 不识别歌曲
|
||||||
|
- 不决定切歌策略
|
||||||
|
|
||||||
|
## Song Detect Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 根据字幕识别歌曲
|
||||||
|
- 生成歌曲结构化结果
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `subtitle_srt` artifact
|
||||||
|
- `song_detect` settings
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- `songs_json` artifact
|
||||||
|
- `songs_txt` artifact
|
||||||
|
|
||||||
|
### Must Not Do
|
||||||
|
|
||||||
|
- 不切歌
|
||||||
|
- 不上传
|
||||||
|
|
||||||
|
## Split Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 根据歌曲列表切割纯享版片段
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `songs_json` artifact
|
||||||
|
- `source_video` artifact
|
||||||
|
- `split` settings
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- 多个 `clip_video` artifact
|
||||||
|
|
||||||
|
## Publish Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 上传纯享版视频
|
||||||
|
- 记录发布结果
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `clip_video[]`
|
||||||
|
- `publish` settings
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- `PublishRecord`
|
||||||
|
- `publish_bvid` artifact
|
||||||
|
|
||||||
|
### Must Not Do
|
||||||
|
|
||||||
|
- 不负责评论文案生成
|
||||||
|
- 不负责合集匹配策略
|
||||||
|
|
||||||
|
## Comment Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 发布并置顶评论
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `PublishRecord`
|
||||||
|
- `songs_txt` artifact
|
||||||
|
- `comment` settings
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- `comment_record` artifact
|
||||||
|
|
||||||
|
## Collection Module
|
||||||
|
|
||||||
|
### Responsibility
|
||||||
|
|
||||||
|
- 根据策略同步合集 A / B
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- `Task`
|
||||||
|
- `PublishRecord` 或外部 `full_video_bvid`
|
||||||
|
- `collection` settings
|
||||||
|
- `collection strategy`
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- `CollectionBinding`
|
||||||
|
|
||||||
|
### Internal Sub-Strategies
|
||||||
|
|
||||||
|
- `full_video_collection_strategy`
|
||||||
|
- `song_collection_strategy`
|
||||||
|
|
||||||
|
## Provider Contracts
|
||||||
|
|
||||||
|
### TranscribeProvider
|
||||||
|
|
||||||
|
```text
|
||||||
|
transcribe(task, source_video, settings) -> subtitle_srt
|
||||||
|
```
|
||||||
|
|
||||||
|
### SongDetector
|
||||||
|
|
||||||
|
```text
|
||||||
|
detect(task, subtitle_srt, settings) -> songs_json, songs_txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### PublishProvider
|
||||||
|
|
||||||
|
```text
|
||||||
|
publish(task, clip_videos, settings) -> PublishRecord
|
||||||
|
```
|
||||||
|
|
||||||
|
### CommentStrategy
|
||||||
|
|
||||||
|
```text
|
||||||
|
sync_comment(task, publish_record, songs_txt, settings) -> comment_record
|
||||||
|
```
|
||||||
|
|
||||||
|
### CollectionStrategy
|
||||||
|
|
||||||
|
```text
|
||||||
|
sync_collection(task, context, settings) -> CollectionBinding[]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Orchestration Rules
|
||||||
|
|
||||||
|
模块本身不负责全局编排。
|
||||||
|
|
||||||
|
全局编排由任务引擎或 worker 负责:
|
||||||
|
|
||||||
|
- 判断下一步该跑什么
|
||||||
|
- 决定是否重试
|
||||||
|
- 写入状态
|
||||||
|
- 调度具体模块
|
||||||
|
|
||||||
|
## Error Contract
|
||||||
|
|
||||||
|
所有模块失败时应返回统一错误结构:
|
||||||
|
|
||||||
|
- `code`
|
||||||
|
- `message`
|
||||||
|
- `retryable`
|
||||||
|
- `details`
|
||||||
|
|
||||||
|
不得只返回原始字符串日志作为唯一错误结果。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 模块之间不共享私有目录扫描逻辑
|
||||||
|
- 模块契约不直接暴露 shell 命令细节
|
||||||
156
docs/plugin-system.md
Normal file
156
docs/plugin-system.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# Plugin System
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
插件系统的目标不是“让任何东西都能热插拔”,而是为未来的能力替换和扩展提供稳定边界。
|
||||||
|
|
||||||
|
优先支持:
|
||||||
|
|
||||||
|
- 转录提供者替换
|
||||||
|
- 歌曲识别提供者替换
|
||||||
|
- 上传器替换
|
||||||
|
- 评论策略替换
|
||||||
|
- 合集策略替换
|
||||||
|
- 输入源扩展
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
借鉴 OpenClaw 的思路,采用 `manifest-first` + `registry` 设计。
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- 插件先注册元信息,再执行运行时代码
|
||||||
|
- 控制面优先读取 manifest 和 schema
|
||||||
|
- 核心系统只依赖抽象接口和 registry
|
||||||
|
- 插件配置必须可校验
|
||||||
|
|
||||||
|
## Plugin Composition
|
||||||
|
|
||||||
|
每个插件由两部分组成:
|
||||||
|
|
||||||
|
### 1. Manifest
|
||||||
|
|
||||||
|
描述插件的元信息和配置能力。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "codex-song-detector",
|
||||||
|
"name": "Codex Song Detector",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "song_detector",
|
||||||
|
"entrypoint": "plugins.codex_song_detector.runtime:register",
|
||||||
|
"configSchema": "plugins/codex_song_detector/config.schema.json",
|
||||||
|
"capabilities": ["song_detect"],
|
||||||
|
"enabledByDefault": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Runtime
|
||||||
|
|
||||||
|
真正实现业务逻辑的代码。
|
||||||
|
|
||||||
|
## Registry
|
||||||
|
|
||||||
|
系统启动时统一构建 registry。
|
||||||
|
|
||||||
|
registry 负责:
|
||||||
|
|
||||||
|
- 注册插件能力
|
||||||
|
- 按类型查找实现
|
||||||
|
- 根据配置激活当前 provider
|
||||||
|
|
||||||
|
### Registry Types
|
||||||
|
|
||||||
|
- `ingest_provider`
|
||||||
|
- `transcribe_provider`
|
||||||
|
- `song_detector`
|
||||||
|
- `split_provider`
|
||||||
|
- `publish_provider`
|
||||||
|
- `comment_strategy`
|
||||||
|
- `collection_strategy`
|
||||||
|
|
||||||
|
## Plugin Loading Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Discover manifests
|
||||||
|
-> Validate manifests
|
||||||
|
-> Register capabilities in registry
|
||||||
|
-> Load plugin config schema
|
||||||
|
-> Validate plugin config
|
||||||
|
-> Activate runtime implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Manifest-First
|
||||||
|
|
||||||
|
这样设计有 4 个直接好处:
|
||||||
|
|
||||||
|
- 管理台可以在不执行插件代码时展示插件信息
|
||||||
|
- UI 可以根据 schema 渲染配置表单
|
||||||
|
- 系统可以提前发现缺失字段或不兼容版本
|
||||||
|
- 插件运行失败不会影响元数据层的可见性
|
||||||
|
|
||||||
|
## Suggested Plugin Boundaries
|
||||||
|
|
||||||
|
### Transcribe Provider
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `groq`
|
||||||
|
- `openai`
|
||||||
|
- `local_whisper`
|
||||||
|
|
||||||
|
### Song Detector
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `codex`
|
||||||
|
- `rule_engine`
|
||||||
|
- `custom_llm`
|
||||||
|
|
||||||
|
### Publish Provider
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `biliup_cli`
|
||||||
|
- `bilibili_api`
|
||||||
|
|
||||||
|
### Collection Strategy
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- `default_song_collection`
|
||||||
|
- `title_match_full_video`
|
||||||
|
- `manual_binding`
|
||||||
|
|
||||||
|
## Control Plane Integration
|
||||||
|
|
||||||
|
插件系统必须服务于控制面。
|
||||||
|
|
||||||
|
因此管理台至少需要知道:
|
||||||
|
|
||||||
|
- 当前有哪些插件
|
||||||
|
- 每个插件类型是什么
|
||||||
|
- 当前启用的是哪一个
|
||||||
|
- 配置是否有效
|
||||||
|
- 最近一次健康检查结果
|
||||||
|
|
||||||
|
## Restrictions
|
||||||
|
|
||||||
|
为了避免再次走向“任意脚本散落”,插件系统需要约束:
|
||||||
|
|
||||||
|
- 插件不得直接修改核心数据库结构
|
||||||
|
- 插件不得绕过统一配置系统
|
||||||
|
- 插件不得私自写独立日志目录作为唯一状态来源
|
||||||
|
- 插件不得直接互相调用具体实现
|
||||||
|
|
||||||
|
## Initial Strategy
|
||||||
|
|
||||||
|
第一阶段不追求真正的第三方插件生态。
|
||||||
|
|
||||||
|
先实现“内置插件化”:
|
||||||
|
|
||||||
|
- 核心仓库内提供多个 provider
|
||||||
|
- 统一用 manifest + registry 管理
|
||||||
|
- 等边界稳定后,再考虑开放外部插件目录
|
||||||
212
docs/state-machine.md
Normal file
212
docs/state-machine.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# State Machine
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
定义 `biliup-next` 的任务状态机,取代旧系统依赖 flag 文件、日志和目录结构推断状态的方式。
|
||||||
|
|
||||||
|
状态机目标:
|
||||||
|
|
||||||
|
- 让每个任务始终有明确状态
|
||||||
|
- 支持失败重试和人工介入
|
||||||
|
- 让 UI 和 API 可以直接消费状态
|
||||||
|
- 保证步骤顺序和依赖关系清晰
|
||||||
|
|
||||||
|
## State Model
|
||||||
|
|
||||||
|
任务状态分为两层:
|
||||||
|
|
||||||
|
- `task status`:任务整体状态
|
||||||
|
- `step status`:任务中每一步的执行状态
|
||||||
|
|
||||||
|
## Task Status
|
||||||
|
|
||||||
|
### Core Statuses
|
||||||
|
|
||||||
|
- `created`
|
||||||
|
- `ingested`
|
||||||
|
- `transcribed`
|
||||||
|
- `songs_detected`
|
||||||
|
- `split_done`
|
||||||
|
- `published`
|
||||||
|
- `commented`
|
||||||
|
- `collection_synced`
|
||||||
|
- `completed`
|
||||||
|
|
||||||
|
### Failure Statuses
|
||||||
|
|
||||||
|
- `failed_retryable`
|
||||||
|
- `failed_manual`
|
||||||
|
|
||||||
|
### Terminal Statuses
|
||||||
|
|
||||||
|
- `completed`
|
||||||
|
- `cancelled`
|
||||||
|
- `failed_manual`
|
||||||
|
|
||||||
|
## Step Status
|
||||||
|
|
||||||
|
每个步骤都独立维护自己的状态。
|
||||||
|
|
||||||
|
- `pending`
|
||||||
|
- `running`
|
||||||
|
- `succeeded`
|
||||||
|
- `failed_retryable`
|
||||||
|
- `failed_manual`
|
||||||
|
- `skipped`
|
||||||
|
|
||||||
|
## Step Definitions
|
||||||
|
|
||||||
|
### ingest
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 接收输入视频
|
||||||
|
- 基础校验
|
||||||
|
- 创建任务记录
|
||||||
|
|
||||||
|
### transcribe
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 生成字幕
|
||||||
|
- 记录字幕产物
|
||||||
|
|
||||||
|
### song_detect
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 识别歌曲列表
|
||||||
|
- 生成 `songs.json` 和 `songs.txt`
|
||||||
|
|
||||||
|
### split
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 根据歌单切割视频
|
||||||
|
- 生成切片产物
|
||||||
|
|
||||||
|
### publish
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 上传纯享版视频
|
||||||
|
- 记录 `aid/bvid`
|
||||||
|
|
||||||
|
### comment
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 发布评论
|
||||||
|
- 置顶评论
|
||||||
|
|
||||||
|
### collection_a
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 将完整版视频加入合集 A
|
||||||
|
|
||||||
|
### collection_b
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 将纯享版视频加入合集 B
|
||||||
|
|
||||||
|
## State Transition Rules
|
||||||
|
|
||||||
|
### Task-Level
|
||||||
|
|
||||||
|
```text
|
||||||
|
created
|
||||||
|
-> ingested
|
||||||
|
-> transcribed
|
||||||
|
-> songs_detected
|
||||||
|
-> split_done
|
||||||
|
-> published
|
||||||
|
-> commented
|
||||||
|
-> collection_synced
|
||||||
|
-> completed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Failure Transition
|
||||||
|
|
||||||
|
任何步骤失败后:
|
||||||
|
|
||||||
|
- 若允许自动重试:任务进入 `failed_retryable`
|
||||||
|
- 若必须人工介入:任务进入 `failed_manual`
|
||||||
|
|
||||||
|
重试成功后:
|
||||||
|
|
||||||
|
- 任务回到该步骤成功后的下一个合法状态
|
||||||
|
|
||||||
|
## Dependency Rules
|
||||||
|
|
||||||
|
- `transcribe` 必须依赖 `ingest`
|
||||||
|
- `song_detect` 必须依赖 `transcribe`
|
||||||
|
- `split` 必须依赖 `song_detect`
|
||||||
|
- `publish` 必须依赖 `split`
|
||||||
|
- `comment` 必须依赖 `publish`
|
||||||
|
- `collection_b` 必须依赖 `publish`
|
||||||
|
- `collection_a` 通常依赖外部完整版 BV,可独立于 `publish`
|
||||||
|
|
||||||
|
## Special Case: Collection A
|
||||||
|
|
||||||
|
合集 A 的数据来源与主上传链路不同。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- `collection_a` 不应阻塞主任务完成
|
||||||
|
- `collection_a` 可作为独立步骤存在
|
||||||
|
- 任务整体完成不必强依赖 `collection_a` 成功
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- `completed` 表示主链路完成
|
||||||
|
- `collection_synced` 表示所有合集同步完成
|
||||||
|
|
||||||
|
## Retry Strategy
|
||||||
|
|
||||||
|
### Retryable Errors
|
||||||
|
|
||||||
|
适合自动重试:
|
||||||
|
|
||||||
|
- 网络错误
|
||||||
|
- 外部 API 临时失败
|
||||||
|
- 上传频控
|
||||||
|
- 外部命令短时异常
|
||||||
|
|
||||||
|
### Manual Errors
|
||||||
|
|
||||||
|
需要人工介入:
|
||||||
|
|
||||||
|
- 配置缺失
|
||||||
|
- 凭证失效
|
||||||
|
- 文件损坏
|
||||||
|
- provider 不可用
|
||||||
|
- 标题无法匹配完整版 BV
|
||||||
|
|
||||||
|
## Persistence Requirements
|
||||||
|
|
||||||
|
每次状态变更都必须落库:
|
||||||
|
|
||||||
|
- 任务状态
|
||||||
|
- 步骤状态
|
||||||
|
- 开始时间
|
||||||
|
- 结束时间
|
||||||
|
- 错误码
|
||||||
|
- 错误信息
|
||||||
|
- 重试次数
|
||||||
|
|
||||||
|
## UI Expectations
|
||||||
|
|
||||||
|
UI 至少需要直接展示:
|
||||||
|
|
||||||
|
- 当前任务状态
|
||||||
|
- 当前正在运行的步骤
|
||||||
|
- 最近失败步骤
|
||||||
|
- 重试次数
|
||||||
|
- 是否需要人工介入
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 不追求一个任务多个步骤完全并发执行
|
||||||
|
- 不允许继续依赖 flag 文件作为权威状态来源
|
||||||
54
docs/vision.md
Normal file
54
docs/vision.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Vision
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
将当前基于目录监听和脚本拼接的流水线,重构为一个模块化、可扩展、可观测、可运维的单体系统。
|
||||||
|
|
||||||
|
系统负责:
|
||||||
|
|
||||||
|
- 接收本地视频任务
|
||||||
|
- 执行转录、识歌、切歌、上传、评论、合集归档
|
||||||
|
- 记录任务状态、产物、错误和外部结果
|
||||||
|
- 提供统一配置和管理入口
|
||||||
|
|
||||||
|
系统不负责:
|
||||||
|
|
||||||
|
- 直播录制
|
||||||
|
- 完整版视频的外部发布流程
|
||||||
|
- 多账号复杂运营后台
|
||||||
|
- 分布式调度
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
- 运维者:部署、启动、排查、重试任务
|
||||||
|
- 内容生产者:投放视频、观察任务状态
|
||||||
|
- 开发者:新增模块、替换外部依赖、扩展功能
|
||||||
|
|
||||||
|
## Problems In Current Project
|
||||||
|
|
||||||
|
- 状态分散在目录名、flag 文件、日志中,缺少单一事实来源
|
||||||
|
- 业务逻辑和运维逻辑耦合严重
|
||||||
|
- 配置项散落在多个脚本和常量中
|
||||||
|
- 同类逻辑重复实现,例如 B 站列表解析、合集处理、任务扫描
|
||||||
|
- 可观测性不足,失败后需要人工翻日志定位
|
||||||
|
- 扩展新能力时只能继续加脚本,结构会越来越乱
|
||||||
|
|
||||||
|
## Target Characteristics
|
||||||
|
|
||||||
|
- 模块化单体,而不是脚本集合
|
||||||
|
- 显式任务状态机
|
||||||
|
- 统一配置系统
|
||||||
|
- 外部依赖适配器化
|
||||||
|
- 结构化任务存储
|
||||||
|
- 插件式扩展点
|
||||||
|
- Web 管理台
|
||||||
|
- 文档优先
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
1. 定义架构、领域模型、模块接口和 API。
|
||||||
|
2. 建立新系统骨架,不影响旧系统运行。
|
||||||
|
3. 落地统一配置、任务状态存储和最小管理 API。
|
||||||
|
4. 按模块迁移旧能力:转录、识歌、切歌、上传、评论、合集。
|
||||||
|
5. 接入 Web 管理台。
|
||||||
|
6. 逐步切换生产流量,最终替换旧脚本体系。
|
||||||
65
frontend/README.md
Normal file
65
frontend/README.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
`frontend/` 是新的 React + Vite 控制台迁移骨架,目标是逐步替换当前 `src/biliup_next/app/static/` 下的原生前端。
|
||||||
|
|
||||||
|
当前已迁入:
|
||||||
|
|
||||||
|
- 基础导航:`Overview / Tasks / Settings / Logs`
|
||||||
|
- `Overview` 第一版
|
||||||
|
- `Tasks` 工作台第一版
|
||||||
|
- `Logs` 工作台第一版
|
||||||
|
- 任务表
|
||||||
|
- 任务详情
|
||||||
|
- 与现有 Python API 的直连
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `src/App.jsx`
|
||||||
|
- `src/components/`
|
||||||
|
- `src/api/client.js`
|
||||||
|
- `src/lib/format.js`
|
||||||
|
- `src/styles.css`
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
当前机器未安装 `npm` 或 `corepack`,所以这套前端骨架还没有在本机完成依赖安装。
|
||||||
|
|
||||||
|
有包管理器后,在 `frontend/` 下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发服务器默认地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5173/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会通过 `vite.config.mjs` 把这些路径代理到现有后端 `http://127.0.0.1:8787`:
|
||||||
|
|
||||||
|
- `/health`
|
||||||
|
- `/doctor`
|
||||||
|
- `/tasks`
|
||||||
|
- `/settings`
|
||||||
|
- `/runtime`
|
||||||
|
- `/history`
|
||||||
|
- `/logs`
|
||||||
|
- `/modules`
|
||||||
|
- `/scheduler`
|
||||||
|
- `/worker`
|
||||||
|
- `/stage`
|
||||||
|
|
||||||
|
生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8787/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 迁移 `Settings`
|
||||||
|
- 将任务表改为真正服务端驱动的分页/排序/筛选
|
||||||
|
- 增加 React 路由和查询缓存
|
||||||
|
- 最终替换当前 `src/biliup_next/app/static/` 入口
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>biliup-next Frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1815
frontend/package-lock.json
generated
Normal file
1815
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "biliup-next-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
495
frontend/src/App.jsx
Normal file
495
frontend/src/App.jsx
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
import { useEffect, useState, useDeferredValue, startTransition } from "react";
|
||||||
|
|
||||||
|
import { fetchJson, uploadFile } from "./api/client.js";
|
||||||
|
import LogsPanel from "./components/LogsPanel.jsx";
|
||||||
|
import OverviewPanel from "./components/OverviewPanel.jsx";
|
||||||
|
import SettingsPanel from "./components/SettingsPanel.jsx";
|
||||||
|
import TaskTable from "./components/TaskTable.jsx";
|
||||||
|
import TaskDetailCard from "./components/TaskDetailCard.jsx";
|
||||||
|
import { summarizeAttention, summarizeDelivery } from "./lib/format.js";
|
||||||
|
|
||||||
|
const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"];
|
||||||
|
|
||||||
|
function PlaceholderView({ title, description }) {
|
||||||
|
return (
|
||||||
|
<section className="placeholder-view">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksView({
|
||||||
|
tasks,
|
||||||
|
selectedTaskId,
|
||||||
|
onSelectTask,
|
||||||
|
onRunTask,
|
||||||
|
taskDetail,
|
||||||
|
loading,
|
||||||
|
detailLoading,
|
||||||
|
selectedStepName,
|
||||||
|
onSelectStep,
|
||||||
|
onRetryStep,
|
||||||
|
onResetStep,
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [attentionFilter, setAttentionFilter] = useState("");
|
||||||
|
const [deliveryFilter, setDeliveryFilter] = useState("");
|
||||||
|
const [sort, setSort] = useState("updated_desc");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
|
||||||
|
const filtered = tasks
|
||||||
|
.filter((task) => {
|
||||||
|
const haystack = `${task.id} ${task.title}`.toLowerCase();
|
||||||
|
if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false;
|
||||||
|
if (statusFilter && task.status !== statusFilter) return false;
|
||||||
|
if (attentionFilter && summarizeAttention(task) !== attentionFilter) return false;
|
||||||
|
if (deliveryFilter && summarizeDelivery(task.delivery_state) !== deliveryFilter) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sort === "title_asc") return String(a.title).localeCompare(String(b.title), "zh-CN");
|
||||||
|
if (sort === "title_desc") return String(b.title).localeCompare(String(a.title), "zh-CN");
|
||||||
|
if (sort === "attention") return summarizeAttention(a).localeCompare(summarizeAttention(b), "zh-CN");
|
||||||
|
return String(b.updated_at).localeCompare(String(a.updated_at), "zh-CN");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="tasks-layout-react">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Tasks Workspace</p>
|
||||||
|
<h2>Task Table</h2>
|
||||||
|
</div>
|
||||||
|
<div className="panel-meta">{loading ? "syncing..." : `${filtered.length} visible`}</div>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-grid">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="搜索任务标题或 task id"
|
||||||
|
/>
|
||||||
|
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="running">处理中</option>
|
||||||
|
<option value="failed_retryable">待重试</option>
|
||||||
|
<option value="failed_manual">待人工</option>
|
||||||
|
<option value="published">待收尾</option>
|
||||||
|
<option value="collection_synced">已完成</option>
|
||||||
|
</select>
|
||||||
|
<select value={attentionFilter} onChange={(event) => setAttentionFilter(event.target.value)}>
|
||||||
|
<option value="">全部关注状态</option>
|
||||||
|
<option value="manual_now">仅看需人工</option>
|
||||||
|
<option value="retry_now">仅看到点重试</option>
|
||||||
|
<option value="waiting_retry">仅看等待重试</option>
|
||||||
|
</select>
|
||||||
|
<select value={deliveryFilter} onChange={(event) => setDeliveryFilter(event.target.value)}>
|
||||||
|
<option value="">全部交付状态</option>
|
||||||
|
<option value="legacy_untracked">主视频评论未追踪</option>
|
||||||
|
<option value="pending_comment">评论待完成</option>
|
||||||
|
<option value="cleanup_removed">已清理视频</option>
|
||||||
|
</select>
|
||||||
|
<select value={sort} onChange={(event) => setSort(event.target.value)}>
|
||||||
|
<option value="updated_desc">最近更新</option>
|
||||||
|
<option value="title_asc">标题 A-Z</option>
|
||||||
|
<option value="title_desc">标题 Z-A</option>
|
||||||
|
<option value="attention">按关注状态</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<TaskTable tasks={filtered} selectedTaskId={selectedTaskId} onSelectTask={onSelectTask} onRunTask={onRunTask} />
|
||||||
|
</article>
|
||||||
|
<TaskDetailCard
|
||||||
|
payload={taskDetail}
|
||||||
|
loading={detailLoading}
|
||||||
|
selectedStepName={selectedStepName}
|
||||||
|
onSelectStep={onSelectStep}
|
||||||
|
onRetryStep={onRetryStep}
|
||||||
|
onResetStep={onResetStep}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [view, setView] = useState("Tasks");
|
||||||
|
const [health, setHealth] = useState(false);
|
||||||
|
const [doctorOk, setDoctorOk] = useState(false);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [services, setServices] = useState({ items: [] });
|
||||||
|
const [scheduler, setScheduler] = useState(null);
|
||||||
|
const [history, setHistory] = useState({ items: [] });
|
||||||
|
const [logs, setLogs] = useState({ items: [] });
|
||||||
|
const [selectedLogName, setSelectedLogName] = useState("");
|
||||||
|
const [logContent, setLogContent] = useState(null);
|
||||||
|
const [filterCurrentTaskLogs, setFilterCurrentTaskLogs] = useState(false);
|
||||||
|
const [autoRefreshLogs, setAutoRefreshLogs] = useState(false);
|
||||||
|
const [settings, setSettings] = useState({});
|
||||||
|
const [settingsSchema, setSettingsSchema] = useState(null);
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||||
|
const [selectedStepName, setSelectedStepName] = useState("");
|
||||||
|
const [taskDetail, setTaskDetail] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||||
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
|
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||||
|
const [banner, setBanner] = useState(null);
|
||||||
|
|
||||||
|
async function loadOverviewPanels() {
|
||||||
|
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||||||
|
fetchJson("/runtime/services"),
|
||||||
|
fetchJson("/scheduler/preview"),
|
||||||
|
fetchJson("/history?limit=20"),
|
||||||
|
]);
|
||||||
|
setServices(servicesPayload);
|
||||||
|
setScheduler(schedulerPayload);
|
||||||
|
setHistory(historyPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShell() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [healthPayload, doctorPayload, taskPayload] = await Promise.all([
|
||||||
|
fetchJson("/health"),
|
||||||
|
fetchJson("/doctor"),
|
||||||
|
fetchJson("/tasks?limit=100"),
|
||||||
|
]);
|
||||||
|
setHealth(Boolean(healthPayload.ok));
|
||||||
|
setDoctorOk(Boolean(doctorPayload.ok));
|
||||||
|
setTasks(taskPayload.items || []);
|
||||||
|
startTransition(() => {
|
||||||
|
if (!selectedTaskId && taskPayload.items?.length) {
|
||||||
|
setSelectedTaskId(taskPayload.items[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskDetail(taskId) {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||||
|
fetchJson(`/tasks/${encodeURIComponent(taskId)}`),
|
||||||
|
fetchJson(`/tasks/${encodeURIComponent(taskId)}/steps`),
|
||||||
|
fetchJson(`/tasks/${encodeURIComponent(taskId)}/artifacts`),
|
||||||
|
fetchJson(`/tasks/${encodeURIComponent(taskId)}/history`),
|
||||||
|
fetchJson(`/tasks/${encodeURIComponent(taskId)}/timeline`),
|
||||||
|
]);
|
||||||
|
setTaskDetail({ task, steps, artifacts, history, timeline });
|
||||||
|
if (!selectedStepName) {
|
||||||
|
const suggested = steps.items?.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status))?.step_name
|
||||||
|
|| steps.items?.find((step) => step.status !== "succeeded")?.step_name
|
||||||
|
|| "";
|
||||||
|
setSelectedStepName(suggested);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadShell().catch((error) => {
|
||||||
|
if (!cancelled) setBanner({ kind: "hot", text: `初始化失败: ${error}` });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedTaskId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "Overview") return;
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadOverviewView() {
|
||||||
|
setOverviewLoading(true);
|
||||||
|
try {
|
||||||
|
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||||||
|
fetchJson("/runtime/services"),
|
||||||
|
fetchJson("/scheduler/preview"),
|
||||||
|
fetchJson("/history?limit=20"),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setServices(servicesPayload);
|
||||||
|
setScheduler(schedulerPayload);
|
||||||
|
setHistory(historyPayload);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setOverviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadOverviewView();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTaskId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
loadTaskDetail(selectedTaskId).catch((error) => {
|
||||||
|
if (!cancelled) setBanner({ kind: "hot", text: `任务详情加载失败: ${error}` });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedTaskId]);
|
||||||
|
|
||||||
|
async function loadCurrentLogContent(logName = selectedLogName) {
|
||||||
|
if (!logName) return;
|
||||||
|
setLogLoading(true);
|
||||||
|
try {
|
||||||
|
const currentTask = tasks.find((item) => item.id === selectedTaskId);
|
||||||
|
let url = `/logs?name=${encodeURIComponent(logName)}&lines=200`;
|
||||||
|
if (filterCurrentTaskLogs && currentTask?.title) {
|
||||||
|
url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||||||
|
}
|
||||||
|
const payload = await fetchJson(url);
|
||||||
|
setLogContent(payload);
|
||||||
|
} finally {
|
||||||
|
setLogLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "Logs") return;
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadLogsIndex() {
|
||||||
|
setLogLoading(true);
|
||||||
|
try {
|
||||||
|
const logsPayload = await fetchJson("/logs");
|
||||||
|
if (cancelled) return;
|
||||||
|
setLogs(logsPayload);
|
||||||
|
if (!selectedLogName && logsPayload.items?.length) {
|
||||||
|
startTransition(() => setSelectedLogName(logsPayload.items[0].name));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLogLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadLogsIndex();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "Logs" || !selectedLogName) return;
|
||||||
|
let cancelled = false;
|
||||||
|
loadCurrentLogContent(selectedLogName).catch((error) => {
|
||||||
|
if (!cancelled) setBanner({ kind: "hot", text: `日志加载失败: ${error}` });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [view, selectedLogName, filterCurrentTaskLogs, selectedTaskId, tasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "Logs" || !selectedLogName || !autoRefreshLogs) return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
loadCurrentLogContent(selectedLogName).catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [view, selectedLogName, autoRefreshLogs, filterCurrentTaskLogs, selectedTaskId, tasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "Settings") return;
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadSettingsView() {
|
||||||
|
setSettingsLoading(true);
|
||||||
|
try {
|
||||||
|
const [settingsPayload, schemaPayload] = await Promise.all([
|
||||||
|
fetchJson("/settings"),
|
||||||
|
fetchJson("/settings/schema"),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setSettings(settingsPayload);
|
||||||
|
setSettingsSchema(schemaPayload);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setSettingsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSettingsView();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
const currentView = (() => {
|
||||||
|
if (view === "Overview") {
|
||||||
|
return (
|
||||||
|
<OverviewPanel
|
||||||
|
health={health}
|
||||||
|
doctorOk={doctorOk}
|
||||||
|
tasks={{ items: tasks }}
|
||||||
|
services={services}
|
||||||
|
scheduler={scheduler}
|
||||||
|
history={history}
|
||||||
|
loading={overviewLoading}
|
||||||
|
onRefreshScheduler={async () => {
|
||||||
|
const payload = await fetchJson("/scheduler/preview");
|
||||||
|
setScheduler(payload);
|
||||||
|
setBanner({ kind: "good", text: "Scheduler 已刷新" });
|
||||||
|
}}
|
||||||
|
onRefreshHistory={async () => {
|
||||||
|
const payload = await fetchJson("/history?limit=20");
|
||||||
|
setHistory(payload);
|
||||||
|
setBanner({ kind: "good", text: "Recent Actions 已刷新" });
|
||||||
|
}}
|
||||||
|
onStageImport={async (sourcePath) => {
|
||||||
|
const result = await fetchJson("/stage/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source_path: sourcePath }),
|
||||||
|
});
|
||||||
|
await loadShell();
|
||||||
|
setBanner({ kind: "good", text: `已导入到 stage: ${result.target_path}` });
|
||||||
|
}}
|
||||||
|
onStageUpload={async (file) => {
|
||||||
|
const result = await uploadFile("/stage/upload", file);
|
||||||
|
await loadShell();
|
||||||
|
setBanner({ kind: "good", text: `已上传到 stage: ${result.target_path}` });
|
||||||
|
}}
|
||||||
|
onRunOnce={async () => {
|
||||||
|
await fetchJson("/worker/run-once", { method: "POST" });
|
||||||
|
await loadShell();
|
||||||
|
setBanner({ kind: "good", text: "Worker 已执行一轮" });
|
||||||
|
}}
|
||||||
|
onServiceAction={async (serviceId, action) => {
|
||||||
|
await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
|
||||||
|
await loadShell();
|
||||||
|
if (view === "Overview") {
|
||||||
|
await loadOverviewPanels();
|
||||||
|
}
|
||||||
|
setBanner({ kind: "good", text: `${serviceId} ${action} 完成` });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (view === "Tasks") {
|
||||||
|
return (
|
||||||
|
<TasksView
|
||||||
|
tasks={tasks}
|
||||||
|
selectedTaskId={selectedTaskId}
|
||||||
|
onSelectTask={(taskId) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setSelectedTaskId(taskId);
|
||||||
|
setSelectedStepName("");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRunTask={async (taskId) => {
|
||||||
|
const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" });
|
||||||
|
await loadShell();
|
||||||
|
await loadTaskDetail(taskId);
|
||||||
|
setBanner({ kind: "good", text: `任务已推进: ${taskId} / processed=${result.processed.length}` });
|
||||||
|
}}
|
||||||
|
taskDetail={taskDetail}
|
||||||
|
loading={loading}
|
||||||
|
detailLoading={detailLoading}
|
||||||
|
selectedStepName={selectedStepName}
|
||||||
|
onSelectStep={setSelectedStepName}
|
||||||
|
onRetryStep={async (stepName) => {
|
||||||
|
if (!selectedTaskId || !stepName) return;
|
||||||
|
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step_name: stepName }),
|
||||||
|
});
|
||||||
|
await loadShell();
|
||||||
|
await loadTaskDetail(selectedTaskId);
|
||||||
|
setBanner({ kind: "good", text: `已重试 ${stepName} / processed=${result.processed.length}` });
|
||||||
|
}}
|
||||||
|
onResetStep={async (stepName) => {
|
||||||
|
if (!selectedTaskId || !stepName) return;
|
||||||
|
if (!window.confirm(`确认重置到 step=${stepName} 并清理其后的产物吗?`)) return;
|
||||||
|
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step_name: stepName }),
|
||||||
|
});
|
||||||
|
await loadShell();
|
||||||
|
await loadTaskDetail(selectedTaskId);
|
||||||
|
setBanner({ kind: "good", text: `已重置到 ${stepName} / processed=${result.run.processed.length}` });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (view === "Settings") {
|
||||||
|
return (
|
||||||
|
<SettingsPanel
|
||||||
|
settings={settings}
|
||||||
|
schema={settingsSchema}
|
||||||
|
loading={settingsLoading}
|
||||||
|
onSave={async (payload) => {
|
||||||
|
await fetchJson("/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const refreshed = await fetchJson("/settings");
|
||||||
|
setSettings(refreshed);
|
||||||
|
setBanner({ kind: "good", text: "Settings 已保存并刷新" });
|
||||||
|
return refreshed;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LogsPanel
|
||||||
|
logs={logs.items || []}
|
||||||
|
selectedLogName={selectedLogName}
|
||||||
|
onSelectLog={(name) => startTransition(() => setSelectedLogName(name))}
|
||||||
|
logContent={logContent}
|
||||||
|
loading={logLoading}
|
||||||
|
currentTaskTitle={tasks.find((item) => item.id === selectedTaskId)?.title || ""}
|
||||||
|
filterCurrentTask={filterCurrentTaskLogs}
|
||||||
|
onToggleFilterCurrentTask={setFilterCurrentTaskLogs}
|
||||||
|
autoRefresh={autoRefreshLogs}
|
||||||
|
onToggleAutoRefresh={setAutoRefreshLogs}
|
||||||
|
onRefreshLog={async () => {
|
||||||
|
if (!selectedLogName) return;
|
||||||
|
await loadCurrentLogContent(selectedLogName);
|
||||||
|
setBanner({ kind: "good", text: "日志已刷新" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="react-shell">
|
||||||
|
<aside className="react-sidebar">
|
||||||
|
<p className="eyebrow">Biliup Next</p>
|
||||||
|
<h1>Frontend</h1>
|
||||||
|
<p className="sidebar-copy">React + Vite 控制台迁移骨架,先接管任务工作台。</p>
|
||||||
|
<nav className="sidebar-nav-react">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
className={view === item ? "nav-btn active" : "nav-btn"}
|
||||||
|
onClick={() => setView(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className="react-main">
|
||||||
|
<header className="react-topbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Migration Workspace</p>
|
||||||
|
<h2>{view}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="status-row">
|
||||||
|
<span className={`status-badge ${health ? "good" : "hot"}`}>API {health ? "ok" : "down"}</span>
|
||||||
|
<span className={`status-badge ${doctorOk ? "good" : "warn"}`}>Doctor {doctorOk ? "ready" : "warn"}</span>
|
||||||
|
<span className="status-badge">{tasks.length} tasks</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{banner ? <div className={`status-banner ${banner.kind}`}>{banner.text}</div> : null}
|
||||||
|
{currentView}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/api/client.js
Normal file
29
frontend/src/api/client.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export async function fetchJson(url, options = {}) {
|
||||||
|
const token = localStorage.getItem("biliup_next_token") || "";
|
||||||
|
const headers = { ...(options.headers || {}) };
|
||||||
|
if (token) headers["X-Biliup-Token"] = token;
|
||||||
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(url, file) {
|
||||||
|
const token = localStorage.getItem("biliup_next_token") || "";
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers["X-Biliup-Token"] = token;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
87
frontend/src/components/LogsPanel.jsx
Normal file
87
frontend/src/components/LogsPanel.jsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
function buildFilteredLines(lines, query) {
|
||||||
|
if (!query) return lines;
|
||||||
|
const needle = query.toLowerCase();
|
||||||
|
return lines.filter((line) => String(line).toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsPanel({
|
||||||
|
logs,
|
||||||
|
selectedLogName,
|
||||||
|
onSelectLog,
|
||||||
|
logContent,
|
||||||
|
loading,
|
||||||
|
onRefreshLog,
|
||||||
|
currentTaskTitle,
|
||||||
|
filterCurrentTask,
|
||||||
|
onToggleFilterCurrentTask,
|
||||||
|
autoRefresh,
|
||||||
|
onToggleAutoRefresh,
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [lineFilter, setLineFilter] = useState("");
|
||||||
|
|
||||||
|
const visibleLogs = useMemo(() => {
|
||||||
|
if (!search) return logs;
|
||||||
|
const needle = search.toLowerCase();
|
||||||
|
return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle));
|
||||||
|
}, [logs, search]);
|
||||||
|
|
||||||
|
const filteredLines = useMemo(
|
||||||
|
() => buildFilteredLines(logContent?.lines || [], lineFilter),
|
||||||
|
[logContent?.lines, lineFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="logs-layout-react">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Logs Workspace</p>
|
||||||
|
<h2>Log Index</h2>
|
||||||
|
</div>
|
||||||
|
<div className="panel-meta">{visibleLogs.length} logs</div>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-grid compact-grid">
|
||||||
|
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索日志文件" />
|
||||||
|
</div>
|
||||||
|
<div className="log-index-list">
|
||||||
|
{visibleLogs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
className={selectedLogName === item.name ? "log-index-item active" : "log-index-item"}
|
||||||
|
onClick={() => onSelectLog(item.name)}
|
||||||
|
>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>{item.path}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!visibleLogs.length ? <p className="muted">{loading ? "loading..." : "暂无日志文件"}</p> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel detail-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Log Detail</p>
|
||||||
|
<h2>{selectedLogName || "选择一个日志"}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="nav-btn" onClick={onRefreshLog}>刷新</button>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-grid compact-grid">
|
||||||
|
<input value={lineFilter} onChange={(event) => setLineFilter(event.target.value)} placeholder="过滤日志行内容" />
|
||||||
|
<label className="toggle-row">
|
||||||
|
<input type="checkbox" checked={filterCurrentTask} onChange={(event) => onToggleFilterCurrentTask?.(event.target.checked)} />
|
||||||
|
<span>按当前任务过滤{currentTaskTitle ? ` · ${currentTaskTitle}` : ""}</span>
|
||||||
|
</label>
|
||||||
|
<label className="toggle-row">
|
||||||
|
<input type="checkbox" checked={autoRefresh} onChange={(event) => onToggleAutoRefresh?.(event.target.checked)} />
|
||||||
|
<span>自动刷新</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<pre className="log-pre">{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/src/components/OverviewPanel.jsx
Normal file
166
frontend/src/components/OverviewPanel.jsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import StatusBadge from "./StatusBadge.jsx";
|
||||||
|
import { attentionLabel, summarizeAttention } from "../lib/format.js";
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, tone = "" }) {
|
||||||
|
return (
|
||||||
|
<article className="summary-card">
|
||||||
|
<span className="eyebrow">{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
{tone ? <StatusBadge tone={tone}>{tone}</StatusBadge> : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverviewPanel({
|
||||||
|
health,
|
||||||
|
doctorOk,
|
||||||
|
tasks,
|
||||||
|
services,
|
||||||
|
scheduler,
|
||||||
|
history,
|
||||||
|
loading,
|
||||||
|
onRefreshScheduler,
|
||||||
|
onRefreshHistory,
|
||||||
|
onRunOnce,
|
||||||
|
onServiceAction,
|
||||||
|
onStageImport,
|
||||||
|
onStageUpload,
|
||||||
|
}) {
|
||||||
|
const [stageSourcePath, setStageSourcePath] = useState("");
|
||||||
|
const [stageFile, setStageFile] = useState(null);
|
||||||
|
const taskItems = tasks?.items || [];
|
||||||
|
const serviceItems = services?.items || [];
|
||||||
|
const actionItems = history?.items || [];
|
||||||
|
const scheduled = scheduler?.scheduled || [];
|
||||||
|
const deferred = scheduler?.deferred || [];
|
||||||
|
const attentionCounts = taskItems.reduce(
|
||||||
|
(acc, task) => {
|
||||||
|
const key = summarizeAttention(task);
|
||||||
|
acc[key] = (acc[key] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overview-stack-react">
|
||||||
|
<div className="overview-grid">
|
||||||
|
<SummaryCard label="Health" value={health ? "ok" : "down"} tone={health ? "good" : "hot"} />
|
||||||
|
<SummaryCard label="Doctor" value={doctorOk ? "ready" : "warn"} tone={doctorOk ? "good" : "warn"} />
|
||||||
|
<SummaryCard label="Tasks" value={String(taskItems.length)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-grid">
|
||||||
|
<article className="detail-card">
|
||||||
|
<div className="card-head-inline">
|
||||||
|
<h3>Import To Stage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="stage-input-grid">
|
||||||
|
<input
|
||||||
|
value={stageSourcePath}
|
||||||
|
onChange={(event) => setStageSourcePath(event.target.value)}
|
||||||
|
placeholder="/absolute/path/to/video.mp4"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="nav-btn compact-btn"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!stageSourcePath.trim()) return;
|
||||||
|
await onStageImport?.(stageSourcePath.trim());
|
||||||
|
setStageSourcePath("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制到隔离 Stage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="stage-input-grid upload-grid-react">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(event) => setStageFile(event.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="nav-btn compact-btn strong-btn"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!stageFile) return;
|
||||||
|
await onStageUpload?.(stageFile);
|
||||||
|
setStageFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
上传到隔离 Stage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="detail-card">
|
||||||
|
<div className="card-head-inline">
|
||||||
|
<h3>Runtime Services</h3>
|
||||||
|
<button className="nav-btn compact-btn strong-btn" onClick={onRunOnce}>执行一轮 Worker</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-stack">
|
||||||
|
{serviceItems.map((service) => (
|
||||||
|
<div className="list-row" key={service.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{service.id}</strong>
|
||||||
|
<div className="muted">{service.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="service-actions">
|
||||||
|
<StatusBadge tone={service.active_state === "active" ? "good" : "hot"}>{service.active_state}</StatusBadge>
|
||||||
|
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "start")}>start</button>
|
||||||
|
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "restart")}>restart</button>
|
||||||
|
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "stop")}>stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!serviceItems.length ? <p className="muted">{loading ? "loading..." : "暂无服务数据"}</p> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="detail-card">
|
||||||
|
<div className="card-head-inline">
|
||||||
|
<h3>Scheduler Queue</h3>
|
||||||
|
<button className="nav-btn compact-btn" onClick={onRefreshScheduler}>刷新</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-stack">
|
||||||
|
<div className="list-row"><span>scheduled</span><strong>{scheduled.length}</strong></div>
|
||||||
|
<div className="list-row"><span>deferred</span><strong>{deferred.length}</strong></div>
|
||||||
|
<div className="list-row"><span>scanned</span><strong>{scheduler?.summary?.scanned_count ?? 0}</strong></div>
|
||||||
|
<div className="list-row"><span>truncated</span><strong>{scheduler?.summary?.truncated_count ?? 0}</strong></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-grid">
|
||||||
|
<article className="detail-card">
|
||||||
|
<h3>Attention Summary</h3>
|
||||||
|
<div className="list-stack">
|
||||||
|
{Object.entries(attentionCounts).map(([key, count]) => (
|
||||||
|
<div className="list-row" key={key}>
|
||||||
|
<span>{attentionLabel(key)}</span>
|
||||||
|
<strong>{count}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!Object.keys(attentionCounts).length ? <p className="muted">暂无任务摘要</p> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="detail-card">
|
||||||
|
<div className="card-head-inline">
|
||||||
|
<h3>Recent Actions</h3>
|
||||||
|
<button className="nav-btn compact-btn" onClick={onRefreshHistory}>刷新</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-stack">
|
||||||
|
{actionItems.slice(0, 8).map((item) => (
|
||||||
|
<div className="list-row" key={`${item.created_at}:${item.action_name}`}>
|
||||||
|
<span>{item.action_name}</span>
|
||||||
|
<StatusBadge tone={item.status === "error" ? "hot" : item.status === "warn" ? "warn" : "good"}>{item.status}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!actionItems.length ? <p className="muted">{loading ? "loading..." : "暂无动作记录"}</p> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
frontend/src/components/SettingsPanel.jsx
Normal file
310
frontend/src/components/SettingsPanel.jsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__";
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldKey(groupName, fieldName) {
|
||||||
|
return `${groupName}.${fieldName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareEntries(a, b) {
|
||||||
|
const orderA = Number(a[1].ui_order || 9999);
|
||||||
|
const orderB = Number(b[1].ui_order || 9999);
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return String(a[0]).localeCompare(String(b[0]), "zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableStringify(value) {
|
||||||
|
return JSON.stringify(value ?? {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldInput({ groupName, fieldName, schema, value, onChange }) {
|
||||||
|
if (schema.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={(event) => onChange(groupName, fieldName, event.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(schema.enum)) {
|
||||||
|
return (
|
||||||
|
<select value={String(value ?? "")} onChange={(event) => onChange(groupName, fieldName, event.target.value)}>
|
||||||
|
{schema.enum.map((optionValue) => (
|
||||||
|
<option key={String(optionValue)} value={String(optionValue)}>
|
||||||
|
{String(optionValue)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (schema.type === "array") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={JSON.stringify(value ?? [], null, 2)}
|
||||||
|
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={schema.sensitive ? "password" : schema.type === "integer" ? "number" : "text"}
|
||||||
|
value={value ?? ""}
|
||||||
|
min={schema.type === "integer" && typeof schema.minimum === "number" ? schema.minimum : undefined}
|
||||||
|
step={schema.type === "integer" ? 1 : undefined}
|
||||||
|
placeholder={schema.ui_placeholder || ""}
|
||||||
|
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFieldValue(schema, rawValue) {
|
||||||
|
if (schema.type === "boolean") return Boolean(rawValue);
|
||||||
|
if (schema.type === "integer") {
|
||||||
|
if (rawValue === "" || Number.isNaN(Number(rawValue))) return { error: "必须填写整数" };
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||||
|
return { error: `最小值为 ${schema.minimum}` };
|
||||||
|
}
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
if (schema.type === "array") {
|
||||||
|
try {
|
||||||
|
const value = typeof rawValue === "string" ? JSON.parse(rawValue || "[]") : rawValue;
|
||||||
|
if (!Array.isArray(value)) return { error: "必须是 JSON 数组" };
|
||||||
|
return { value };
|
||||||
|
} catch {
|
||||||
|
return { error: "必须是 JSON 数组" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { value: rawValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPanel({ settings, schema, onSave, loading }) {
|
||||||
|
const [draft, setDraft] = useState({});
|
||||||
|
const [rawDraft, setRawDraft] = useState("{}");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [dirty, setDirty] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState("");
|
||||||
|
const [jsonError, setJsonError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextDraft = clone(settings || {});
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setRawDraft(stableStringify(nextDraft));
|
||||||
|
setErrors({});
|
||||||
|
setDirty({});
|
||||||
|
setJsonError("");
|
||||||
|
setSaveMessage("");
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const entries = Object.entries(schema?.groups || {}).sort((a, b) => {
|
||||||
|
const orderA = Number(schema?.group_ui?.[a[0]]?.order || 9999);
|
||||||
|
const orderB = Number(schema?.group_ui?.[b[0]]?.order || 9999);
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
return entries
|
||||||
|
.map(([groupName, fields]) => {
|
||||||
|
const featured = [];
|
||||||
|
const advanced = [];
|
||||||
|
Object.entries(fields)
|
||||||
|
.sort(compareEntries)
|
||||||
|
.forEach(([fieldName, fieldSchema]) => {
|
||||||
|
const haystack = `${groupName}.${fieldName} ${fieldSchema.title || ""} ${fieldSchema.description || ""}`.toLowerCase();
|
||||||
|
if (needle && !haystack.includes(needle)) return;
|
||||||
|
(fieldSchema.ui_featured ? featured : advanced).push([fieldName, fieldSchema]);
|
||||||
|
});
|
||||||
|
return [groupName, featured, advanced];
|
||||||
|
})
|
||||||
|
.filter(([, featured, advanced]) => featured.length || advanced.length);
|
||||||
|
}, [schema, search]);
|
||||||
|
|
||||||
|
function handleFieldChange(groupName, fieldName, rawValue) {
|
||||||
|
const fieldSchema = schema.groups[groupName][fieldName];
|
||||||
|
const result = normalizeFieldValue(fieldSchema, rawValue);
|
||||||
|
setDraft((current) => {
|
||||||
|
const next = clone(current);
|
||||||
|
next[groupName] ??= {};
|
||||||
|
next[groupName][fieldName] = result.value ?? rawValue;
|
||||||
|
setRawDraft(stableStringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const key = fieldKey(groupName, fieldName);
|
||||||
|
setDirty((current) => ({ ...current, [key]: true }));
|
||||||
|
setSaveMessage("");
|
||||||
|
setErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
if (result.error) next[key] = result.error;
|
||||||
|
else delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRevertField(groupName, fieldName) {
|
||||||
|
const key = fieldKey(groupName, fieldName);
|
||||||
|
const originalValue = settings?.[groupName]?.[fieldName];
|
||||||
|
const fieldSchema = schema.groups[groupName][fieldName];
|
||||||
|
const rawValue = fieldSchema.type === "array" ? clone(originalValue ?? []) : originalValue ?? "";
|
||||||
|
setDraft((current) => {
|
||||||
|
const next = clone(current);
|
||||||
|
next[groupName] ??= {};
|
||||||
|
next[groupName][fieldName] = rawValue;
|
||||||
|
setRawDraft(stableStringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDirty((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSaveMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncJsonToForm() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawDraft || "{}");
|
||||||
|
setDraft(parsed);
|
||||||
|
setJsonError("");
|
||||||
|
setErrors({});
|
||||||
|
setDirty({});
|
||||||
|
setSaveMessage("JSON 已同步到表单");
|
||||||
|
} catch {
|
||||||
|
setJsonError("JSON 格式无效,无法同步到表单");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFormToJson() {
|
||||||
|
setRawDraft(stableStringify(draft));
|
||||||
|
setJsonError("");
|
||||||
|
setSaveMessage("表单已同步到 JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(errors).length || jsonError) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await onSave(draft);
|
||||||
|
const nextDraft = clone(saved || draft);
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setRawDraft(stableStringify(nextDraft));
|
||||||
|
setDirty({});
|
||||||
|
setErrors({});
|
||||||
|
setSaveMessage("Settings 已保存");
|
||||||
|
setJsonError("");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveMessage(`保存失败: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-layout-react">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings Workspace</p>
|
||||||
|
<h2>Schema Form</h2>
|
||||||
|
</div>
|
||||||
|
<button className="nav-btn" onClick={handleSave} disabled={saving || Boolean(Object.keys(errors).length)}>
|
||||||
|
{saving ? "保存中..." : "保存 Settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-grid compact-grid">
|
||||||
|
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索配置项" />
|
||||||
|
</div>
|
||||||
|
<div className="settings-note-stack">
|
||||||
|
<p className="muted">
|
||||||
|
敏感字段显示为 <code>{SECRET_PLACEHOLDER}</code>。保留占位符表示不改原值,改成空字符串表示清空。
|
||||||
|
</p>
|
||||||
|
{saveMessage ? <div className="status-inline-note">{saveMessage}</div> : null}
|
||||||
|
{jsonError ? <div className="status-inline-note error">{jsonError}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="settings-react-groups">
|
||||||
|
{groups.map(([groupName, featured, advanced]) => (
|
||||||
|
<section className="detail-card" key={groupName}>
|
||||||
|
<div className="settings-group-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{groupName}</p>
|
||||||
|
<h3>{schema.group_ui?.[groupName]?.title || groupName}</h3>
|
||||||
|
</div>
|
||||||
|
{schema.group_ui?.[groupName]?.description ? (
|
||||||
|
<p className="muted">{schema.group_ui[groupName].description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="settings-field-grid">
|
||||||
|
{[...featured, ...advanced].map(([fieldName, fieldSchema]) => {
|
||||||
|
const key = fieldKey(groupName, fieldName);
|
||||||
|
return (
|
||||||
|
<label className={`settings-field-card ${dirty[key] ? "dirty" : ""} ${errors[key] ? "error" : ""}`} key={key}>
|
||||||
|
<div className="settings-field-head">
|
||||||
|
<strong>{fieldSchema.title || key}</strong>
|
||||||
|
<div className="status-row">
|
||||||
|
{fieldSchema.ui_widget ? <span className="status-badge">{fieldSchema.ui_widget}</span> : null}
|
||||||
|
{fieldSchema.ui_featured ? <span className="status-badge warn">featured</span> : null}
|
||||||
|
{dirty[key] ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-btn compact-btn"
|
||||||
|
onClick={() => handleRevertField(groupName, fieldName)}
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fieldSchema.description ? <span className="muted">{fieldSchema.description}</span> : null}
|
||||||
|
<FieldInput
|
||||||
|
groupName={groupName}
|
||||||
|
fieldName={fieldName}
|
||||||
|
schema={fieldSchema}
|
||||||
|
value={draft[groupName]?.[fieldName]}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
{errors[key] ? <span className="field-error-react">{errors[key]}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{!groups.length ? <article className="detail-card"><p className="muted">{loading ? "loading..." : "没有匹配配置项"}</p></article> : null}
|
||||||
|
</div>
|
||||||
|
<section className="detail-card">
|
||||||
|
<div className="card-head-inline">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Advanced</p>
|
||||||
|
<h3>Raw JSON</h3>
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button type="button" className="nav-btn compact-btn" onClick={syncFormToJson}>表单同步到 JSON</button>
|
||||||
|
<button type="button" className="nav-btn compact-btn" onClick={syncJsonToForm}>JSON 重绘表单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="settings-json-editor"
|
||||||
|
value={rawDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRawDraft(event.target.value);
|
||||||
|
setJsonError("");
|
||||||
|
setSaveMessage("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/components/StatusBadge.jsx
Normal file
6
frontend/src/components/StatusBadge.jsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { statusClass } from "../lib/format.js";
|
||||||
|
|
||||||
|
export default function StatusBadge({ children, tone }) {
|
||||||
|
const klass = tone || statusClass(String(children));
|
||||||
|
return <span className={`status-badge ${klass}`.trim()}>{children}</span>;
|
||||||
|
}
|
||||||
142
frontend/src/components/TaskDetailCard.jsx
Normal file
142
frontend/src/components/TaskDetailCard.jsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import StatusBadge from "./StatusBadge.jsx";
|
||||||
|
import { attentionLabel, deliveryLabel, formatDate, summarizeAttention, summarizeDelivery } from "../lib/format.js";
|
||||||
|
|
||||||
|
function SummaryRow({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestedStepName(steps) {
|
||||||
|
const items = steps?.items || [];
|
||||||
|
const retryable = items.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status));
|
||||||
|
return retryable?.step_name || items.find((step) => step.status !== "succeeded")?.step_name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskDetailCard({
|
||||||
|
payload,
|
||||||
|
loading,
|
||||||
|
selectedStepName,
|
||||||
|
onSelectStep,
|
||||||
|
onRetryStep,
|
||||||
|
onResetStep,
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<article className="panel detail-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Task Detail</p>
|
||||||
|
<h2>Loading...</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload?.task) {
|
||||||
|
return (
|
||||||
|
<article className="panel detail-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Task Detail</p>
|
||||||
|
<h2>选择一个任务</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task, steps, artifacts, history } = payload;
|
||||||
|
const delivery = task.delivery_state || {};
|
||||||
|
const latestAction = history?.items?.[0];
|
||||||
|
const activeStepName = useMemo(
|
||||||
|
() => selectedStepName || suggestedStepName(steps),
|
||||||
|
[selectedStepName, steps],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="panel detail-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Task Detail</p>
|
||||||
|
<h2>{task.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="status-row">
|
||||||
|
<StatusBadge>{task.status}</StatusBadge>
|
||||||
|
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
|
||||||
|
<button className="nav-btn compact-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName}>
|
||||||
|
Retry Step
|
||||||
|
</button>
|
||||||
|
<button className="nav-btn compact-btn strong-btn" onClick={() => onResetStep?.(activeStepName)} disabled={!activeStepName}>
|
||||||
|
Reset To Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-grid">
|
||||||
|
<section className="detail-card">
|
||||||
|
<h3>Current State</h3>
|
||||||
|
<SummaryRow label="Task ID" value={task.id} />
|
||||||
|
<SummaryRow label="Updated" value={formatDate(task.updated_at)} />
|
||||||
|
<SummaryRow label="Next Retry" value={formatDate(task.retry_state?.next_retry_at)} />
|
||||||
|
<SummaryRow label="Split Comment" value={deliveryLabel(delivery.split_comment || "pending")} />
|
||||||
|
<SummaryRow label="Full Timeline" value={deliveryLabel(delivery.full_video_timeline_comment || "pending")} />
|
||||||
|
<SummaryRow label="Cleanup" value={deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present")} />
|
||||||
|
</section>
|
||||||
|
<section className="detail-card">
|
||||||
|
<h3>Latest Action</h3>
|
||||||
|
{latestAction ? (
|
||||||
|
<>
|
||||||
|
<SummaryRow label="Action" value={latestAction.action_name} />
|
||||||
|
<SummaryRow label="Status" value={latestAction.status} />
|
||||||
|
<SummaryRow label="Summary" value={latestAction.summary || "-"} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted">暂无动作记录</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-grid">
|
||||||
|
<section className="detail-card">
|
||||||
|
<h3>Steps</h3>
|
||||||
|
<div className="list-stack">
|
||||||
|
{steps?.items?.map((step) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={step.step_name}
|
||||||
|
className={activeStepName === step.step_name ? "list-row selectable active" : "list-row selectable"}
|
||||||
|
onClick={() => onSelectStep?.(step.step_name)}
|
||||||
|
>
|
||||||
|
<span>{step.step_name}</span>
|
||||||
|
<StatusBadge>{step.status}</StatusBadge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeStepName ? (
|
||||||
|
<div className="selected-step-note">
|
||||||
|
当前选中 step: <strong>{activeStepName}</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<section className="detail-card">
|
||||||
|
<h3>Artifacts</h3>
|
||||||
|
<div className="list-stack">
|
||||||
|
{artifacts?.items?.slice(0, 8).map((artifact) => (
|
||||||
|
<div key={`${artifact.artifact_type}:${artifact.path}`} className="list-row">
|
||||||
|
<span>{artifact.artifact_type}</span>
|
||||||
|
<span className="muted">{artifact.path}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/TaskTable.jsx
Normal file
84
frontend/src/components/TaskTable.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import StatusBadge from "./StatusBadge.jsx";
|
||||||
|
import { attentionLabel, deliveryLabel, formatDate, summarizeAttention, summarizeDelivery } from "../lib/format.js";
|
||||||
|
|
||||||
|
function deliveryStateLabel(task) {
|
||||||
|
const delivery = task.delivery_state || {};
|
||||||
|
return {
|
||||||
|
splitComment: deliveryLabel(delivery.split_comment || "pending"),
|
||||||
|
fullComment: deliveryLabel(delivery.full_video_timeline_comment || "pending"),
|
||||||
|
cleanup: deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
|
||||||
|
return (
|
||||||
|
<div className="table-wrap-react">
|
||||||
|
<table className="task-table-react">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>任务</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>关注</th>
|
||||||
|
<th>纯享评论</th>
|
||||||
|
<th>主视频评论</th>
|
||||||
|
<th>清理</th>
|
||||||
|
<th>下次重试</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const delivery = deliveryStateLabel(task);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={task.id}
|
||||||
|
className={selectedTaskId === task.id ? "active" : ""}
|
||||||
|
onClick={() => onSelectTask(task.id)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div className="task-title">{task.title}</div>
|
||||||
|
<div className="task-subtitle">{task.id}</div>
|
||||||
|
</td>
|
||||||
|
<td><StatusBadge>{task.status}</StatusBadge></td>
|
||||||
|
<td><StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge></td>
|
||||||
|
<td><StatusBadge>{delivery.splitComment}</StatusBadge></td>
|
||||||
|
<td><StatusBadge>{delivery.fullComment}</StatusBadge></td>
|
||||||
|
<td><StatusBadge>{delivery.cleanup}</StatusBadge></td>
|
||||||
|
<td>
|
||||||
|
<div>{formatDate(task.retry_state?.next_retry_at)}</div>
|
||||||
|
{task.retry_state?.retry_remaining_seconds != null ? (
|
||||||
|
<div className="task-subtitle">{task.retry_state.retry_remaining_seconds}s</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(task.updated_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
className="nav-btn compact-btn"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onSelectTask(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
打开
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="nav-btn compact-btn strong-btn"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onRunTask?.(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
执行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/lib/format.js
Normal file
51
frontend/src/lib/format.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
export function statusClass(status) {
|
||||||
|
if (["collection_synced", "published", "done", "resolved", "present"].includes(status)) return "good";
|
||||||
|
if (["failed_manual"].includes(status)) return "hot";
|
||||||
|
if (["failed_retryable", "pending", "legacy_untracked", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeAttention(task) {
|
||||||
|
if (task.status === "failed_manual") return "manual_now";
|
||||||
|
if (task.retry_state?.retry_due) return "retry_now";
|
||||||
|
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
|
||||||
|
if (task.status === "running") return "running";
|
||||||
|
return "stable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attentionLabel(value) {
|
||||||
|
return {
|
||||||
|
manual_now: "需人工",
|
||||||
|
retry_now: "立即重试",
|
||||||
|
waiting_retry: "等待重试",
|
||||||
|
running: "处理中",
|
||||||
|
stable: "正常",
|
||||||
|
}[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeDelivery(delivery = {}) {
|
||||||
|
if (delivery.full_video_timeline_comment === "legacy_untracked") return "legacy_untracked";
|
||||||
|
if (delivery.split_comment === "pending" || delivery.full_video_timeline_comment === "pending") return "pending_comment";
|
||||||
|
if (delivery.source_video_present === false || delivery.split_videos_present === false) return "cleanup_removed";
|
||||||
|
return "stable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveryLabel(value) {
|
||||||
|
return {
|
||||||
|
done: "已发送",
|
||||||
|
pending: "待处理",
|
||||||
|
legacy_untracked: "历史未追踪",
|
||||||
|
present: "保留",
|
||||||
|
removed: "已清理",
|
||||||
|
cleanup_removed: "已清理视频",
|
||||||
|
pending_comment: "评论待完成",
|
||||||
|
stable: "正常",
|
||||||
|
}[value] || value;
|
||||||
|
}
|
||||||
11
frontend/src/main.jsx
Normal file
11
frontend/src/main.jsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
553
frontend/src/styles.css
Normal file
553
frontend/src/styles.css
Normal file
@ -0,0 +1,553 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4efe7;
|
||||||
|
--paper: rgba(255, 252, 247, 0.94);
|
||||||
|
--paper-strong: rgba(255, 255, 255, 0.98);
|
||||||
|
--ink: #1d1a16;
|
||||||
|
--muted: #6b6159;
|
||||||
|
--line: rgba(29, 26, 22, 0.12);
|
||||||
|
--accent: #b24b1a;
|
||||||
|
--accent-2: #0e6c62;
|
||||||
|
--warn: #9a690f;
|
||||||
|
--good-bg: rgba(14, 108, 98, 0.12);
|
||||||
|
--warn-bg: rgba(154, 105, 15, 0.12);
|
||||||
|
--hot-bg: rgba(178, 75, 26, 0.12);
|
||||||
|
--shadow: 0 24px 70px rgba(57, 37, 16, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(178, 75, 26, 0.14), transparent 30%),
|
||||||
|
radial-gradient(circle at top right, rgba(14, 108, 98, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f7f2ea 0%, #efe7dc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-shell {
|
||||||
|
width: min(1680px, calc(100vw - 28px));
|
||||||
|
margin: 18px auto 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-sidebar,
|
||||||
|
.panel,
|
||||||
|
.react-topbar {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-sidebar {
|
||||||
|
padding: 22px;
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.good {
|
||||||
|
background: var(--good-bg);
|
||||||
|
color: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.warn {
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.hot {
|
||||||
|
background: var(--hot-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-topbar {
|
||||||
|
padding: 18px 22px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-topbar h2,
|
||||||
|
.panel h2,
|
||||||
|
.panel h3,
|
||||||
|
.react-sidebar h1 {
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-sidebar h1 {
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-copy,
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-react {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: rgba(255,255,255,0.84);
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
background: linear-gradient(135deg, rgba(178, 75, 26, 0.12), rgba(255,255,255,0.95));
|
||||||
|
border-color: rgba(178, 75, 26, 0.28);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(29, 26, 22, 0.07);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.good { background: var(--good-bg); color: var(--accent-2); }
|
||||||
|
.status-badge.warn { background: var(--warn-bg); color: var(--warn); }
|
||||||
|
.status-badge.hot { background: var(--hot-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
.compact-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong-btn {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid,
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card,
|
||||||
|
.detail-card,
|
||||||
|
.placeholder-view {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-layout-react {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-stack-react {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-layout-react {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 340px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout-react {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-grid.compact-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-grid input,
|
||||||
|
.toolbar-grid select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap-react {
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-react {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 980px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-react th,
|
||||||
|
.task-table-react td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-react th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(243, 239, 232, 0.96);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-react tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-react tbody tr:hover {
|
||||||
|
background: rgba(178, 75, 26, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-react tbody tr.active {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-row,
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row:last-child,
|
||||||
|
.detail-panel .detail-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-stack {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row.selectable {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row.selectable.active {
|
||||||
|
border-color: rgba(178, 75, 26, 0.28);
|
||||||
|
background: rgba(255, 248, 240, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-step-note {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions,
|
||||||
|
.service-actions,
|
||||||
|
.card-head-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head-inline {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-input-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-input-grid input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid-react input[type="file"] {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-react-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-note-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inline-note {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255,255,255,0.84);
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inline-note.error {
|
||||||
|
background: rgba(255, 243, 239, 0.92);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,0.74);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-card.dirty {
|
||||||
|
border-color: rgba(178, 75, 26, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-card.error {
|
||||||
|
border-color: rgba(178, 75, 26, 0.44);
|
||||||
|
background: rgba(255, 243, 239, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-card input[type="text"],
|
||||||
|
.settings-field-card input[type="password"],
|
||||||
|
.settings-field-card input[type="number"],
|
||||||
|
.settings-field-card select,
|
||||||
|
.settings-field-card textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-card textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: "IBM Plex Mono", "Noto Sans Mono CJK SC", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-json-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
font-family: "IBM Plex Mono", "Noto Sans Mono CJK SC", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error-react {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-index-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-index-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-index-item.active {
|
||||||
|
border-color: rgba(178, 75, 26, 0.28);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-index-item span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-pre {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 520px;
|
||||||
|
max-height: calc(100vh - 260px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(32, 28, 23, 0.96);
|
||||||
|
color: #f6eee2;
|
||||||
|
font-family: "IBM Plex Mono", "Noto Sans Mono CJK SC", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.react-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-layout-react,
|
||||||
|
.logs-layout-react,
|
||||||
|
.detail-grid,
|
||||||
|
.overview-grid,
|
||||||
|
.settings-field-grid,
|
||||||
|
.stage-input-grid,
|
||||||
|
.toolbar-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/vite.config.mjs
Normal file
34
frontend/vite.config.mjs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
const proxiedPaths = [
|
||||||
|
"/health",
|
||||||
|
"/doctor",
|
||||||
|
"/tasks",
|
||||||
|
"/settings",
|
||||||
|
"/runtime",
|
||||||
|
"/history",
|
||||||
|
"/logs",
|
||||||
|
"/modules",
|
||||||
|
"/scheduler",
|
||||||
|
"/worker",
|
||||||
|
"/stage",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/ui/",
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173,
|
||||||
|
proxy: Object.fromEntries(
|
||||||
|
proxiedPaths.map((path) => [
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
target: "http://127.0.0.1:8787",
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
49
install-systemd.sh
Executable file
49
install-systemd.sh
Executable file
@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SYSTEMD_DIR="$PROJECT_DIR/systemd"
|
||||||
|
RENDER_DIR="$SYSTEMD_DIR/rendered"
|
||||||
|
|
||||||
|
USER_NAME="${BILIUP_NEXT_USER:-$(id -un)}"
|
||||||
|
GROUP_NAME="${BILIUP_NEXT_GROUP:-$(id -gn)}"
|
||||||
|
DEFAULT_PYTHON="$PROJECT_DIR/../.venv/bin/python"
|
||||||
|
if [[ -x "$DEFAULT_PYTHON" ]]; then
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-$(readlink -f "$DEFAULT_PYTHON")}"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-$(command -v python3)}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
render_unit() {
|
||||||
|
local template="$1"
|
||||||
|
local output="$2"
|
||||||
|
sed \
|
||||||
|
-e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \
|
||||||
|
-e "s|__USER__|$USER_NAME|g" \
|
||||||
|
-e "s|__GROUP__|$GROUP_NAME|g" \
|
||||||
|
-e "s|__PYTHON_BIN__|$PYTHON_BIN|g" \
|
||||||
|
"$template" > "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$RENDER_DIR"
|
||||||
|
|
||||||
|
render_unit \
|
||||||
|
"$SYSTEMD_DIR/biliup-next-worker.service.template" \
|
||||||
|
"$RENDER_DIR/biliup-next-worker.service"
|
||||||
|
render_unit \
|
||||||
|
"$SYSTEMD_DIR/biliup-next-api.service.template" \
|
||||||
|
"$RENDER_DIR/biliup-next-api.service"
|
||||||
|
|
||||||
|
sudo install -m 0644 "$RENDER_DIR/biliup-next-worker.service" /etc/systemd/system/biliup-next-worker.service
|
||||||
|
sudo install -m 0644 "$RENDER_DIR/biliup-next-api.service" /etc/systemd/system/biliup-next-api.service
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now biliup-next-worker.service
|
||||||
|
sudo systemctl enable --now biliup-next-api.service
|
||||||
|
|
||||||
|
echo "Installed:"
|
||||||
|
echo " /etc/systemd/system/biliup-next-worker.service"
|
||||||
|
echo " /etc/systemd/system/biliup-next-api.service"
|
||||||
|
echo "User: $USER_NAME"
|
||||||
|
echo "Group: $GROUP_NAME"
|
||||||
|
echo "Python: $PYTHON_BIN"
|
||||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[project]
|
||||||
|
name = "biliup-next"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Next-generation control-plane-first biliup pipeline"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.32.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
biliup-next = "biliup_next.app.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
22
run-api.sh
Executable file
22
run-api.sh
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LOCAL_DEFAULT_PYTHON="$PROJECT_DIR/.venv/bin/python"
|
||||||
|
LEGACY_DEFAULT_PYTHON="$PROJECT_DIR/../.venv/bin/python"
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-$LOCAL_DEFAULT_PYTHON}"
|
||||||
|
|
||||||
|
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||||
|
if [[ -x "$LEGACY_DEFAULT_PYTHON" ]]; then
|
||||||
|
PYTHON_BIN="$LEGACY_DEFAULT_PYTHON"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-python3}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
export PYTHONPATH="$PROJECT_DIR/src"
|
||||||
|
|
||||||
|
exec "$PYTHON_BIN" -m biliup_next.app.cli serve \
|
||||||
|
--host "${BILIUP_NEXT_API_HOST:-0.0.0.0}" \
|
||||||
|
--port "${BILIUP_NEXT_API_PORT:-8787}"
|
||||||
21
run-worker.sh
Executable file
21
run-worker.sh
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LOCAL_DEFAULT_PYTHON="$PROJECT_DIR/.venv/bin/python"
|
||||||
|
LEGACY_DEFAULT_PYTHON="$PROJECT_DIR/../.venv/bin/python"
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-$LOCAL_DEFAULT_PYTHON}"
|
||||||
|
|
||||||
|
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||||
|
if [[ -x "$LEGACY_DEFAULT_PYTHON" ]]; then
|
||||||
|
PYTHON_BIN="$LEGACY_DEFAULT_PYTHON"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-python3}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
export PYTHONPATH="$PROJECT_DIR/src"
|
||||||
|
|
||||||
|
"$PYTHON_BIN" -m biliup_next.app.cli init-workspace
|
||||||
|
exec "$PYTHON_BIN" -m biliup_next.app.cli worker --interval "${BILIUP_NEXT_WORKER_INTERVAL:-5}"
|
||||||
16
runtime/README.md
Normal file
16
runtime/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# runtime
|
||||||
|
|
||||||
|
这个目录用于存放 `biliup-next` 的本地运行资产。
|
||||||
|
|
||||||
|
推荐放在这里的内容:
|
||||||
|
|
||||||
|
- `cookies.json`
|
||||||
|
- `upload_config.json`
|
||||||
|
- `biliup`
|
||||||
|
|
||||||
|
可通过以下命令从父项目导入当前可用版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/theshy/biliup/biliup-next
|
||||||
|
./.venv/bin/biliup-next sync-legacy-assets
|
||||||
|
```
|
||||||
68
setup.sh
Executable file
68
setup.sh
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LOCAL_VENV="$PROJECT_DIR/.venv"
|
||||||
|
LEGACY_VENV="$PROJECT_DIR/../.venv"
|
||||||
|
|
||||||
|
echo "==> biliup-next setup"
|
||||||
|
echo "project: $PROJECT_DIR"
|
||||||
|
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="$(command -v python3)"
|
||||||
|
else
|
||||||
|
echo "python3 not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$LOCAL_VENV" ]]; then
|
||||||
|
echo "==> create virtualenv: $LOCAL_VENV"
|
||||||
|
"$PYTHON_BIN" -m venv "$LOCAL_VENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VENV_PYTHON="$LOCAL_VENV/bin/python"
|
||||||
|
VENV_PIP="$LOCAL_VENV/bin/pip"
|
||||||
|
|
||||||
|
echo "==> install package"
|
||||||
|
"$VENV_PYTHON" -m pip install --upgrade pip >/dev/null
|
||||||
|
"$VENV_PIP" install -e "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if [[ -f "$PROJECT_DIR/config/settings.json" ]]; then
|
||||||
|
echo "==> settings file exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> init workspace"
|
||||||
|
PYTHONPATH="$PROJECT_DIR/src" "$VENV_PYTHON" -m biliup_next.app.cli init-workspace
|
||||||
|
|
||||||
|
echo "==> sync local runtime assets when available"
|
||||||
|
PYTHONPATH="$PROJECT_DIR/src" "$VENV_PYTHON" -m biliup_next.app.cli sync-legacy-assets || true
|
||||||
|
|
||||||
|
echo "==> runtime doctor"
|
||||||
|
PYTHONPATH="$PROJECT_DIR/src" "$VENV_PYTHON" -m biliup_next.app.cli doctor
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Optional external dependencies expected by current legacy-backed providers:"
|
||||||
|
echo " ffmpeg / ffprobe / codex / biliup"
|
||||||
|
echo " cookies.json / upload_config.json / .env from parent project may still be reused"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -r -p "Install systemd services now? [y/N] " INSTALL_SYSTEMD
|
||||||
|
if [[ "${INSTALL_SYSTEMD:-N}" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "==> install systemd units"
|
||||||
|
BILIUP_NEXT_PYTHON="$VENV_PYTHON" bash "$PROJECT_DIR/install-systemd.sh"
|
||||||
|
else
|
||||||
|
echo "skip systemd install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Setup complete.
|
||||||
|
|
||||||
|
Run manually:
|
||||||
|
cd $PROJECT_DIR
|
||||||
|
bash run-worker.sh
|
||||||
|
bash run-api.sh
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
http://127.0.0.1:8787/
|
||||||
|
EOF
|
||||||
30
smoke-test.sh
Executable file
30
smoke-test.sh
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PYTHON_BIN="${BILIUP_NEXT_PYTHON:-$PROJECT_DIR/.venv/bin/python}"
|
||||||
|
|
||||||
|
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||||
|
echo "python not found: $PYTHON_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
echo "==> doctor"
|
||||||
|
PYTHONPATH="$PROJECT_DIR/src" "$PYTHON_BIN" -m biliup_next.app.cli doctor
|
||||||
|
|
||||||
|
echo "==> init-workspace"
|
||||||
|
PYTHONPATH="$PROJECT_DIR/src" "$PYTHON_BIN" -m biliup_next.app.cli init-workspace
|
||||||
|
|
||||||
|
echo "==> api health"
|
||||||
|
curl -fsS http://127.0.0.1:8787/health
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "==> api settings schema"
|
||||||
|
curl -fsS http://127.0.0.1:8787/settings/schema >/dev/null
|
||||||
|
|
||||||
|
echo "==> api tasks"
|
||||||
|
curl -fsS http://127.0.0.1:8787/tasks?limit=5 >/dev/null
|
||||||
|
|
||||||
|
echo "==> done"
|
||||||
6
src/biliup_next.egg-info/PKG-INFO
Normal file
6
src/biliup_next.egg-info/PKG-INFO
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: biliup-next
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: Next-generation control-plane-first biliup pipeline
|
||||||
|
Requires-Python: >=3.11
|
||||||
|
Requires-Dist: requests>=2.32.0
|
||||||
42
src/biliup_next.egg-info/SOURCES.txt
Normal file
42
src/biliup_next.egg-info/SOURCES.txt
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
src/biliup_next/__init__.py
|
||||||
|
src/biliup_next.egg-info/PKG-INFO
|
||||||
|
src/biliup_next.egg-info/SOURCES.txt
|
||||||
|
src/biliup_next.egg-info/dependency_links.txt
|
||||||
|
src/biliup_next.egg-info/entry_points.txt
|
||||||
|
src/biliup_next.egg-info/requires.txt
|
||||||
|
src/biliup_next.egg-info/top_level.txt
|
||||||
|
src/biliup_next/app/api_server.py
|
||||||
|
src/biliup_next/app/bootstrap.py
|
||||||
|
src/biliup_next/app/cli.py
|
||||||
|
src/biliup_next/app/dashboard.py
|
||||||
|
src/biliup_next/app/worker.py
|
||||||
|
src/biliup_next/core/config.py
|
||||||
|
src/biliup_next/core/errors.py
|
||||||
|
src/biliup_next/core/models.py
|
||||||
|
src/biliup_next/core/providers.py
|
||||||
|
src/biliup_next/core/registry.py
|
||||||
|
src/biliup_next/infra/db.py
|
||||||
|
src/biliup_next/infra/legacy_paths.py
|
||||||
|
src/biliup_next/infra/log_reader.py
|
||||||
|
src/biliup_next/infra/plugin_loader.py
|
||||||
|
src/biliup_next/infra/runtime_doctor.py
|
||||||
|
src/biliup_next/infra/stage_importer.py
|
||||||
|
src/biliup_next/infra/systemd_runtime.py
|
||||||
|
src/biliup_next/infra/task_repository.py
|
||||||
|
src/biliup_next/infra/task_reset.py
|
||||||
|
src/biliup_next/infra/adapters/bilibili_collection_legacy.py
|
||||||
|
src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py
|
||||||
|
src/biliup_next/infra/adapters/biliup_publish_legacy.py
|
||||||
|
src/biliup_next/infra/adapters/codex_legacy.py
|
||||||
|
src/biliup_next/infra/adapters/ffmpeg_split_legacy.py
|
||||||
|
src/biliup_next/infra/adapters/groq_legacy.py
|
||||||
|
src/biliup_next/modules/collection/service.py
|
||||||
|
src/biliup_next/modules/comment/service.py
|
||||||
|
src/biliup_next/modules/ingest/service.py
|
||||||
|
src/biliup_next/modules/ingest/providers/local_file.py
|
||||||
|
src/biliup_next/modules/publish/service.py
|
||||||
|
src/biliup_next/modules/song_detect/service.py
|
||||||
|
src/biliup_next/modules/split/service.py
|
||||||
|
src/biliup_next/modules/transcribe/service.py
|
||||||
1
src/biliup_next.egg-info/dependency_links.txt
Normal file
1
src/biliup_next.egg-info/dependency_links.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
2
src/biliup_next.egg-info/entry_points.txt
Normal file
2
src/biliup_next.egg-info/entry_points.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
biliup-next = biliup_next.app.cli:main
|
||||||
1
src/biliup_next.egg-info/requires.txt
Normal file
1
src/biliup_next.egg-info/requires.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
requests>=2.32.0
|
||||||
1
src/biliup_next.egg-info/top_level.txt
Normal file
1
src/biliup_next.egg-info/top_level.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
biliup_next
|
||||||
1
src/biliup_next/__init__.py
Normal file
1
src/biliup_next/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""biliup-next package."""
|
||||||
530
src/biliup_next/app/api_server.py
Normal file
530
src/biliup_next/app/api_server.py
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cgi
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
|
from biliup_next.app.task_actions import reset_to_step_action
|
||||||
|
from biliup_next.app.task_actions import retry_step_action
|
||||||
|
from biliup_next.app.task_actions import run_task_action
|
||||||
|
from biliup_next.app.bootstrap import ensure_initialized
|
||||||
|
from biliup_next.app.dashboard import render_dashboard_html
|
||||||
|
from biliup_next.app.retry_meta import retry_meta_for_step
|
||||||
|
from biliup_next.app.scheduler import build_scheduler_preview
|
||||||
|
from biliup_next.app.worker import run_once
|
||||||
|
from biliup_next.core.config import SettingsService
|
||||||
|
from biliup_next.core.models import ActionRecord, utc_now_iso
|
||||||
|
from biliup_next.infra.log_reader import LogReader
|
||||||
|
from biliup_next.infra.runtime_doctor import RuntimeDoctor
|
||||||
|
from biliup_next.infra.stage_importer import StageImporter
|
||||||
|
from biliup_next.infra.storage_guard import mb_to_bytes
|
||||||
|
from biliup_next.infra.systemd_runtime import SystemdRuntime
|
||||||
|
|
||||||
|
|
||||||
|
class ApiHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "biliup-next/0.1"
|
||||||
|
|
||||||
|
def _task_payload(self, task_id: str, state: dict[str, object]) -> dict[str, object] | None:
|
||||||
|
task = state["repo"].get_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
return None
|
||||||
|
payload = task.to_dict()
|
||||||
|
retry_state = self._task_retry_state(task_id, state)
|
||||||
|
if retry_state:
|
||||||
|
payload["retry_state"] = retry_state
|
||||||
|
payload["delivery_state"] = self._task_delivery_state(task_id, state)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _step_payload(self, step, state: dict[str, object]) -> dict[str, object]: # type: ignore[no-untyped-def]
|
||||||
|
payload = step.to_dict()
|
||||||
|
retry_meta = retry_meta_for_step(step, state["settings"])
|
||||||
|
if retry_meta:
|
||||||
|
payload.update(retry_meta)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _task_retry_state(self, task_id: str, state: dict[str, object]) -> dict[str, object] | None:
|
||||||
|
for step in state["repo"].list_steps(task_id):
|
||||||
|
retry_meta = retry_meta_for_step(step, state["settings"])
|
||||||
|
if retry_meta:
|
||||||
|
return {"step_name": step.step_name, **retry_meta}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _task_delivery_state(self, task_id: str, state: dict[str, object]) -> dict[str, object]:
|
||||||
|
task = state["repo"].get_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
return {}
|
||||||
|
session_dir = Path(str(state["settings"]["paths"]["session_dir"])) / task.title
|
||||||
|
source_path = Path(task.source_path)
|
||||||
|
split_dir = session_dir / "split_video"
|
||||||
|
legacy_comment_done = (session_dir / "comment_done.flag").exists()
|
||||||
|
|
||||||
|
def comment_status(flag_name: str, *, enabled: bool) -> str:
|
||||||
|
if not enabled:
|
||||||
|
return "disabled"
|
||||||
|
if flag_name == "comment_full_done.flag" and legacy_comment_done and not (session_dir / flag_name).exists():
|
||||||
|
return "legacy_untracked"
|
||||||
|
return "done" if (session_dir / flag_name).exists() else "pending"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"split_comment": comment_status("comment_split_done.flag", enabled=state["settings"]["comment"].get("post_split_comment", True)),
|
||||||
|
"full_video_timeline_comment": comment_status(
|
||||||
|
"comment_full_done.flag",
|
||||||
|
enabled=state["settings"]["comment"].get("post_full_video_timeline_comment", True),
|
||||||
|
),
|
||||||
|
"full_video_bvid_resolved": (session_dir / "full_video_bvid.txt").exists(),
|
||||||
|
"source_video_present": source_path.exists(),
|
||||||
|
"split_videos_present": split_dir.exists(),
|
||||||
|
"cleanup_enabled": {
|
||||||
|
"delete_source_video_after_collection_synced": state["settings"].get("cleanup", {}).get("delete_source_video_after_collection_synced", False),
|
||||||
|
"delete_split_videos_after_collection_synced": state["settings"].get("cleanup", {}).get("delete_split_videos_after_collection_synced", False),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _serve_asset(self, asset_name: str) -> None:
|
||||||
|
root = ensure_initialized()["root"]
|
||||||
|
asset_path = root / "src" / "biliup_next" / "app" / "static" / asset_name
|
||||||
|
if not asset_path.exists():
|
||||||
|
self._json({"error": "asset not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
content_type = self._guess_content_type(asset_path)
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(asset_path.read_bytes())
|
||||||
|
|
||||||
|
def _guess_content_type(self, path: Path) -> str:
|
||||||
|
guessed, _ = mimetypes.guess_type(path.name)
|
||||||
|
if guessed:
|
||||||
|
if guessed.startswith("text/") or guessed in {"application/javascript", "application/json"}:
|
||||||
|
return f"{guessed}; charset=utf-8"
|
||||||
|
return guessed
|
||||||
|
return "application/octet-stream"
|
||||||
|
|
||||||
|
def _frontend_dist_dir(self) -> Path:
|
||||||
|
root = ensure_initialized()["root"]
|
||||||
|
return root / "frontend" / "dist"
|
||||||
|
|
||||||
|
def _frontend_dist_ready(self) -> bool:
|
||||||
|
dist = self._frontend_dist_dir()
|
||||||
|
return (dist / "index.html").exists()
|
||||||
|
|
||||||
|
def _serve_frontend_dist(self, parsed_path: str) -> bool:
|
||||||
|
dist = self._frontend_dist_dir()
|
||||||
|
if not (dist / "index.html").exists():
|
||||||
|
return False
|
||||||
|
if parsed_path in {"/ui", "/ui/"}:
|
||||||
|
self._html((dist / "index.html").read_text(encoding="utf-8"))
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not parsed_path.startswith("/ui/"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
relative = parsed_path.removeprefix("/ui/")
|
||||||
|
asset_path = dist / relative
|
||||||
|
if asset_path.exists() and asset_path.is_file():
|
||||||
|
body = asset_path.read_bytes()
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", self._guess_content_type(asset_path))
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if "." not in Path(relative).name:
|
||||||
|
self._html((dist / "index.html").read_text(encoding="utf-8"))
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._json({"error": "frontend asset not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path.startswith("/ui") and self._serve_frontend_dist(parsed.path):
|
||||||
|
return
|
||||||
|
if not self._check_auth(parsed.path):
|
||||||
|
return
|
||||||
|
if parsed.path.startswith("/assets/"):
|
||||||
|
self._serve_asset(parsed.path.removeprefix("/assets/"))
|
||||||
|
return
|
||||||
|
if parsed.path == "/":
|
||||||
|
self._html(render_dashboard_html())
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/health":
|
||||||
|
self._json({"ok": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/settings":
|
||||||
|
state = ensure_initialized()
|
||||||
|
service = SettingsService(state["root"])
|
||||||
|
self._json(service.load_redacted().settings)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/settings/schema":
|
||||||
|
state = ensure_initialized()
|
||||||
|
service = SettingsService(state["root"])
|
||||||
|
self._json(service.load().schema)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/doctor":
|
||||||
|
doctor = RuntimeDoctor(ensure_initialized()["root"])
|
||||||
|
self._json(doctor.run())
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/runtime/services":
|
||||||
|
self._json(SystemdRuntime().list_services())
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/scheduler/preview":
|
||||||
|
state = ensure_initialized()
|
||||||
|
self._json(build_scheduler_preview(state, include_stage_scan=False, limit=200))
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/logs":
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
name = query.get("name", [None])[0]
|
||||||
|
if not name:
|
||||||
|
self._json(LogReader().list_logs())
|
||||||
|
return
|
||||||
|
lines = int(query.get("lines", ["200"])[0])
|
||||||
|
contains = query.get("contains", [None])[0]
|
||||||
|
self._json(LogReader().tail(name, lines, contains))
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/history":
|
||||||
|
state = ensure_initialized()
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
limit = int(query.get("limit", ["100"])[0])
|
||||||
|
task_id = query.get("task_id", [None])[0]
|
||||||
|
action_name = query.get("action_name", [None])[0]
|
||||||
|
status = query.get("status", [None])[0]
|
||||||
|
items = [
|
||||||
|
item.to_dict()
|
||||||
|
for item in state["repo"].list_action_records(
|
||||||
|
task_id=task_id,
|
||||||
|
limit=limit,
|
||||||
|
action_name=action_name,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self._json({"items": items})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/modules":
|
||||||
|
state = ensure_initialized()
|
||||||
|
self._json({"items": state["registry"].list_manifests(), "discovered_manifests": state["manifests"]})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/tasks":
|
||||||
|
state = ensure_initialized()
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
limit = int(query.get("limit", ["100"])[0])
|
||||||
|
tasks = [self._task_payload(task.id, state) for task in state["repo"].list_tasks(limit=limit)]
|
||||||
|
self._json({"items": tasks})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path.startswith("/tasks/"):
|
||||||
|
state = ensure_initialized()
|
||||||
|
parts = [unquote(p) for p in parsed.path.split("/") if p]
|
||||||
|
if len(parts) == 2:
|
||||||
|
task = self._task_payload(parts[1], state)
|
||||||
|
if task is None:
|
||||||
|
self._json({"error": "task not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
self._json(task)
|
||||||
|
return
|
||||||
|
if len(parts) == 3 and parts[2] == "steps":
|
||||||
|
steps = [self._step_payload(step, state) for step in state["repo"].list_steps(parts[1])]
|
||||||
|
self._json({"items": steps})
|
||||||
|
return
|
||||||
|
if len(parts) == 3 and parts[2] == "artifacts":
|
||||||
|
artifacts = [artifact.to_dict() for artifact in state["repo"].list_artifacts(parts[1])]
|
||||||
|
self._json({"items": artifacts})
|
||||||
|
return
|
||||||
|
if len(parts) == 3 and parts[2] == "history":
|
||||||
|
actions = [item.to_dict() for item in state["repo"].list_action_records(parts[1], limit=100)]
|
||||||
|
self._json({"items": actions})
|
||||||
|
return
|
||||||
|
if len(parts) == 3 and parts[2] == "timeline":
|
||||||
|
task = state["repo"].get_task(parts[1])
|
||||||
|
if task is None:
|
||||||
|
self._json({"error": "task not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
steps = state["repo"].list_steps(parts[1])
|
||||||
|
artifacts = state["repo"].list_artifacts(parts[1])
|
||||||
|
actions = state["repo"].list_action_records(parts[1], limit=200)
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
if task.created_at:
|
||||||
|
items.append({
|
||||||
|
"kind": "task",
|
||||||
|
"time": task.created_at,
|
||||||
|
"title": "Task Created",
|
||||||
|
"summary": task.title,
|
||||||
|
"status": task.status,
|
||||||
|
})
|
||||||
|
if task.updated_at and task.updated_at != task.created_at:
|
||||||
|
items.append({
|
||||||
|
"kind": "task",
|
||||||
|
"time": task.updated_at,
|
||||||
|
"title": "Task Updated",
|
||||||
|
"summary": task.status,
|
||||||
|
"status": task.status,
|
||||||
|
})
|
||||||
|
for step in steps:
|
||||||
|
if step.started_at:
|
||||||
|
items.append({
|
||||||
|
"kind": "step",
|
||||||
|
"time": step.started_at,
|
||||||
|
"title": f"{step.step_name} started",
|
||||||
|
"summary": step.status,
|
||||||
|
"status": step.status,
|
||||||
|
})
|
||||||
|
if step.finished_at:
|
||||||
|
retry_meta = retry_meta_for_step(step, state["settings"])
|
||||||
|
retry_note = ""
|
||||||
|
if retry_meta and retry_meta.get("next_retry_at"):
|
||||||
|
retry_note = f" | next retry: {retry_meta['next_retry_at']}"
|
||||||
|
items.append({
|
||||||
|
"kind": "step",
|
||||||
|
"time": step.finished_at,
|
||||||
|
"title": f"{step.step_name} finished",
|
||||||
|
"summary": f"{step.error_message or step.status}{retry_note}",
|
||||||
|
"status": step.status,
|
||||||
|
"retry_state": retry_meta,
|
||||||
|
})
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact.created_at:
|
||||||
|
items.append({
|
||||||
|
"kind": "artifact",
|
||||||
|
"time": artifact.created_at,
|
||||||
|
"title": artifact.artifact_type,
|
||||||
|
"summary": artifact.path,
|
||||||
|
"status": "created",
|
||||||
|
})
|
||||||
|
for action in actions:
|
||||||
|
summary = action.summary
|
||||||
|
try:
|
||||||
|
details = json.loads(action.details_json or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
details = {}
|
||||||
|
if action.action_name == "comment" and isinstance(details, dict):
|
||||||
|
split_status = details.get("split", {}).get("status")
|
||||||
|
full_status = details.get("full", {}).get("status")
|
||||||
|
fragments = []
|
||||||
|
if split_status:
|
||||||
|
fragments.append(f"split={split_status}")
|
||||||
|
if full_status:
|
||||||
|
fragments.append(f"full={full_status}")
|
||||||
|
if fragments:
|
||||||
|
summary = f"{summary} | {' '.join(fragments)}"
|
||||||
|
if action.action_name in {"collection_a", "collection_b"} and isinstance(details, dict):
|
||||||
|
cleanup = details.get("result", {}).get("cleanup") or details.get("cleanup")
|
||||||
|
if isinstance(cleanup, dict):
|
||||||
|
removed = cleanup.get("removed") or []
|
||||||
|
if removed:
|
||||||
|
summary = f"{summary} | cleanup removed={len(removed)}"
|
||||||
|
items.append({
|
||||||
|
"kind": "action",
|
||||||
|
"time": action.created_at,
|
||||||
|
"title": action.action_name,
|
||||||
|
"summary": summary,
|
||||||
|
"status": action.status,
|
||||||
|
})
|
||||||
|
items.sort(key=lambda item: str(item["time"]), reverse=True)
|
||||||
|
self._json({"items": items})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
def do_PUT(self) -> None: # noqa: N802
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if not self._check_auth(parsed.path):
|
||||||
|
return
|
||||||
|
if parsed.path != "/settings":
|
||||||
|
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
root = ensure_initialized()["root"]
|
||||||
|
service = SettingsService(root)
|
||||||
|
service.save_staged_from_redacted(payload)
|
||||||
|
service.promote_staged()
|
||||||
|
self._json({"ok": True})
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if not self._check_auth(parsed.path):
|
||||||
|
return
|
||||||
|
if parsed.path != "/tasks":
|
||||||
|
if parsed.path.startswith("/tasks/"):
|
||||||
|
parts = [unquote(p) for p in parsed.path.split("/") if p]
|
||||||
|
if len(parts) == 4 and parts[0] == "tasks" and parts[2] == "actions":
|
||||||
|
task_id = parts[1]
|
||||||
|
action = parts[3]
|
||||||
|
if action == "run":
|
||||||
|
result = run_task_action(task_id)
|
||||||
|
self._json(result, status=HTTPStatus.ACCEPTED)
|
||||||
|
return
|
||||||
|
if action == "retry-step":
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
step_name = payload.get("step_name")
|
||||||
|
if not step_name:
|
||||||
|
self._json({"error": "missing step_name"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
result = retry_step_action(task_id, step_name)
|
||||||
|
self._json(result, status=HTTPStatus.ACCEPTED)
|
||||||
|
return
|
||||||
|
if action == "reset-to-step":
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
step_name = payload.get("step_name")
|
||||||
|
if not step_name:
|
||||||
|
self._json({"error": "missing step_name"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
result = reset_to_step_action(task_id, step_name)
|
||||||
|
self._json(result, status=HTTPStatus.ACCEPTED)
|
||||||
|
return
|
||||||
|
if parsed.path == "/worker/run-once":
|
||||||
|
payload = run_once()
|
||||||
|
self._record_action(None, "worker_run_once", "ok", "worker run once invoked", payload)
|
||||||
|
self._json(payload, status=HTTPStatus.ACCEPTED)
|
||||||
|
return
|
||||||
|
if parsed.path.startswith("/runtime/services/"):
|
||||||
|
parts = [unquote(p) for p in parsed.path.split("/") if p]
|
||||||
|
if len(parts) == 4 and parts[0] == "runtime" and parts[1] == "services":
|
||||||
|
try:
|
||||||
|
payload = SystemdRuntime().act(parts[2], parts[3])
|
||||||
|
except ValueError as exc:
|
||||||
|
self._json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self._record_action(None, "service_action", "ok" if payload.get("command_ok") else "error", f"{parts[3]} {parts[2]}", payload)
|
||||||
|
self._json(payload, status=HTTPStatus.ACCEPTED)
|
||||||
|
return
|
||||||
|
if parsed.path == "/stage/import":
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
source_path = payload.get("source_path")
|
||||||
|
if not source_path:
|
||||||
|
self._json({"error": "missing source_path"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
state = ensure_initialized()
|
||||||
|
stage_dir = Path(state["settings"]["paths"]["stage_dir"])
|
||||||
|
try:
|
||||||
|
result = StageImporter().import_file(Path(source_path), stage_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
self._json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self._record_action(None, "stage_import", "ok", "imported file into stage", result)
|
||||||
|
self._json(result, status=HTTPStatus.CREATED)
|
||||||
|
return
|
||||||
|
if parsed.path == "/stage/upload":
|
||||||
|
content_type = self.headers.get("Content-Type", "")
|
||||||
|
if "multipart/form-data" not in content_type:
|
||||||
|
self._json({"error": "content-type must be multipart/form-data"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
form = cgi.FieldStorage(
|
||||||
|
fp=self.rfile,
|
||||||
|
headers=self.headers,
|
||||||
|
environ={
|
||||||
|
"REQUEST_METHOD": "POST",
|
||||||
|
"CONTENT_TYPE": content_type,
|
||||||
|
"CONTENT_LENGTH": self.headers.get("Content-Length", "0"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
file_item = form["file"] if "file" in form else None
|
||||||
|
if file_item is None or not getattr(file_item, "filename", None):
|
||||||
|
self._json({"error": "missing file"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
state = ensure_initialized()
|
||||||
|
stage_dir = Path(state["settings"]["paths"]["stage_dir"])
|
||||||
|
try:
|
||||||
|
result = StageImporter().import_upload(file_item.filename, file_item.file, stage_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
self._json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self._record_action(None, "stage_upload", "ok", "uploaded file into stage", result)
|
||||||
|
self._json(result, status=HTTPStatus.CREATED)
|
||||||
|
return
|
||||||
|
if parsed.path == "/scheduler/run-once":
|
||||||
|
result = run_once()
|
||||||
|
self._record_action(None, "scheduler_run_once", "ok", "scheduler run once completed", result.get("scheduler", {}))
|
||||||
|
self._json(result, status=HTTPStatus.ACCEPTED)
|
||||||
|
return
|
||||||
|
self._json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
source_path = payload.get("source_path")
|
||||||
|
if not source_path:
|
||||||
|
self._json({"error": "missing source_path"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
state = ensure_initialized()
|
||||||
|
try:
|
||||||
|
task = state["ingest_service"].create_task_from_file(
|
||||||
|
Path(source_path),
|
||||||
|
state["settings"]["ingest"],
|
||||||
|
)
|
||||||
|
except Exception as exc: # keep API small for now
|
||||||
|
status = HTTPStatus.CONFLICT if exc.__class__.__name__ == "ModuleError" else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
payload = exc.to_dict() if hasattr(exc, "to_dict") else {"error": str(exc)}
|
||||||
|
self._json(payload, status=status)
|
||||||
|
return
|
||||||
|
self._json(task.to_dict(), status=HTTPStatus.CREATED)
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args) -> None: # noqa: A003
|
||||||
|
return
|
||||||
|
|
||||||
|
def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||||
|
body = html.encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _record_action(self, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None:
|
||||||
|
state = ensure_initialized()
|
||||||
|
state["repo"].add_action_record(
|
||||||
|
ActionRecord(
|
||||||
|
id=None,
|
||||||
|
task_id=task_id,
|
||||||
|
action_name=action_name,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
details_json=json.dumps(details, ensure_ascii=False),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_auth(self, path: str) -> bool:
|
||||||
|
if path in {"/", "/health", "/ui", "/ui/"} or path.startswith("/assets/") or path.startswith("/ui/assets/"):
|
||||||
|
return True
|
||||||
|
state = ensure_initialized()
|
||||||
|
expected = str(state["settings"]["runtime"].get("control_token", "")).strip()
|
||||||
|
if not expected:
|
||||||
|
return True
|
||||||
|
provided = self.headers.get("X-Biliup-Token", "").strip()
|
||||||
|
if provided == expected:
|
||||||
|
return True
|
||||||
|
self._json({"error": "unauthorized"}, status=HTTPStatus.UNAUTHORIZED)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def serve(host: str, port: int) -> None:
|
||||||
|
ensure_initialized()
|
||||||
|
server = ThreadingHTTPServer((host, port), ApiHandler)
|
||||||
|
print(f"biliup-next api listening on http://{host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
77
src/biliup_next/app/bootstrap.py
Normal file
77
src/biliup_next/app/bootstrap.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from biliup_next.core.config import SettingsService
|
||||||
|
from biliup_next.core.registry import Registry
|
||||||
|
from biliup_next.infra.comment_flag_migration import CommentFlagMigrationService
|
||||||
|
from biliup_next.infra.db import Database
|
||||||
|
from biliup_next.infra.plugin_loader import PluginLoader
|
||||||
|
from biliup_next.infra.task_repository import TaskRepository
|
||||||
|
from biliup_next.modules.collection.service import CollectionService
|
||||||
|
from biliup_next.modules.comment.service import CommentService
|
||||||
|
from biliup_next.modules.ingest.service import IngestService
|
||||||
|
from biliup_next.modules.publish.service import PublishService
|
||||||
|
from biliup_next.modules.song_detect.service import SongDetectService
|
||||||
|
from biliup_next.modules.split.service import SplitService
|
||||||
|
from biliup_next.modules.transcribe.service import TranscribeService
|
||||||
|
|
||||||
|
|
||||||
|
def project_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_initialized() -> dict[str, object]:
|
||||||
|
root = project_root()
|
||||||
|
settings_service = SettingsService(root)
|
||||||
|
bundle = settings_service.load()
|
||||||
|
db_path = (root / bundle.settings["runtime"]["database_path"]).resolve()
|
||||||
|
db = Database(db_path)
|
||||||
|
db.initialize()
|
||||||
|
repo = TaskRepository(db)
|
||||||
|
registry = Registry()
|
||||||
|
plugin_loader = PluginLoader(root)
|
||||||
|
manifests = plugin_loader.load_manifests()
|
||||||
|
for manifest in manifests:
|
||||||
|
if not manifest.enabled_by_default:
|
||||||
|
continue
|
||||||
|
provider = plugin_loader.instantiate_provider(manifest)
|
||||||
|
provider_manifest = getattr(provider, "manifest", None)
|
||||||
|
if provider_manifest is None:
|
||||||
|
raise RuntimeError(f"provider missing manifest: {manifest.entrypoint}")
|
||||||
|
if provider_manifest.id != manifest.id or provider_manifest.provider_type != manifest.provider_type:
|
||||||
|
raise RuntimeError(f"provider manifest mismatch: {manifest.entrypoint}")
|
||||||
|
registry.register(
|
||||||
|
manifest.provider_type,
|
||||||
|
manifest.id,
|
||||||
|
provider,
|
||||||
|
provider_manifest,
|
||||||
|
)
|
||||||
|
session_dir = (root / bundle.settings["paths"]["session_dir"]).resolve()
|
||||||
|
imported = repo.bootstrap_from_legacy_sessions(session_dir)
|
||||||
|
comment_flag_migration = CommentFlagMigrationService().migrate(session_dir)
|
||||||
|
ingest_service = IngestService(registry, repo)
|
||||||
|
transcribe_service = TranscribeService(registry, repo)
|
||||||
|
song_detect_service = SongDetectService(registry, repo)
|
||||||
|
split_service = SplitService(registry, repo)
|
||||||
|
publish_service = PublishService(registry, repo)
|
||||||
|
comment_service = CommentService(registry, repo)
|
||||||
|
collection_service = CollectionService(registry, repo)
|
||||||
|
return {
|
||||||
|
"root": root,
|
||||||
|
"settings": bundle.settings,
|
||||||
|
"db": db,
|
||||||
|
"repo": repo,
|
||||||
|
"registry": registry,
|
||||||
|
"manifests": [asdict(m) for m in manifests],
|
||||||
|
"ingest_service": ingest_service,
|
||||||
|
"transcribe_service": transcribe_service,
|
||||||
|
"song_detect_service": song_detect_service,
|
||||||
|
"split_service": split_service,
|
||||||
|
"publish_service": publish_service,
|
||||||
|
"comment_service": comment_service,
|
||||||
|
"collection_service": collection_service,
|
||||||
|
"imported": imported,
|
||||||
|
"comment_flag_migration": comment_flag_migration,
|
||||||
|
}
|
||||||
120
src/biliup_next/app/cli.py
Normal file
120
src/biliup_next/app/cli.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from biliup_next.app.api_server import serve
|
||||||
|
from biliup_next.app.bootstrap import ensure_initialized
|
||||||
|
from biliup_next.app.scheduler import run_scheduler_cycle
|
||||||
|
from biliup_next.app.worker import run_forever, run_once
|
||||||
|
from biliup_next.infra.legacy_asset_sync import LegacyAssetSync
|
||||||
|
from biliup_next.infra.runtime_doctor import RuntimeDoctor
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(prog="biliup-next")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("init")
|
||||||
|
sub.add_parser("doctor")
|
||||||
|
sub.add_parser("list-tasks")
|
||||||
|
sub.add_parser("list-modules")
|
||||||
|
sub.add_parser("run-once")
|
||||||
|
sub.add_parser("schedule-once")
|
||||||
|
sub.add_parser("init-workspace")
|
||||||
|
sub.add_parser("sync-legacy-assets")
|
||||||
|
sub.add_parser("scan-stage")
|
||||||
|
delete_task_parser = sub.add_parser("delete-task")
|
||||||
|
delete_task_parser.add_argument("task_id")
|
||||||
|
worker_parser = sub.add_parser("worker")
|
||||||
|
worker_parser.add_argument("--interval", type=int, default=5)
|
||||||
|
|
||||||
|
create_task_parser = sub.add_parser("create-task")
|
||||||
|
create_task_parser.add_argument("source_path")
|
||||||
|
|
||||||
|
serve_parser = sub.add_parser("serve")
|
||||||
|
serve_parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
serve_parser.add_argument("--port", type=int, default=8787)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "init":
|
||||||
|
state = ensure_initialized()
|
||||||
|
print(json.dumps({"ok": True, "imported": state["imported"]}, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "doctor":
|
||||||
|
root = ensure_initialized()["root"]
|
||||||
|
doctor = RuntimeDoctor(root)
|
||||||
|
print(json.dumps(doctor.run(), ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "init-workspace":
|
||||||
|
state = ensure_initialized()
|
||||||
|
paths = state["settings"]["paths"]
|
||||||
|
created = []
|
||||||
|
for key in ("stage_dir", "backup_dir", "session_dir"):
|
||||||
|
path = (state["root"] / paths[key]).resolve()
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
created.append(str(path))
|
||||||
|
print(json.dumps({"ok": True, "created": created}, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "sync-legacy-assets":
|
||||||
|
root = ensure_initialized()["root"]
|
||||||
|
result = LegacyAssetSync(root).sync()
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "list-tasks":
|
||||||
|
state = ensure_initialized()
|
||||||
|
tasks = [task.to_dict() for task in state["repo"].list_tasks()]
|
||||||
|
print(json.dumps({"items": tasks}, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "list-modules":
|
||||||
|
state = ensure_initialized()
|
||||||
|
print(json.dumps({"items": state["registry"].list_manifests()}, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "delete-task":
|
||||||
|
state = ensure_initialized()
|
||||||
|
state["repo"].delete_task(args.task_id)
|
||||||
|
print(json.dumps({"ok": True, "deleted_task_id": args.task_id}, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "scan-stage":
|
||||||
|
state = ensure_initialized()
|
||||||
|
settings = dict(state["settings"]["ingest"])
|
||||||
|
settings.update(state["settings"]["paths"])
|
||||||
|
print(json.dumps(state["ingest_service"].scan_stage(settings), ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "create-task":
|
||||||
|
state = ensure_initialized()
|
||||||
|
task = state["ingest_service"].create_task_from_file(
|
||||||
|
Path(args.source_path),
|
||||||
|
state["settings"]["ingest"],
|
||||||
|
)
|
||||||
|
print(json.dumps(task.to_dict(), ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "run-once":
|
||||||
|
print(json.dumps(run_once(), ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "schedule-once":
|
||||||
|
print(json.dumps(run_scheduler_cycle(include_stage_scan=True, limit=200).preview, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "worker":
|
||||||
|
run_forever(args.interval)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "serve":
|
||||||
|
serve(args.host, args.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
346
src/biliup_next/app/dashboard.py
Normal file
346
src/biliup_next/app/dashboard.py
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def render_dashboard_html() -> str:
|
||||||
|
return """<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>biliup-next Control</title>
|
||||||
|
<link rel="stylesheet" href="/assets/dashboard.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<p class="eyebrow">Biliup Next</p>
|
||||||
|
<h1>Control</h1>
|
||||||
|
<p class="sidebar-copy">围绕任务状态、运行时健康和配置管理组织的本地控制面。</p>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<button class="nav-btn active" data-view="overview">Overview</button>
|
||||||
|
<button class="nav-btn" data-view="tasks">Tasks</button>
|
||||||
|
<button class="nav-btn" data-view="settings">Settings</button>
|
||||||
|
<button class="nav-btn" data-view="logs">Logs</button>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<label class="sidebar-label">Control Token</label>
|
||||||
|
<div class="sidebar-token">
|
||||||
|
<input id="tokenInput" placeholder="optional control token" />
|
||||||
|
<button id="saveTokenBtn" class="secondary compact">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="button-stack">
|
||||||
|
<button id="refreshBtn">刷新视图</button>
|
||||||
|
<button id="runOnceBtn" class="secondary">执行一轮 Worker</button>
|
||||||
|
<button id="saveSettingsBtn" class="secondary">保存 Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Operational Workspace</p>
|
||||||
|
<h2 id="viewTitle">Overview</h2>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-meta">
|
||||||
|
<div class="status-chip">API · <span id="healthValue">-</span></div>
|
||||||
|
<div class="status-chip">Doctor · <span id="doctorValue">-</span></div>
|
||||||
|
<div class="status-chip">Tasks · <span id="tasksValue">-</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="banner" class="banner"></div>
|
||||||
|
|
||||||
|
<section class="view active" data-view="overview">
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Runtime Snapshot</h3></div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Health</span>
|
||||||
|
<strong id="overviewHealthValue" class="stat-value">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Doctor</span>
|
||||||
|
<strong id="overviewDoctorValue" class="stat-value">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Tasks</span>
|
||||||
|
<strong id="overviewTasksValue" class="stat-value">-</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="overviewTaskSummary" class="summary-strip" style="margin-top:14px;"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Import To Stage</h3></div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<input id="stageSourcePath" placeholder="/absolute/path/to/video.mp4" />
|
||||||
|
<button id="importStageBtn" class="secondary">复制到隔离 Stage</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid upload-grid">
|
||||||
|
<input id="stageFileInput" type="file" />
|
||||||
|
<button id="uploadStageBtn" class="secondary">上传到隔离 Stage</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted-note">只会复制或上传到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Services</h3></div>
|
||||||
|
<div id="serviceList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Recent Actions</h3>
|
||||||
|
<button id="refreshHistoryBtn" class="secondary compact">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-grid">
|
||||||
|
<label class="checkbox-row"><input id="historyCurrentTask" type="checkbox" />仅当前任务</label>
|
||||||
|
<select id="historyStatusFilter">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="ok">ok</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
<input id="historyActionFilter" placeholder="action_name,如 worker_run_once" />
|
||||||
|
</div>
|
||||||
|
<div id="recentActionList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Scheduler Queue</h3>
|
||||||
|
<button id="refreshSchedulerBtn" class="secondary compact">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div id="schedulerSummary" class="summary-strip"></div>
|
||||||
|
<div id="schedulerList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Stage Scan Result</h3></div>
|
||||||
|
<div id="stageScanSummary" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Doctor Checks</h3></div>
|
||||||
|
<div id="doctorChecks" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Retry & Manual Attention</h3></div>
|
||||||
|
<div id="overviewRetrySummary" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Modules</h3></div>
|
||||||
|
<div id="moduleList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Overview Notes</h3></div>
|
||||||
|
<div class="stack-list">
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>先看 Health / Doctor</strong>
|
||||||
|
<div class="muted-note">系统级异常通常先体现在依赖检查,而不是单任务状态。</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>再看 Retry Summary</strong>
|
||||||
|
<div class="muted-note">优先处理已到重试时间和需要人工介入的任务。</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>最后看 Recent Actions</strong>
|
||||||
|
<div class="muted-note">用动作流判断最近系统是否真的在前进。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="tasks">
|
||||||
|
<div class="tasks-layout">
|
||||||
|
<section class="panel task-index-panel">
|
||||||
|
<div class="panel-head"><h3>Task Index</h3></div>
|
||||||
|
<div class="task-index-summary">
|
||||||
|
<div id="taskStatusSummary" class="summary-strip"></div>
|
||||||
|
<div class="task-pagination-toolbar">
|
||||||
|
<div id="taskPaginationSummary" class="muted-note">-</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<select id="taskPageSizeSelect">
|
||||||
|
<option value="12">12 / page</option>
|
||||||
|
<option value="24" selected>24 / page</option>
|
||||||
|
<option value="48">48 / page</option>
|
||||||
|
</select>
|
||||||
|
<button id="taskPrevPageBtn" class="secondary compact">上一页</button>
|
||||||
|
<button id="taskNextPageBtn" class="secondary compact">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-filters">
|
||||||
|
<input id="taskSearchInput" placeholder="搜索任务标题或 task id" />
|
||||||
|
<select id="taskStatusFilter">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="created">created</option>
|
||||||
|
<option value="transcribed">transcribed</option>
|
||||||
|
<option value="songs_detected">songs_detected</option>
|
||||||
|
<option value="split_done">split_done</option>
|
||||||
|
<option value="published">published</option>
|
||||||
|
<option value="collection_synced">collection_synced</option>
|
||||||
|
<option value="failed_retryable">failed_retryable</option>
|
||||||
|
<option value="failed_manual">failed_manual</option>
|
||||||
|
</select>
|
||||||
|
<select id="taskSortSelect">
|
||||||
|
<option value="updated_desc">最近更新</option>
|
||||||
|
<option value="updated_asc">最早更新</option>
|
||||||
|
<option value="title_asc">标题 A-Z</option>
|
||||||
|
<option value="title_desc">标题 Z-A</option>
|
||||||
|
<option value="attention_state">按关注状态</option>
|
||||||
|
<option value="status_group">按状态分组</option>
|
||||||
|
<option value="split_comment_status">按纯享评论</option>
|
||||||
|
<option value="full_comment_status">按主视频评论</option>
|
||||||
|
<option value="cleanup_state">按清理状态</option>
|
||||||
|
<option value="next_retry_asc">按下次重试</option>
|
||||||
|
</select>
|
||||||
|
<select id="taskDeliveryFilter">
|
||||||
|
<option value="">全部交付状态</option>
|
||||||
|
<option value="legacy_untracked">主视频评论未追踪</option>
|
||||||
|
<option value="pending_comment">评论待完成</option>
|
||||||
|
<option value="cleanup_removed">已清理视频</option>
|
||||||
|
</select>
|
||||||
|
<select id="taskAttentionFilter">
|
||||||
|
<option value="">全部关注状态</option>
|
||||||
|
<option value="manual_now">仅看需人工</option>
|
||||||
|
<option value="retry_now">仅看到点重试</option>
|
||||||
|
<option value="waiting_retry">仅看等待重试</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="taskListState" class="task-list-state">正在加载任务列表…</div>
|
||||||
|
<div id="taskList" class="task-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="task-workspace">
|
||||||
|
<section class="panel task-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Task Detail</h3>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="runTaskBtn" class="secondary compact">执行当前任务</button>
|
||||||
|
<button id="retryStepBtn" class="secondary compact">重试选中 Step</button>
|
||||||
|
<button id="resetStepBtn" class="secondary compact">重置到选中 Step</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="taskWorkspaceState" class="task-workspace-state show">选择一个任务后,这里会显示当前链路、重试状态和最近动作。</div>
|
||||||
|
<div id="taskHero" class="task-hero empty">选择一个任务后,这里会显示当前链路、重试状态和最近动作。</div>
|
||||||
|
<div id="taskRetryPanel" class="retry-banner hidden"></div>
|
||||||
|
<div class="detail-layout">
|
||||||
|
<div id="taskDetail" class="detail-grid"></div>
|
||||||
|
<div id="taskSummary" class="summary-card">暂无最近结果</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Steps</h3></div>
|
||||||
|
<div id="stepList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Artifacts</h3></div>
|
||||||
|
<div id="artifactList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid two-up">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>History</h3></div>
|
||||||
|
<div id="historyList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Timeline</h3></div>
|
||||||
|
<div id="timelineList" class="timeline-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="settings">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Settings</h3></div>
|
||||||
|
<div class="settings-toolbar">
|
||||||
|
<input id="settingsSearch" placeholder="过滤配置项,例如 codex / season / retry" />
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="syncFormToJsonBtn" class="secondary compact">表单同步到 JSON</button>
|
||||||
|
<button id="syncJsonToFormBtn" class="secondary compact">JSON 重绘表单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="settingsForm" class="settings-groups"></div>
|
||||||
|
<details class="settings-advanced">
|
||||||
|
<summary>Advanced JSON Editor</summary>
|
||||||
|
<textarea id="settingsEditor" spellcheck="false"></textarea>
|
||||||
|
</details>
|
||||||
|
<p class="muted-note">敏感字段显示为 `__BILIUP_NEXT_SECRET__`。保留占位符表示不改原值,改为空字符串表示清空。</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="logs">
|
||||||
|
<div class="logs-workspace">
|
||||||
|
<section class="panel logs-index-panel">
|
||||||
|
<div class="panel-head"><h3>Log Index</h3></div>
|
||||||
|
<div class="task-filters">
|
||||||
|
<input id="logSearchInput" placeholder="搜索日志文件名" />
|
||||||
|
<label class="checkbox-row"><input id="filterCurrentTask" type="checkbox" />按当前任务标题过滤</label>
|
||||||
|
<label class="checkbox-row"><input id="logAutoRefresh" type="checkbox" />自动刷新</label>
|
||||||
|
</div>
|
||||||
|
<div class="button-row" style="margin-bottom:12px;">
|
||||||
|
<button id="refreshLogBtn" class="secondary compact">刷新日志</button>
|
||||||
|
</div>
|
||||||
|
<div id="logListState" class="task-list-state show">正在加载日志索引…</div>
|
||||||
|
<div id="logList" class="task-list"></div>
|
||||||
|
</section>
|
||||||
|
<div class="log-content-stack">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Log Content</h3></div>
|
||||||
|
<div class="filter-grid">
|
||||||
|
<input id="logLineFilter" placeholder="过滤内容关键字" />
|
||||||
|
<div class="muted-note" id="logPath">-</div>
|
||||||
|
<div class="muted-note" id="logMeta">-</div>
|
||||||
|
</div>
|
||||||
|
<pre id="logContent"></pre>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h3>Logs Guide</h3></div>
|
||||||
|
<div class="stack-list">
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>优先看当前任务</strong>
|
||||||
|
<div class="muted-note">勾选“按当前任务标题过滤”,可快速聚焦任务链路。</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>先看系统,再看任务</strong>
|
||||||
|
<div class="muted-note">如果服务异常,先看 `systemd` 状态;如果单任务异常,再看 steps/history/timeline。</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>上传异常</strong>
|
||||||
|
<div class="muted-note">优先看 `upload.log`、任务时间线里的 publish error,以及下一次重试时间。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/assets/app/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
73
src/biliup_next/app/retry_meta.py
Normal file
73
src/biliup_next/app/retry_meta.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]:
|
||||||
|
raw_schedule = settings.get("retry_schedule_minutes")
|
||||||
|
if isinstance(raw_schedule, list):
|
||||||
|
schedule: list[int] = []
|
||||||
|
for item in raw_schedule:
|
||||||
|
if isinstance(item, int) and not isinstance(item, bool) and item >= 0:
|
||||||
|
schedule.append(item * 60)
|
||||||
|
if schedule:
|
||||||
|
return schedule
|
||||||
|
retry_count = settings.get("retry_count", 5)
|
||||||
|
retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else 5
|
||||||
|
retry_count = max(retry_count, 0)
|
||||||
|
retry_backoff = settings.get("retry_backoff_seconds", 300)
|
||||||
|
retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else 300
|
||||||
|
retry_backoff = max(retry_backoff, 0)
|
||||||
|
return [retry_backoff] * retry_count
|
||||||
|
|
||||||
|
|
||||||
|
def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def]
|
||||||
|
if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0:
|
||||||
|
return None
|
||||||
|
if getattr(step, "step_name", None) != "publish":
|
||||||
|
return None
|
||||||
|
|
||||||
|
publish_settings = settings_by_group.get("publish", {})
|
||||||
|
if not isinstance(publish_settings, dict):
|
||||||
|
publish_settings = {}
|
||||||
|
schedule = publish_retry_schedule_seconds(publish_settings)
|
||||||
|
attempt_index = step.retry_count - 1
|
||||||
|
if attempt_index >= len(schedule):
|
||||||
|
return {
|
||||||
|
"retry_due": False,
|
||||||
|
"retry_exhausted": True,
|
||||||
|
"retry_wait_seconds": None,
|
||||||
|
"retry_remaining_seconds": None,
|
||||||
|
"next_retry_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_seconds = schedule[attempt_index]
|
||||||
|
reference = parse_iso(getattr(step, "finished_at", None)) or parse_iso(getattr(step, "started_at", None))
|
||||||
|
if reference is None:
|
||||||
|
return {
|
||||||
|
"retry_due": True,
|
||||||
|
"retry_exhausted": False,
|
||||||
|
"retry_wait_seconds": wait_seconds,
|
||||||
|
"retry_remaining_seconds": 0,
|
||||||
|
"next_retry_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
next_retry_at = reference + timedelta(seconds=wait_seconds)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
remaining_seconds = max(int((next_retry_at - now).total_seconds()), 0)
|
||||||
|
return {
|
||||||
|
"retry_due": now >= next_retry_at,
|
||||||
|
"retry_exhausted": False,
|
||||||
|
"retry_wait_seconds": wait_seconds,
|
||||||
|
"retry_remaining_seconds": remaining_seconds,
|
||||||
|
"next_retry_at": next_retry_at.isoformat(),
|
||||||
|
}
|
||||||
181
src/biliup_next/app/scheduler.py
Normal file
181
src/biliup_next/app/scheduler.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from biliup_next.app.bootstrap import ensure_initialized
|
||||||
|
from biliup_next.app.task_engine import next_runnable_step, settings_for
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STATUS_PRIORITY = [
|
||||||
|
"failed_retryable",
|
||||||
|
"created",
|
||||||
|
"transcribed",
|
||||||
|
"songs_detected",
|
||||||
|
"split_done",
|
||||||
|
"published",
|
||||||
|
"commented",
|
||||||
|
"collection_synced",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScheduledTask:
|
||||||
|
task_id: str
|
||||||
|
reason: str
|
||||||
|
step_name: str | None = None
|
||||||
|
remaining_seconds: int | None = None
|
||||||
|
task_status: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SchedulerCycle:
|
||||||
|
preview: dict[str, object]
|
||||||
|
scheduled: list[ScheduledTask]
|
||||||
|
deferred: list[dict[str, object]]
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scheduled_task(item: ScheduledTask) -> dict[str, object]:
|
||||||
|
return asdict(item)
|
||||||
|
|
||||||
|
|
||||||
|
def scheduler_settings(state: dict[str, object]) -> dict[str, object]:
|
||||||
|
return dict(state["settings"].get("scheduler", {}))
|
||||||
|
|
||||||
|
|
||||||
|
def _status_priority_map(state: dict[str, object]) -> dict[str, int]:
|
||||||
|
configured = scheduler_settings(state).get("status_priority", DEFAULT_STATUS_PRIORITY)
|
||||||
|
ordered = configured if isinstance(configured, list) else DEFAULT_STATUS_PRIORITY
|
||||||
|
return {str(status): index for index, status in enumerate(ordered)}
|
||||||
|
|
||||||
|
|
||||||
|
def _task_sort_key(state: dict[str, object], item: ScheduledTask) -> tuple[int, int, str]:
|
||||||
|
settings = scheduler_settings(state)
|
||||||
|
status_priority = _status_priority_map(state)
|
||||||
|
retry_rank = 0 if settings.get("prioritize_retry_due", True) and item.reason == "retry_due" else 1
|
||||||
|
status_rank = status_priority.get(item.task_status or "", len(status_priority) + 10)
|
||||||
|
updated_at = item.updated_at or ""
|
||||||
|
if not settings.get("oldest_first", True):
|
||||||
|
updated_at = "".join(chr(255 - ord(ch)) for ch in updated_at)
|
||||||
|
return retry_rank, status_rank, updated_at
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_payload(state: dict[str, object], *, requested_limit: int) -> dict[str, object]:
|
||||||
|
settings = scheduler_settings(state)
|
||||||
|
return {
|
||||||
|
"candidate_scan_limit": int(settings.get("candidate_scan_limit", requested_limit)),
|
||||||
|
"max_tasks_per_cycle": int(settings.get("max_tasks_per_cycle", requested_limit)),
|
||||||
|
"prioritize_retry_due": bool(settings.get("prioritize_retry_due", True)),
|
||||||
|
"oldest_first": bool(settings.get("oldest_first", True)),
|
||||||
|
"status_priority": list(settings.get("status_priority", DEFAULT_STATUS_PRIORITY)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_stage_once(state: dict[str, object]) -> dict[str, object]:
|
||||||
|
ingest_settings = settings_for(state, "ingest")
|
||||||
|
return state["ingest_service"].scan_stage(ingest_settings)
|
||||||
|
|
||||||
|
|
||||||
|
def select_scheduled_tasks(state: dict[str, object], *, limit: int = 200) -> tuple[list[ScheduledTask], list[dict[str, object]]]:
|
||||||
|
repo = state["repo"]
|
||||||
|
scheduled: list[ScheduledTask] = []
|
||||||
|
deferred: list[dict[str, object]] = []
|
||||||
|
settings = scheduler_settings(state)
|
||||||
|
candidate_limit = int(settings.get("candidate_scan_limit", limit))
|
||||||
|
max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit))
|
||||||
|
for task in repo.list_tasks(limit=candidate_limit):
|
||||||
|
if task.status == "failed_manual":
|
||||||
|
continue
|
||||||
|
steps = {step.step_name: step for step in repo.list_steps(task.id)}
|
||||||
|
step_name, waiting_payload = next_runnable_step(task, steps, state)
|
||||||
|
if waiting_payload is not None:
|
||||||
|
deferred.append(waiting_payload)
|
||||||
|
continue
|
||||||
|
if step_name is None:
|
||||||
|
continue
|
||||||
|
scheduled.append(
|
||||||
|
ScheduledTask(
|
||||||
|
task.id,
|
||||||
|
reason="retry_due" if task.status == "failed_retryable" else "ready",
|
||||||
|
step_name=step_name,
|
||||||
|
remaining_seconds=None,
|
||||||
|
task_status=task.status,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scheduled.sort(key=lambda item: _task_sort_key(state, item))
|
||||||
|
return scheduled[:max_tasks_per_cycle], deferred
|
||||||
|
|
||||||
|
|
||||||
|
def build_scheduler_preview(state: dict[str, object], *, limit: int = 200, include_stage_scan: bool = False) -> dict[str, object]:
|
||||||
|
repo = state["repo"]
|
||||||
|
settings = scheduler_settings(state)
|
||||||
|
candidate_limit = int(settings.get("candidate_scan_limit", limit))
|
||||||
|
max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit))
|
||||||
|
|
||||||
|
stage_scan_result: dict[str, object] = {"accepted": [], "rejected": [], "skipped": []}
|
||||||
|
if include_stage_scan:
|
||||||
|
stage_scan_result = scan_stage_once(state)
|
||||||
|
|
||||||
|
scheduled_all: list[ScheduledTask] = []
|
||||||
|
deferred: list[dict[str, object]] = []
|
||||||
|
skipped_counts = {
|
||||||
|
"failed_manual": 0,
|
||||||
|
"no_runnable_step": 0,
|
||||||
|
}
|
||||||
|
scanned_count = 0
|
||||||
|
|
||||||
|
for task in repo.list_tasks(limit=candidate_limit):
|
||||||
|
scanned_count += 1
|
||||||
|
if task.status == "failed_manual":
|
||||||
|
skipped_counts["failed_manual"] += 1
|
||||||
|
continue
|
||||||
|
steps = {step.step_name: step for step in repo.list_steps(task.id)}
|
||||||
|
step_name, waiting_payload = next_runnable_step(task, steps, state)
|
||||||
|
if waiting_payload is not None:
|
||||||
|
deferred.append(waiting_payload)
|
||||||
|
continue
|
||||||
|
if step_name is None:
|
||||||
|
skipped_counts["no_runnable_step"] += 1
|
||||||
|
continue
|
||||||
|
scheduled_all.append(
|
||||||
|
ScheduledTask(
|
||||||
|
task.id,
|
||||||
|
reason="retry_due" if task.status == "failed_retryable" else "ready",
|
||||||
|
step_name=step_name,
|
||||||
|
remaining_seconds=None,
|
||||||
|
task_status=task.status,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_all.sort(key=lambda item: _task_sort_key(state, item))
|
||||||
|
scheduled = scheduled_all[:max_tasks_per_cycle]
|
||||||
|
truncated_count = max(0, len(scheduled_all) - len(scheduled))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stage_scan": stage_scan_result,
|
||||||
|
"scheduled": [serialize_scheduled_task(item) for item in scheduled],
|
||||||
|
"deferred": deferred,
|
||||||
|
"summary": {
|
||||||
|
"scanned_count": scanned_count,
|
||||||
|
"scheduled_count": len(scheduled),
|
||||||
|
"deferred_count": len(deferred),
|
||||||
|
"truncated_count": truncated_count,
|
||||||
|
"skipped_counts": skipped_counts,
|
||||||
|
},
|
||||||
|
"strategy": _strategy_payload(state, requested_limit=limit),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduled_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> tuple[dict[str, object], list[ScheduledTask], list[dict[str, object]]]:
|
||||||
|
cycle = run_scheduler_cycle(include_stage_scan=include_stage_scan, limit=limit)
|
||||||
|
return cycle.preview["stage_scan"], cycle.scheduled, cycle.deferred
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduler_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> SchedulerCycle:
|
||||||
|
state = ensure_initialized()
|
||||||
|
preview = build_scheduler_preview(state, include_stage_scan=include_stage_scan, limit=limit)
|
||||||
|
scheduled = [ScheduledTask(**item) for item in preview["scheduled"]]
|
||||||
|
return SchedulerCycle(preview=preview, scheduled=scheduled, deferred=preview["deferred"])
|
||||||
215
src/biliup_next/app/static/app/actions.js
Normal file
215
src/biliup_next/app/static/app/actions.js
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { fetchJson } from "./api.js";
|
||||||
|
import { navigate } from "./router.js";
|
||||||
|
import {
|
||||||
|
clearSettingsFieldState,
|
||||||
|
resetTaskPage,
|
||||||
|
setLogAutoRefreshTimer,
|
||||||
|
setSelectedTask,
|
||||||
|
setTaskPage,
|
||||||
|
setTaskPageSize,
|
||||||
|
state,
|
||||||
|
} from "./state.js";
|
||||||
|
import { showBanner, syncSettingsEditorFromState } from "./utils.js";
|
||||||
|
import { renderSettingsForm } from "./views/settings.js";
|
||||||
|
import { renderTasks } from "./views/tasks.js";
|
||||||
|
|
||||||
|
export function bindActions({
|
||||||
|
loadOverview,
|
||||||
|
loadTaskDetail,
|
||||||
|
refreshLog,
|
||||||
|
handleSettingsFieldChange,
|
||||||
|
}) {
|
||||||
|
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||||
|
button.onclick = () => navigate(button.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("refreshBtn").onclick = async () => {
|
||||||
|
await loadOverview();
|
||||||
|
showBanner("视图已刷新", "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("runOnceBtn").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchJson("/worker/run-once", { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(String(err), "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("saveSettingsBtn").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(document.getElementById("settingsEditor").value);
|
||||||
|
await fetchJson("/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
clearSettingsFieldState();
|
||||||
|
await loadOverview();
|
||||||
|
showBanner("Settings 已保存", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`保存失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("syncFormToJsonBtn").onclick = () => {
|
||||||
|
syncSettingsEditorFromState();
|
||||||
|
showBanner("表单已同步到 JSON", "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("syncJsonToFormBtn").onclick = () => {
|
||||||
|
try {
|
||||||
|
state.currentSettings = JSON.parse(document.getElementById("settingsEditor").value);
|
||||||
|
clearSettingsFieldState();
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
showBanner("JSON 已重绘到表单", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`JSON 解析失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settingsSearch").oninput = () => renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
document.getElementById("settingsForm").onclick = (event) => {
|
||||||
|
const button = event.target.closest("button[data-revert-group]");
|
||||||
|
if (!button) return;
|
||||||
|
const group = button.dataset.revertGroup;
|
||||||
|
const field = button.dataset.revertField;
|
||||||
|
const originalValue = state.originalSettings[group]?.[field];
|
||||||
|
state.currentSettings[group] ??= {};
|
||||||
|
if (originalValue === undefined) delete state.currentSettings[group][field];
|
||||||
|
else state.currentSettings[group][field] = JSON.parse(JSON.stringify(originalValue));
|
||||||
|
clearSettingsFieldState();
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
showBanner(`已撤销 ${group}.${field}`, "ok");
|
||||||
|
};
|
||||||
|
const rerenderTasks = () => renderTasks(async (taskId) => {
|
||||||
|
setSelectedTask(taskId);
|
||||||
|
await loadTaskDetail(taskId);
|
||||||
|
});
|
||||||
|
document.getElementById("taskSearchInput").oninput = () => { resetTaskPage(); rerenderTasks(); };
|
||||||
|
document.getElementById("taskStatusFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||||
|
document.getElementById("taskSortSelect").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||||
|
document.getElementById("taskDeliveryFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||||
|
document.getElementById("taskAttentionFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||||
|
document.getElementById("taskPageSizeSelect").onchange = () => {
|
||||||
|
setTaskPageSize(Number(document.getElementById("taskPageSizeSelect").value) || 24);
|
||||||
|
resetTaskPage();
|
||||||
|
rerenderTasks();
|
||||||
|
};
|
||||||
|
document.getElementById("taskPrevPageBtn").onclick = () => {
|
||||||
|
setTaskPage(Math.max(1, state.taskPage - 1));
|
||||||
|
rerenderTasks();
|
||||||
|
};
|
||||||
|
document.getElementById("taskNextPageBtn").onclick = () => {
|
||||||
|
setTaskPage(state.taskPage + 1);
|
||||||
|
rerenderTasks();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("importStageBtn").onclick = async () => {
|
||||||
|
const sourcePath = document.getElementById("stageSourcePath").value.trim();
|
||||||
|
if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn");
|
||||||
|
try {
|
||||||
|
const result = await fetchJson("/stage/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source_path: sourcePath }),
|
||||||
|
});
|
||||||
|
document.getElementById("stageSourcePath").value = "";
|
||||||
|
showBanner(`已导入到 stage: ${result.target_path}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`导入失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("uploadStageBtn").onclick = async () => {
|
||||||
|
const input = document.getElementById("stageFileInput");
|
||||||
|
if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", input.files[0]);
|
||||||
|
const res = await fetch("/stage/upload", { method: "POST", body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || JSON.stringify(data));
|
||||||
|
input.value = "";
|
||||||
|
showBanner(`已上传到 stage: ${data.target_path}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`上传失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||||
|
document.getElementById("logSearchInput").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||||
|
document.getElementById("logLineFilter").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||||
|
document.getElementById("logAutoRefresh").onchange = () => {
|
||||||
|
if (state.logAutoRefreshTimer) {
|
||||||
|
clearInterval(state.logAutoRefreshTimer);
|
||||||
|
setLogAutoRefreshTimer(null);
|
||||||
|
}
|
||||||
|
if (document.getElementById("logAutoRefresh").checked) {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
refreshLog().catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
setLogAutoRefreshTimer(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err"));
|
||||||
|
document.getElementById("refreshSchedulerBtn").onclick = () => loadOverview().then(() => showBanner("调度队列已刷新", "ok")).catch((err) => showBanner(`调度队列刷新失败: ${err}`, "err"));
|
||||||
|
|
||||||
|
document.getElementById("saveTokenBtn").onclick = async () => {
|
||||||
|
const token = document.getElementById("tokenInput").value.trim();
|
||||||
|
localStorage.setItem("biliup_next_token", token);
|
||||||
|
try {
|
||||||
|
await loadOverview();
|
||||||
|
showBanner("Token 已保存并生效", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`Token 验证失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("runTaskBtn").onclick = async () => {
|
||||||
|
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`任务已推进,processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`任务执行失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("retryStepBtn").onclick = async () => {
|
||||||
|
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||||
|
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||||
|
});
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`已重试 step=${state.selectedStepName},processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`重试失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("resetStepBtn").onclick = async () => {
|
||||||
|
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||||
|
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||||
|
const ok = window.confirm(`确认重置到 step=${state.selectedStepName} 并清理其后的产物吗?`);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||||
|
});
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`已重置并重跑 step=${state.selectedStepName},processed=${result.run.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`重置失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/biliup_next/app/static/app/api.js
Normal file
52
src/biliup_next/app/static/app/api.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export async function fetchJson(url, options) {
|
||||||
|
const token = localStorage.getItem("biliup_next_token") || "";
|
||||||
|
const opts = options ? { ...options } : {};
|
||||||
|
opts.headers = { ...(opts.headers || {}) };
|
||||||
|
if (token) opts.headers["X-Biliup-Token"] = token;
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHistoryUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", "20");
|
||||||
|
const status = document.getElementById("historyStatusFilter")?.value || "";
|
||||||
|
const actionName = document.getElementById("historyActionFilter")?.value.trim() || "";
|
||||||
|
const currentOnly = document.getElementById("historyCurrentTask")?.checked;
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (actionName) params.set("action_name", actionName);
|
||||||
|
if (currentOnly && state.selectedTaskId) params.set("task_id", state.selectedTaskId);
|
||||||
|
return `/history?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOverviewPayload() {
|
||||||
|
const historyUrl = buildHistoryUrl();
|
||||||
|
const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler] = await Promise.all([
|
||||||
|
fetchJson("/health"),
|
||||||
|
fetchJson("/doctor"),
|
||||||
|
fetchJson("/tasks?limit=100"),
|
||||||
|
fetchJson("/modules"),
|
||||||
|
fetchJson("/settings"),
|
||||||
|
fetchJson("/settings/schema"),
|
||||||
|
fetchJson("/runtime/services"),
|
||||||
|
fetchJson("/logs"),
|
||||||
|
fetchJson(historyUrl),
|
||||||
|
fetchJson("/scheduler/preview"),
|
||||||
|
]);
|
||||||
|
return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTaskPayload(taskId) {
|
||||||
|
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||||
|
fetchJson(`/tasks/${taskId}`),
|
||||||
|
fetchJson(`/tasks/${taskId}/steps`),
|
||||||
|
fetchJson(`/tasks/${taskId}/artifacts`),
|
||||||
|
fetchJson(`/tasks/${taskId}/history`),
|
||||||
|
fetchJson(`/tasks/${taskId}/timeline`),
|
||||||
|
]);
|
||||||
|
return { task, steps, artifacts, history, timeline };
|
||||||
|
}
|
||||||
16
src/biliup_next/app/static/app/components/artifact-list.js
Normal file
16
src/biliup_next/app/static/app/components/artifact-list.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { escapeHtml, formatDate } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderArtifactList(artifacts) {
|
||||||
|
const artifactWrap = document.getElementById("artifactList");
|
||||||
|
artifactWrap.innerHTML = "";
|
||||||
|
artifacts.items.forEach((artifact) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
|
||||||
|
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
|
||||||
|
`;
|
||||||
|
artifactWrap.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { escapeHtml } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderDoctor(checks) {
|
||||||
|
const wrap = document.getElementById("doctorChecks");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const check of checks) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
|
||||||
|
<div class="muted-note">${escapeHtml(check.detail)}</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/biliup_next/app/static/app/components/history-list.js
Normal file
23
src/biliup_next/app/static/app/components/history-list.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { escapeHtml, formatDate, statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderHistoryList(history) {
|
||||||
|
const historyWrap = document.getElementById("historyList");
|
||||||
|
historyWrap.innerHTML = "";
|
||||||
|
history.items.forEach((item) => {
|
||||||
|
let details = "";
|
||||||
|
try {
|
||||||
|
details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2);
|
||||||
|
} catch {
|
||||||
|
details = item.details_json || "";
|
||||||
|
}
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(item.action_name)}</strong><span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span></div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.summary)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||||
|
<pre>${escapeHtml(details)}</pre>
|
||||||
|
`;
|
||||||
|
historyWrap.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
src/biliup_next/app/static/app/components/modules-list.js
Normal file
15
src/biliup_next/app/static/app/components/modules-list.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { escapeHtml } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderModules(items) {
|
||||||
|
const wrap = document.getElementById("moduleList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export function renderRuntimeSnapshot({ health, doctor, tasks }) {
|
||||||
|
const healthText = health.ok ? "OK" : "FAIL";
|
||||||
|
const doctorText = doctor.ok ? "OK" : "FAIL";
|
||||||
|
document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || "";
|
||||||
|
document.getElementById("healthValue").textContent = healthText;
|
||||||
|
document.getElementById("doctorValue").textContent = doctorText;
|
||||||
|
document.getElementById("tasksValue").textContent = tasks.items.length;
|
||||||
|
document.getElementById("overviewHealthValue").textContent = healthText;
|
||||||
|
document.getElementById("overviewDoctorValue").textContent = doctorText;
|
||||||
|
document.getElementById("overviewTasksValue").textContent = tasks.items.length;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderOverviewTaskSummary(tasks) {
|
||||||
|
const wrap = document.getElementById("overviewTaskSummary");
|
||||||
|
if (!wrap) return;
|
||||||
|
const counts = new Map();
|
||||||
|
tasks.forEach((task) => counts.set(task.status, (counts.get(task.status) || 0) + 1));
|
||||||
|
const ordered = ["running", "failed_retryable", "failed_manual", "published", "collection_synced", "created", "transcribed", "songs_detected", "split_done"];
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
ordered.forEach((status) => {
|
||||||
|
const count = counts.get(status);
|
||||||
|
if (!count) return;
|
||||||
|
const pill = document.createElement("div");
|
||||||
|
pill.className = `pill ${statusClass(status)}`;
|
||||||
|
pill.textContent = `${status} ${count}`;
|
||||||
|
wrap.appendChild(pill);
|
||||||
|
});
|
||||||
|
if (!wrap.children.length) {
|
||||||
|
const pill = document.createElement("div");
|
||||||
|
pill.className = "pill";
|
||||||
|
pill.textContent = "no tasks";
|
||||||
|
wrap.appendChild(pill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOverviewRetrySummary(tasks) {
|
||||||
|
const wrap = document.getElementById("overviewRetrySummary");
|
||||||
|
if (!wrap) return;
|
||||||
|
const waitingRetry = tasks.filter((task) => task.retry_state?.next_retry_at && !task.retry_state?.retry_due);
|
||||||
|
const dueRetry = tasks.filter((task) => task.retry_state?.retry_due);
|
||||||
|
const failedManual = tasks.filter((task) => task.status === "failed_manual");
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>Waiting Retry</strong>
|
||||||
|
<div class="muted-note">${waitingRetry.length} 个任务正在等待下一次重试</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>Retry Due</strong>
|
||||||
|
<div class="muted-note">${dueRetry.length} 个任务已到重试时间</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-card">
|
||||||
|
<strong>Manual Attention</strong>
|
||||||
|
<div class="muted-note">${failedManual.length} 个任务需要人工处理</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { escapeHtml, formatDate, statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderRecentActions(items) {
|
||||||
|
const wrap = document.getElementById("recentActionList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(item.action_name)}</strong>
|
||||||
|
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/biliup_next/app/static/app/components/retry-banner.js
Normal file
19
src/biliup_next/app/static/app/components/retry-banner.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { escapeHtml, formatDate, formatDuration } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderRetryPanel(task) {
|
||||||
|
const wrap = document.getElementById("taskRetryPanel");
|
||||||
|
const retry = task.retry_state;
|
||||||
|
if (!retry || !retry.next_retry_at) {
|
||||||
|
wrap.className = "retry-banner";
|
||||||
|
wrap.style.display = "none";
|
||||||
|
wrap.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.style.display = "block";
|
||||||
|
wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`;
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<strong>${escapeHtml(retry.step_name)}</strong>
|
||||||
|
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
|
||||||
|
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
27
src/biliup_next/app/static/app/components/service-list.js
Normal file
27
src/biliup_next/app/static/app/components/service-list.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { escapeHtml, statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderServices(items, onServiceAction) {
|
||||||
|
const wrap = document.getElementById("serviceList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "service-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(item.id)}</strong>
|
||||||
|
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
|
||||||
|
<span class="pill">${escapeHtml(item.sub_state)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
|
||||||
|
<div class="button-row" style="margin-top:12px;">
|
||||||
|
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
|
||||||
|
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
|
||||||
|
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
wrap.querySelectorAll("button[data-service]").forEach((btn) => {
|
||||||
|
btn.onclick = () => onServiceAction(btn.dataset.service, btn.dataset.action);
|
||||||
|
});
|
||||||
|
}
|
||||||
34
src/biliup_next/app/static/app/components/step-list.js
Normal file
34
src/biliup_next/app/static/app/components/step-list.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { state } from "../state.js";
|
||||||
|
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderStepList(steps, onStepSelect) {
|
||||||
|
const stepWrap = document.getElementById("stepList");
|
||||||
|
stepWrap.innerHTML = "";
|
||||||
|
steps.items.forEach((step) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `row-card ${state.selectedStepName === step.step_name ? "active" : ""}`;
|
||||||
|
row.style.cursor = "pointer";
|
||||||
|
const retryBlock = step.next_retry_at ? `
|
||||||
|
<div class="step-card-metrics">
|
||||||
|
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
|
||||||
|
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
|
||||||
|
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
|
||||||
|
</div>
|
||||||
|
` : "";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(step.step_name)}</strong>
|
||||||
|
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
|
||||||
|
<span class="pill">retry ${step.retry_count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
|
||||||
|
<div class="step-card-metrics">
|
||||||
|
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
|
||||||
|
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
|
||||||
|
</div>
|
||||||
|
${retryBlock}
|
||||||
|
`;
|
||||||
|
row.onclick = () => onStepSelect(step.step_name);
|
||||||
|
stepWrap.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
22
src/biliup_next/app/static/app/components/task-hero.js
Normal file
22
src/biliup_next/app/static/app/components/task-hero.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { escapeHtml, statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderTaskHero(task, steps) {
|
||||||
|
const wrap = document.getElementById("taskHero");
|
||||||
|
const succeeded = steps.items.filter((step) => step.status === "succeeded").length;
|
||||||
|
const running = steps.items.filter((step) => step.status === "running").length;
|
||||||
|
const failed = steps.items.filter((step) => step.status.startsWith("failed")).length;
|
||||||
|
const delivery = task.delivery_state || {};
|
||||||
|
wrap.className = "task-hero";
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="task-hero-title">${escapeHtml(task.title)}</div>
|
||||||
|
<div class="task-hero-subtitle">${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}</div>
|
||||||
|
<div class="hero-meta-grid">
|
||||||
|
<div class="mini-stat"><div class="mini-stat-label">Task Status</div><div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(task.status)}</span></div></div>
|
||||||
|
<div class="mini-stat"><div class="mini-stat-label">Succeeded Steps</div><div class="mini-stat-value">${succeeded}/${steps.items.length}</div></div>
|
||||||
|
<div class="mini-stat"><div class="mini-stat-label">Running / Failed</div><div class="mini-stat-value">${running} / ${failed}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="task-hero-delivery muted-note">
|
||||||
|
split comment=${escapeHtml(delivery.split_comment || "-")} · full timeline=${escapeHtml(delivery.full_video_timeline_comment || "-")} · source=${delivery.source_video_present ? "present" : "removed"} · split videos=${delivery.split_videos_present ? "present" : "removed"}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
22
src/biliup_next/app/static/app/components/timeline-list.js
Normal file
22
src/biliup_next/app/static/app/components/timeline-list.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderTimelineList(timeline) {
|
||||||
|
const timelineWrap = document.getElementById("timelineList");
|
||||||
|
timelineWrap.innerHTML = "";
|
||||||
|
timeline.items.forEach((item) => {
|
||||||
|
const retryNote = item.retry_state?.next_retry_at
|
||||||
|
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
|
||||||
|
: "";
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "timeline-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="timeline-title"><strong>${escapeHtml(item.title)}</strong><span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span><span class="pill">${escapeHtml(item.kind)}</span></div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
|
||||||
|
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
|
||||||
|
${retryNote}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
timelineWrap.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
210
src/biliup_next/app/static/app/main.js
Normal file
210
src/biliup_next/app/static/app/main.js
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { fetchJson, loadOverviewPayload, loadTaskPayload } from "./api.js";
|
||||||
|
import { bindActions } from "./actions.js";
|
||||||
|
import { currentRoute, initRouter, navigate } from "./router.js";
|
||||||
|
import {
|
||||||
|
clearSettingsFieldState,
|
||||||
|
markSettingsFieldDirty,
|
||||||
|
setOverviewData,
|
||||||
|
setSettingsFieldError,
|
||||||
|
setLogs,
|
||||||
|
setLogListLoading,
|
||||||
|
setSelectedLog,
|
||||||
|
setSelectedStep,
|
||||||
|
setSelectedTask,
|
||||||
|
setTaskDetailStatus,
|
||||||
|
setTaskListLoading,
|
||||||
|
state,
|
||||||
|
} from "./state.js";
|
||||||
|
import { settingsFieldKey, showBanner } from "./utils.js";
|
||||||
|
import {
|
||||||
|
renderDoctor,
|
||||||
|
renderModules,
|
||||||
|
renderRecentActions,
|
||||||
|
renderSchedulerQueue,
|
||||||
|
renderServices,
|
||||||
|
renderShellStats,
|
||||||
|
} from "./views/overview.js";
|
||||||
|
import { renderLogContent, renderLogsList } from "./views/logs.js";
|
||||||
|
import { renderSettingsForm } from "./views/settings.js";
|
||||||
|
import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js";
|
||||||
|
|
||||||
|
async function refreshLog() {
|
||||||
|
const name = state.selectedLogName;
|
||||||
|
if (!name) return;
|
||||||
|
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
|
||||||
|
if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) {
|
||||||
|
const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId);
|
||||||
|
if (currentTask?.title) {
|
||||||
|
url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload = await fetchJson(url);
|
||||||
|
renderLogContent(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLog(name) {
|
||||||
|
setSelectedLog(name);
|
||||||
|
renderLogsList(state.currentLogs, refreshLog, selectLog);
|
||||||
|
await refreshLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskDetail(taskId) {
|
||||||
|
setTaskDetailStatus("loading");
|
||||||
|
renderTaskWorkspaceState("loading");
|
||||||
|
try {
|
||||||
|
const payload = await loadTaskPayload(taskId);
|
||||||
|
renderTaskDetail(payload, async (stepName) => {
|
||||||
|
setSelectedStep(stepName);
|
||||||
|
await loadTaskDetail(taskId);
|
||||||
|
});
|
||||||
|
setTaskDetailStatus("ready");
|
||||||
|
renderTaskWorkspaceState("ready");
|
||||||
|
} catch (err) {
|
||||||
|
const message = `任务详情加载失败: ${err}`;
|
||||||
|
setTaskDetailStatus("error", message);
|
||||||
|
renderTaskWorkspaceState("error", message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskSelectHandler(taskId) {
|
||||||
|
setSelectedTask(taskId);
|
||||||
|
setSelectedStep(null);
|
||||||
|
navigate("tasks", taskId);
|
||||||
|
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||||
|
return loadTaskDetail(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function taskRowActionHandler(action, taskId) {
|
||||||
|
if (action !== "run") return;
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`任务执行失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSettingsFieldChange(event) {
|
||||||
|
const input = event.target;
|
||||||
|
const group = input.dataset.group;
|
||||||
|
const field = input.dataset.field;
|
||||||
|
const fieldSchema = state.currentSettingsSchema.groups[group][field];
|
||||||
|
const key = settingsFieldKey(group, field);
|
||||||
|
let value;
|
||||||
|
if (fieldSchema.type === "boolean") value = input.checked;
|
||||||
|
else if (fieldSchema.type === "integer") {
|
||||||
|
value = Number(input.value);
|
||||||
|
if (input.value === "" || Number.isNaN(value)) {
|
||||||
|
state.currentSettings[group] ??= {};
|
||||||
|
state.currentSettings[group][field] = input.value;
|
||||||
|
markSettingsFieldDirty(key, true);
|
||||||
|
setSettingsFieldError(key, "必须填写整数");
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fieldSchema.type === "array") {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(input.value || "[]");
|
||||||
|
if (!Array.isArray(value)) throw new Error("not array");
|
||||||
|
} catch {
|
||||||
|
markSettingsFieldDirty(key, true);
|
||||||
|
setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`);
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else value = input.value;
|
||||||
|
if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) {
|
||||||
|
markSettingsFieldDirty(key, true);
|
||||||
|
setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`);
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markSettingsFieldDirty(key, true);
|
||||||
|
setSettingsFieldError(key, "");
|
||||||
|
if (!state.currentSettings[group]) state.currentSettings[group] = {};
|
||||||
|
state.currentSettings[group][field] = value;
|
||||||
|
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
setTaskListLoading(true);
|
||||||
|
setLogListLoading(true);
|
||||||
|
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||||
|
const payload = await loadOverviewPayload();
|
||||||
|
setOverviewData({
|
||||||
|
tasks: payload.tasks.items,
|
||||||
|
settings: payload.settings,
|
||||||
|
settingsSchema: payload.settingsSchema,
|
||||||
|
});
|
||||||
|
clearSettingsFieldState();
|
||||||
|
setTaskListLoading(false);
|
||||||
|
renderShellStats(payload);
|
||||||
|
renderSettingsForm(handleSettingsFieldChange);
|
||||||
|
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||||
|
renderModules(payload.modules.items);
|
||||||
|
renderDoctor(payload.doctor.checks);
|
||||||
|
renderSchedulerQueue(payload.scheduler);
|
||||||
|
renderServices(payload.services.items, async (serviceId, action) => {
|
||||||
|
if (["stop", "restart"].includes(action)) {
|
||||||
|
const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`service 操作失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLogs(payload.logs.items);
|
||||||
|
setLogListLoading(false);
|
||||||
|
if (!state.selectedLogName && payload.logs.items.length) {
|
||||||
|
setSelectedLog(payload.logs.items[0].name);
|
||||||
|
}
|
||||||
|
renderLogsList(payload.logs.items, refreshLog, selectLog);
|
||||||
|
renderRecentActions(payload.history.items);
|
||||||
|
const route = currentRoute();
|
||||||
|
const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId);
|
||||||
|
if (route.view === "tasks" && routeTaskExists) {
|
||||||
|
setSelectedTask(route.taskId);
|
||||||
|
} else if (!state.selectedTaskId && state.currentTasks.length) {
|
||||||
|
setSelectedTask(state.currentTasks[0].id);
|
||||||
|
}
|
||||||
|
if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId);
|
||||||
|
else {
|
||||||
|
setTaskDetailStatus("idle");
|
||||||
|
renderTaskWorkspaceState("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRouteChange(route) {
|
||||||
|
if (route.view !== "tasks") return;
|
||||||
|
if (!route.taskId) {
|
||||||
|
if (state.selectedTaskId) navigate("tasks", state.selectedTaskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.currentTasks.length) return;
|
||||||
|
if (!state.currentTasks.some((item) => item.id === route.taskId)) return;
|
||||||
|
if (state.selectedTaskId !== route.taskId) {
|
||||||
|
setSelectedTask(route.taskId);
|
||||||
|
setSelectedStep(null);
|
||||||
|
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||||
|
await loadTaskDetail(route.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindActions({
|
||||||
|
loadOverview,
|
||||||
|
loadTaskDetail,
|
||||||
|
refreshLog,
|
||||||
|
handleSettingsFieldChange,
|
||||||
|
});
|
||||||
|
initRouter((route) => {
|
||||||
|
handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err"));
|
||||||
|
});
|
||||||
|
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));
|
||||||
18
src/biliup_next/app/static/app/render.js
Normal file
18
src/biliup_next/app/static/app/render.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { setView } from "./state.js";
|
||||||
|
|
||||||
|
export function renderView(view) {
|
||||||
|
setView(view);
|
||||||
|
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.view === view);
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".view").forEach((section) => {
|
||||||
|
section.classList.toggle("active", section.dataset.view === view);
|
||||||
|
});
|
||||||
|
const titleMap = {
|
||||||
|
overview: "Overview",
|
||||||
|
tasks: "Tasks",
|
||||||
|
settings: "Settings",
|
||||||
|
logs: "Logs",
|
||||||
|
};
|
||||||
|
document.getElementById("viewTitle").textContent = titleMap[view] || "Control";
|
||||||
|
}
|
||||||
22
src/biliup_next/app/static/app/router.js
Normal file
22
src/biliup_next/app/static/app/router.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { renderView } from "./render.js";
|
||||||
|
|
||||||
|
export function currentRoute() {
|
||||||
|
const raw = window.location.hash.replace(/^#/, "") || "overview";
|
||||||
|
const [view = "overview", ...rest] = raw.split("/");
|
||||||
|
const taskId = rest.length ? decodeURIComponent(rest.join("/")) : null;
|
||||||
|
return { view: view || "overview", taskId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigate(view, taskId = null) {
|
||||||
|
window.location.hash = taskId ? `${view}/${encodeURIComponent(taskId)}` : view;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRouter(onRouteChange) {
|
||||||
|
const sync = () => {
|
||||||
|
const route = currentRoute();
|
||||||
|
renderView(route.view);
|
||||||
|
if (onRouteChange) onRouteChange(route);
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
91
src/biliup_next/app/static/app/state.js
Normal file
91
src/biliup_next/app/static/app/state.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
export const state = {
|
||||||
|
selectedTaskId: null,
|
||||||
|
selectedStepName: null,
|
||||||
|
currentTasks: [],
|
||||||
|
currentSettings: {},
|
||||||
|
originalSettings: {},
|
||||||
|
currentSettingsSchema: null,
|
||||||
|
settingsDirtyFields: {},
|
||||||
|
settingsFieldErrors: {},
|
||||||
|
currentView: "overview",
|
||||||
|
taskPage: 1,
|
||||||
|
taskPageSize: 24,
|
||||||
|
taskListLoading: true,
|
||||||
|
taskDetailStatus: "idle",
|
||||||
|
taskDetailError: "",
|
||||||
|
currentLogs: [],
|
||||||
|
selectedLogName: null,
|
||||||
|
logListLoading: true,
|
||||||
|
logAutoRefreshTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setView(view) {
|
||||||
|
state.currentView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedTask(taskId) {
|
||||||
|
state.selectedTaskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedStep(stepName) {
|
||||||
|
state.selectedStepName = stepName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOverviewData({ tasks, settings, settingsSchema }) {
|
||||||
|
state.currentTasks = tasks;
|
||||||
|
state.currentSettings = settings;
|
||||||
|
state.originalSettings = JSON.parse(JSON.stringify(settings || {}));
|
||||||
|
state.currentSettingsSchema = settingsSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markSettingsFieldDirty(key, dirty = true) {
|
||||||
|
if (dirty) state.settingsDirtyFields[key] = true;
|
||||||
|
else delete state.settingsDirtyFields[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSettingsFieldError(key, message = "") {
|
||||||
|
if (message) state.settingsFieldErrors[key] = message;
|
||||||
|
else delete state.settingsFieldErrors[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSettingsFieldState() {
|
||||||
|
state.settingsDirtyFields = {};
|
||||||
|
state.settingsFieldErrors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTaskPage(page) {
|
||||||
|
state.taskPage = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTaskPageSize(size) {
|
||||||
|
state.taskPageSize = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetTaskPage() {
|
||||||
|
state.taskPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTaskListLoading(loading) {
|
||||||
|
state.taskListLoading = loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTaskDetailStatus(status, error = "") {
|
||||||
|
state.taskDetailStatus = status;
|
||||||
|
state.taskDetailError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogs(logs) {
|
||||||
|
state.currentLogs = logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedLog(name) {
|
||||||
|
state.selectedLogName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogListLoading(loading) {
|
||||||
|
state.logListLoading = loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogAutoRefreshTimer(timerId) {
|
||||||
|
state.logAutoRefreshTimer = timerId;
|
||||||
|
}
|
||||||
61
src/biliup_next/app/static/app/utils.js
Normal file
61
src/biliup_next/app/static/app/utils.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export function statusClass(status) {
|
||||||
|
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
|
||||||
|
if (["done", "resolved", "present"].includes(status)) return "good";
|
||||||
|
if (["legacy_untracked", "pending", "unresolved"].includes(status)) return "warn";
|
||||||
|
if (["removed", "disabled"].includes(status)) return "";
|
||||||
|
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
|
||||||
|
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showBanner(message, kind) {
|
||||||
|
const el = document.getElementById("banner");
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = `banner show ${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
if (seconds == null || Number.isNaN(Number(seconds))) return "-";
|
||||||
|
const total = Math.max(0, Number(seconds));
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSettingsEditorFromState() {
|
||||||
|
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupOrder(groupName) {
|
||||||
|
return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareFieldEntries(a, b) {
|
||||||
|
const orderA = Number(a[1].ui_order || 9999);
|
||||||
|
const orderB = Number(b[1].ui_order || 9999);
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return String(a[0]).localeCompare(String(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settingsFieldKey(group, field) {
|
||||||
|
return `${group}.${field}`;
|
||||||
|
}
|
||||||
52
src/biliup_next/app/static/app/views/logs.js
Normal file
52
src/biliup_next/app/static/app/views/logs.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { state } from "../state.js";
|
||||||
|
import { escapeHtml, formatDate, showBanner } from "../utils.js";
|
||||||
|
|
||||||
|
export function filteredLogs() {
|
||||||
|
const search = (document.getElementById("logSearchInput")?.value || "").trim().toLowerCase();
|
||||||
|
return state.currentLogs.filter((item) => !search || item.name.toLowerCase().includes(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLogsList(items, onRefreshLog, onSelectLog) {
|
||||||
|
const wrap = document.getElementById("logList");
|
||||||
|
const stateEl = document.getElementById("logListState");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const visible = filteredLogs();
|
||||||
|
if (state.logListLoading) {
|
||||||
|
stateEl.textContent = "正在加载日志索引…";
|
||||||
|
stateEl.classList.add("show");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!visible.length) {
|
||||||
|
stateEl.textContent = "没有匹配日志文件。";
|
||||||
|
stateEl.classList.add("show");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateEl.classList.remove("show");
|
||||||
|
visible.forEach((item) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `task-card log-card ${state.selectedLogName === item.name ? "active" : ""}`;
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="task-title">${escapeHtml(item.name)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.path || "")}</div>
|
||||||
|
`;
|
||||||
|
row.onclick = () => onSelectLog(item.name);
|
||||||
|
wrap.appendChild(row);
|
||||||
|
});
|
||||||
|
if (!state.selectedLogName && visible[0]) onSelectLog(visible[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLogContent(payload) {
|
||||||
|
document.getElementById("logPath").textContent = payload.path || "-";
|
||||||
|
document.getElementById("logMeta").textContent = `updated ${formatDate(new Date().toISOString())}`;
|
||||||
|
const filter = (document.getElementById("logLineFilter")?.value || "").trim().toLowerCase();
|
||||||
|
const content = payload.content || "";
|
||||||
|
if (!filter) {
|
||||||
|
document.getElementById("logContent").textContent = content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filtered = content
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.toLowerCase().includes(filter))
|
||||||
|
.join("\n");
|
||||||
|
document.getElementById("logContent").textContent = filtered;
|
||||||
|
}
|
||||||
98
src/biliup_next/app/static/app/views/overview.js
Normal file
98
src/biliup_next/app/static/app/views/overview.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { renderDoctor } from "../components/doctor-check-list.js";
|
||||||
|
import { renderModules } from "../components/modules-list.js";
|
||||||
|
import { renderRecentActions } from "../components/recent-actions-list.js";
|
||||||
|
import { renderRuntimeSnapshot } from "../components/overview-runtime.js";
|
||||||
|
import {
|
||||||
|
renderOverviewRetrySummary,
|
||||||
|
renderOverviewTaskSummary,
|
||||||
|
} from "../components/overview-task-summary.js";
|
||||||
|
import { renderServices } from "../components/service-list.js";
|
||||||
|
import { escapeHtml } from "../utils.js";
|
||||||
|
|
||||||
|
export function renderShellStats({ health, doctor, tasks }) {
|
||||||
|
renderRuntimeSnapshot({ health, doctor, tasks });
|
||||||
|
renderOverviewTaskSummary(tasks.items);
|
||||||
|
renderOverviewRetrySummary(tasks.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSchedulerQueue(scheduler) {
|
||||||
|
const summary = document.getElementById("schedulerSummary");
|
||||||
|
const list = document.getElementById("schedulerList");
|
||||||
|
const stageScan = document.getElementById("stageScanSummary");
|
||||||
|
if (!summary || !list || !stageScan) return;
|
||||||
|
summary.innerHTML = "";
|
||||||
|
list.innerHTML = "";
|
||||||
|
stageScan.innerHTML = "";
|
||||||
|
|
||||||
|
const scheduledCount = scheduler?.scheduled?.length || 0;
|
||||||
|
const deferredCount = scheduler?.deferred?.length || 0;
|
||||||
|
const summaryData = scheduler?.summary || {};
|
||||||
|
const strategy = scheduler?.strategy || {};
|
||||||
|
[
|
||||||
|
["scheduled", scheduledCount, scheduledCount ? "warn" : ""],
|
||||||
|
["deferred", deferredCount, deferredCount ? "hot" : ""],
|
||||||
|
["scanned", summaryData.scanned_count || 0, ""],
|
||||||
|
["truncated", summaryData.truncated_count || 0, (summaryData.truncated_count || 0) ? "warn" : ""],
|
||||||
|
].forEach(([label, value, klass]) => {
|
||||||
|
const pill = document.createElement("div");
|
||||||
|
pill.className = `pill ${klass}`.trim();
|
||||||
|
pill.textContent = `${label} ${value}`;
|
||||||
|
summary.appendChild(pill);
|
||||||
|
});
|
||||||
|
|
||||||
|
const strategyRow = document.createElement("div");
|
||||||
|
strategyRow.className = "row-card";
|
||||||
|
strategyRow.innerHTML = `
|
||||||
|
<strong>Scheduler Strategy</strong>
|
||||||
|
<div class="muted-note">max_tasks_per_cycle=${escapeHtml(String(strategy.max_tasks_per_cycle ?? "-"))}, candidate_scan_limit=${escapeHtml(String(strategy.candidate_scan_limit ?? "-"))}</div>
|
||||||
|
<div class="muted-note">prioritize_retry_due=${escapeHtml(String(strategy.prioritize_retry_due ?? "-"))}, oldest_first=${escapeHtml(String(strategy.oldest_first ?? "-"))}</div>
|
||||||
|
<div class="muted-note">status_priority=${escapeHtml((strategy.status_priority || []).join(" > ") || "-")}</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(strategyRow);
|
||||||
|
|
||||||
|
const skipped = summaryData.skipped_counts || {};
|
||||||
|
const skippedRow = document.createElement("div");
|
||||||
|
skippedRow.className = "row-card";
|
||||||
|
skippedRow.innerHTML = `
|
||||||
|
<strong>Unscheduled Reasons</strong>
|
||||||
|
<div class="muted-note">failed_manual=${escapeHtml(String(skipped.failed_manual || 0))}</div>
|
||||||
|
<div class="muted-note">no_runnable_step=${escapeHtml(String(skipped.no_runnable_step || 0))}</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(skippedRow);
|
||||||
|
|
||||||
|
const items = [...(scheduler?.scheduled || []).map((item) => ({ ...item, queue: "scheduled" })), ...(scheduler?.deferred || []).map((item) => ({ ...item, queue: "deferred" }))];
|
||||||
|
if (!items.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "row-card";
|
||||||
|
empty.innerHTML = `<strong>当前无排队任务</strong><div class="muted-note">scheduler 本轮没有挑出需要执行或等待重试的任务。</div>`;
|
||||||
|
list.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
items.slice(0, 12).forEach((item) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(item.task_id)}</strong>
|
||||||
|
<span class="pill ${item.queue === "deferred" ? "hot" : "warn"}">${escapeHtml(item.queue)}</span>
|
||||||
|
${item.step_name ? `<span class="pill">${escapeHtml(item.step_name)}</span>` : ""}
|
||||||
|
${item.task_status ? `<span class="pill">${escapeHtml(item.task_status)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.reason || (item.waiting_for_retry ? "waiting_for_retry" : "-"))}</div>
|
||||||
|
${item.remaining_seconds != null ? `<div class="muted-note">remaining ${escapeHtml(String(item.remaining_seconds))}s</div>` : ""}
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = scheduler?.stage_scan || { accepted: [], rejected: [], skipped: [] };
|
||||||
|
[
|
||||||
|
["accepted", scan.accepted?.length || 0],
|
||||||
|
["rejected", scan.rejected?.length || 0],
|
||||||
|
["skipped", scan.skipped?.length || 0],
|
||||||
|
].forEach(([label, value]) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `<strong>${escapeHtml(label)}</strong><div class="muted-note">${escapeHtml(String(value))}</div>`;
|
||||||
|
stageScan.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
162
src/biliup_next/app/static/app/views/settings.js
Normal file
162
src/biliup_next/app/static/app/views/settings.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { state } from "../state.js";
|
||||||
|
import {
|
||||||
|
compareFieldEntries,
|
||||||
|
escapeHtml,
|
||||||
|
getGroupOrder,
|
||||||
|
settingsFieldKey,
|
||||||
|
syncSettingsEditorFromState,
|
||||||
|
} from "../utils.js";
|
||||||
|
|
||||||
|
export function renderSettingsForm(onFieldChange) {
|
||||||
|
const wrap = document.getElementById("settingsForm");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
if (!state.currentSettingsSchema?.groups) return;
|
||||||
|
const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const featuredContainer = document.createElement("div");
|
||||||
|
featuredContainer.className = "settings-groups";
|
||||||
|
const advancedDetails = document.createElement("details");
|
||||||
|
advancedDetails.className = "settings-advanced";
|
||||||
|
advancedDetails.innerHTML = "<summary>Advanced Settings</summary>";
|
||||||
|
const advancedContainer = document.createElement("div");
|
||||||
|
advancedContainer.className = "settings-groups";
|
||||||
|
|
||||||
|
const createSettingsField = (groupName, fieldName, fieldSchema) => {
|
||||||
|
const key = settingsFieldKey(groupName, fieldName);
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "settings-field";
|
||||||
|
if (state.settingsDirtyFields[key]) row.classList.add("dirty");
|
||||||
|
if (state.settingsFieldErrors[key]) row.classList.add("error");
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "settings-label";
|
||||||
|
label.textContent = fieldSchema.title || `${groupName}.${fieldName}`;
|
||||||
|
if (fieldSchema.ui_widget) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "settings-badge";
|
||||||
|
badge.textContent = fieldSchema.ui_widget;
|
||||||
|
label.appendChild(badge);
|
||||||
|
}
|
||||||
|
if (fieldSchema.ui_featured === true) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "settings-badge";
|
||||||
|
badge.textContent = "featured";
|
||||||
|
label.appendChild(badge);
|
||||||
|
}
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const value = state.currentSettings[groupName]?.[fieldName];
|
||||||
|
let input;
|
||||||
|
if (fieldSchema.type === "boolean") {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = "checkbox";
|
||||||
|
input.checked = Boolean(value);
|
||||||
|
} else if (Array.isArray(fieldSchema.enum)) {
|
||||||
|
input = document.createElement("select");
|
||||||
|
fieldSchema.enum.forEach((optionValue) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(optionValue);
|
||||||
|
option.textContent = String(optionValue);
|
||||||
|
if (value === optionValue) option.selected = true;
|
||||||
|
input.appendChild(option);
|
||||||
|
});
|
||||||
|
} else if (fieldSchema.type === "array") {
|
||||||
|
input = document.createElement("textarea");
|
||||||
|
input.style.minHeight = "96px";
|
||||||
|
input.value = JSON.stringify(value ?? [], null, 2);
|
||||||
|
} else {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text");
|
||||||
|
input.value = value ?? "";
|
||||||
|
if (fieldSchema.type === "integer") {
|
||||||
|
if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum);
|
||||||
|
input.step = "1";
|
||||||
|
}
|
||||||
|
if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder;
|
||||||
|
}
|
||||||
|
input.dataset.group = groupName;
|
||||||
|
input.dataset.field = fieldName;
|
||||||
|
input.onchange = onFieldChange;
|
||||||
|
row.appendChild(input);
|
||||||
|
|
||||||
|
const originalValue = state.originalSettings[groupName]?.[fieldName];
|
||||||
|
const currentValue = state.currentSettings[groupName]?.[fieldName];
|
||||||
|
const changed = JSON.stringify(originalValue ?? null) !== JSON.stringify(currentValue ?? null);
|
||||||
|
if (changed) {
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
controls.className = "button-row";
|
||||||
|
const revert = document.createElement("button");
|
||||||
|
revert.className = "secondary compact";
|
||||||
|
revert.type = "button";
|
||||||
|
revert.textContent = "撤销本字段";
|
||||||
|
revert.dataset.revertGroup = groupName;
|
||||||
|
revert.dataset.revertField = fieldName;
|
||||||
|
controls.appendChild(revert);
|
||||||
|
row.appendChild(controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldSchema.description || fieldSchema.sensitive) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "hint";
|
||||||
|
let text = fieldSchema.description || "";
|
||||||
|
if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`;
|
||||||
|
hint.textContent = text;
|
||||||
|
row.appendChild(hint);
|
||||||
|
}
|
||||||
|
if (state.settingsFieldErrors[key]) {
|
||||||
|
const error = document.createElement("div");
|
||||||
|
error.className = "field-error";
|
||||||
|
error.textContent = state.settingsFieldErrors[key];
|
||||||
|
row.appendChild(error);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSettingsGroup = (groupName, fields, featured) => {
|
||||||
|
const entries = Object.entries(fields);
|
||||||
|
if (!entries.length) return null;
|
||||||
|
const group = document.createElement("div");
|
||||||
|
group.className = `settings-group ${featured ? "featured" : ""}`.trim();
|
||||||
|
group.innerHTML = `<h3>${escapeHtml(state.currentSettingsSchema.group_ui?.[groupName]?.title || groupName)}</h3>`;
|
||||||
|
const descText = state.currentSettingsSchema.group_ui?.[groupName]?.description;
|
||||||
|
if (descText) {
|
||||||
|
const desc = document.createElement("div");
|
||||||
|
desc.className = "group-desc";
|
||||||
|
desc.textContent = descText;
|
||||||
|
group.appendChild(desc);
|
||||||
|
}
|
||||||
|
const fieldWrap = document.createElement("div");
|
||||||
|
fieldWrap.className = "settings-fields";
|
||||||
|
entries.forEach(([fieldName, fieldSchema]) => fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema)));
|
||||||
|
group.appendChild(fieldWrap);
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(state.currentSettingsSchema.groups)
|
||||||
|
.sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0]))
|
||||||
|
.forEach(([groupName, fields]) => {
|
||||||
|
const featuredFields = {};
|
||||||
|
const advancedFields = {};
|
||||||
|
Object.entries(fields).sort((a, b) => compareFieldEntries(a, b)).forEach(([fieldName, fieldSchema]) => {
|
||||||
|
const key = `${groupName}.${fieldName}`.toLowerCase();
|
||||||
|
if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) return;
|
||||||
|
if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema;
|
||||||
|
else advancedFields[fieldName] = fieldSchema;
|
||||||
|
});
|
||||||
|
const featuredGroup = createSettingsGroup(groupName, featuredFields, true);
|
||||||
|
const advancedGroup = createSettingsGroup(groupName, advancedFields, false);
|
||||||
|
if (featuredGroup) featuredContainer.appendChild(featuredGroup);
|
||||||
|
if (advancedGroup) advancedContainer.appendChild(advancedGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!featuredContainer.children.length && !advancedContainer.children.length) {
|
||||||
|
wrap.innerHTML = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (featuredContainer.children.length) wrap.appendChild(featuredContainer);
|
||||||
|
if (advancedContainer.children.length) {
|
||||||
|
advancedDetails.appendChild(advancedContainer);
|
||||||
|
wrap.appendChild(advancedDetails);
|
||||||
|
}
|
||||||
|
syncSettingsEditorFromState();
|
||||||
|
}
|
||||||
462
src/biliup_next/app/static/app/views/tasks.js
Normal file
462
src/biliup_next/app/static/app/views/tasks.js
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
import { state, setTaskPage } from "../state.js";
|
||||||
|
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||||
|
import { renderArtifactList } from "../components/artifact-list.js";
|
||||||
|
import { renderHistoryList } from "../components/history-list.js";
|
||||||
|
import { renderRetryPanel } from "../components/retry-banner.js";
|
||||||
|
import { renderStepList } from "../components/step-list.js";
|
||||||
|
import { renderTaskHero } from "../components/task-hero.js";
|
||||||
|
import { renderTimelineList } from "../components/timeline-list.js";
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
created: "待转录",
|
||||||
|
transcribed: "待识歌",
|
||||||
|
songs_detected: "待切歌",
|
||||||
|
split_done: "待上传",
|
||||||
|
published: "待收尾",
|
||||||
|
collection_synced: "已完成",
|
||||||
|
failed_retryable: "待重试",
|
||||||
|
failed_manual: "待人工",
|
||||||
|
running: "处理中",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELIVERY_LABELS = {
|
||||||
|
done: "已发送",
|
||||||
|
pending: "待处理",
|
||||||
|
legacy_untracked: "历史未追踪",
|
||||||
|
resolved: "已定位",
|
||||||
|
unresolved: "未定位",
|
||||||
|
present: "保留",
|
||||||
|
removed: "已清理",
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayStatus(status) {
|
||||||
|
return STATUS_LABELS[status] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDelivery(status) {
|
||||||
|
return DELIVERY_LABELS[status] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupState(deliveryState = {}) {
|
||||||
|
return deliveryState.source_video_present === false || deliveryState.split_videos_present === false
|
||||||
|
? "removed"
|
||||||
|
: "present";
|
||||||
|
}
|
||||||
|
|
||||||
|
function attentionState(task) {
|
||||||
|
if (task.status === "failed_manual") return "manual_now";
|
||||||
|
if (task.retry_state?.retry_due) return "retry_now";
|
||||||
|
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
|
||||||
|
if (task.status === "running") return "in_progress";
|
||||||
|
return "stable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAttention(status) {
|
||||||
|
return {
|
||||||
|
manual_now: "需人工",
|
||||||
|
retry_now: "立即重试",
|
||||||
|
waiting_retry: "等待重试",
|
||||||
|
in_progress: "处理中",
|
||||||
|
stable: "正常",
|
||||||
|
}[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attentionClass(status) {
|
||||||
|
if (status === "manual_now") return "hot";
|
||||||
|
if (["retry_now", "waiting_retry", "in_progress"].includes(status)) return "warn";
|
||||||
|
return "good";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareText(a, b) {
|
||||||
|
return String(a || "").localeCompare(String(b || ""), "zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBySort(sort, a, b) {
|
||||||
|
const deliveryA = a.delivery_state || {};
|
||||||
|
const deliveryB = b.delivery_state || {};
|
||||||
|
if (sort === "updated_asc") return compareText(a.updated_at, b.updated_at);
|
||||||
|
if (sort === "title_asc") return compareText(a.title, b.title);
|
||||||
|
if (sort === "title_desc") return compareText(b.title, a.title);
|
||||||
|
if (sort === "next_retry_asc") {
|
||||||
|
const diff = compareText(a.retry_state?.next_retry_at || "9999", b.retry_state?.next_retry_at || "9999");
|
||||||
|
return diff || compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
if (sort === "attention_state") {
|
||||||
|
const diff = compareText(attentionState(a), attentionState(b));
|
||||||
|
return diff || compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
if (sort === "split_comment_status") {
|
||||||
|
const diff = compareText(deliveryA.split_comment, deliveryB.split_comment);
|
||||||
|
return diff || compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
if (sort === "full_comment_status") {
|
||||||
|
const diff = compareText(deliveryA.full_video_timeline_comment, deliveryB.full_video_timeline_comment);
|
||||||
|
return diff || compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
if (sort === "cleanup_state") {
|
||||||
|
const diff = compareText(cleanupState(deliveryA), cleanupState(deliveryB));
|
||||||
|
return diff || compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
return compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerSortValue(field, currentSort) {
|
||||||
|
const fieldMap = {
|
||||||
|
title: ["title_asc", "title_desc"],
|
||||||
|
status: ["status_group", "updated_desc"],
|
||||||
|
attention: ["attention_state", "updated_desc"],
|
||||||
|
split_comment: ["split_comment_status", "updated_desc"],
|
||||||
|
full_comment: ["full_comment_status", "updated_desc"],
|
||||||
|
cleanup: ["cleanup_state", "updated_desc"],
|
||||||
|
next_retry: ["next_retry_asc", "updated_desc"],
|
||||||
|
updated: ["updated_desc", "updated_asc"],
|
||||||
|
};
|
||||||
|
const [primary, secondary] = fieldMap[field] || ["updated_desc", "updated_asc"];
|
||||||
|
return currentSort === primary ? secondary : primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerLabel(text, field, currentSort) {
|
||||||
|
const activeSorts = {
|
||||||
|
title: ["title_asc", "title_desc"],
|
||||||
|
status: ["status_group"],
|
||||||
|
attention: ["attention_state"],
|
||||||
|
split_comment: ["split_comment_status"],
|
||||||
|
full_comment: ["full_comment_status"],
|
||||||
|
cleanup: ["cleanup_state"],
|
||||||
|
next_retry: ["next_retry_asc"],
|
||||||
|
updated: ["updated_desc", "updated_asc"],
|
||||||
|
};
|
||||||
|
const active = activeSorts[field]?.includes(currentSort) ? " active" : "";
|
||||||
|
const direction = currentSort === "updated_desc" && field === "updated"
|
||||||
|
? "↓"
|
||||||
|
: currentSort === "updated_asc" && field === "updated"
|
||||||
|
? "↑"
|
||||||
|
: currentSort === "title_asc" && field === "title"
|
||||||
|
? "↑"
|
||||||
|
: currentSort === "title_desc" && field === "title"
|
||||||
|
? "↓"
|
||||||
|
: currentSort === "status_group" && field === "status"
|
||||||
|
? "•"
|
||||||
|
: currentSort === "attention_state" && field === "attention"
|
||||||
|
? "•"
|
||||||
|
: currentSort === "split_comment_status" && field === "split_comment"
|
||||||
|
? "•"
|
||||||
|
: currentSort === "full_comment_status" && field === "full_comment"
|
||||||
|
? "•"
|
||||||
|
: currentSort === "cleanup_state" && field === "cleanup"
|
||||||
|
? "•"
|
||||||
|
: currentSort === "next_retry_asc" && field === "next_retry"
|
||||||
|
? "↑"
|
||||||
|
: "";
|
||||||
|
return `<button class="table-sort-btn${active}" data-sort-field="${field}">${escapeHtml(text)}${direction ? `<span>${direction}</span>` : ""}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filteredTasks() {
|
||||||
|
const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase();
|
||||||
|
const status = document.getElementById("taskStatusFilter")?.value || "";
|
||||||
|
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
|
||||||
|
const delivery = document.getElementById("taskDeliveryFilter")?.value || "";
|
||||||
|
const attention = document.getElementById("taskAttentionFilter")?.value || "";
|
||||||
|
let items = state.currentTasks.filter((task) => {
|
||||||
|
const haystack = `${task.id} ${task.title}`.toLowerCase();
|
||||||
|
if (search && !haystack.includes(search)) return false;
|
||||||
|
if (status && task.status !== status) return false;
|
||||||
|
const deliveryState = task.delivery_state || {};
|
||||||
|
if (delivery === "legacy_untracked" && deliveryState.full_video_timeline_comment !== "legacy_untracked") return false;
|
||||||
|
if (delivery === "pending_comment" && deliveryState.split_comment !== "pending" && deliveryState.full_video_timeline_comment !== "pending") return false;
|
||||||
|
if (delivery === "cleanup_removed" && deliveryState.source_video_present !== false && deliveryState.split_videos_present !== false) return false;
|
||||||
|
if (attention && attentionState(task) !== attention) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const statusRank = {
|
||||||
|
failed_manual: 0,
|
||||||
|
failed_retryable: 1,
|
||||||
|
running: 2,
|
||||||
|
created: 3,
|
||||||
|
transcribed: 4,
|
||||||
|
songs_detected: 5,
|
||||||
|
split_done: 6,
|
||||||
|
published: 7,
|
||||||
|
collection_synced: 8,
|
||||||
|
};
|
||||||
|
items = [...items].sort((a, b) => {
|
||||||
|
if (sort === "status_group") {
|
||||||
|
const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return compareText(b.updated_at, a.updated_at);
|
||||||
|
}
|
||||||
|
return compareBySort(sort, a, b);
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pagedTasks(items = filteredTasks()) {
|
||||||
|
const total = items.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / state.taskPageSize));
|
||||||
|
const safePage = Math.min(Math.max(1, state.taskPage), totalPages);
|
||||||
|
if (safePage !== state.taskPage) setTaskPage(safePage);
|
||||||
|
const start = (safePage - 1) * state.taskPageSize;
|
||||||
|
const end = start + state.taskPageSize;
|
||||||
|
return {
|
||||||
|
items: items.slice(start, end),
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
page: safePage,
|
||||||
|
pageSize: state.taskPageSize,
|
||||||
|
start: total ? start + 1 : 0,
|
||||||
|
end: Math.min(end, total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTaskStatusSummary(items = filteredTasks()) {
|
||||||
|
const wrap = document.getElementById("taskStatusSummary");
|
||||||
|
if (!wrap) return;
|
||||||
|
const counts = new Map();
|
||||||
|
items.forEach((item) => counts.set(item.status, (counts.get(item.status) || 0) + 1));
|
||||||
|
const orderedStatuses = ["running", "failed_retryable", "failed_manual", "created", "transcribed", "songs_detected", "split_done", "published", "collection_synced"];
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const totalPill = document.createElement("div");
|
||||||
|
totalPill.className = "pill";
|
||||||
|
totalPill.textContent = `filtered ${items.length}`;
|
||||||
|
wrap.appendChild(totalPill);
|
||||||
|
orderedStatuses.forEach((status) => {
|
||||||
|
const count = counts.get(status);
|
||||||
|
if (!count) return;
|
||||||
|
const pill = document.createElement("div");
|
||||||
|
pill.className = `pill ${statusClass(status)}`;
|
||||||
|
pill.textContent = `${status} ${count}`;
|
||||||
|
wrap.appendChild(pill);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTaskPagination(items = filteredTasks()) {
|
||||||
|
const meta = pagedTasks(items);
|
||||||
|
const summary = document.getElementById("taskPaginationSummary");
|
||||||
|
const prevBtn = document.getElementById("taskPrevPageBtn");
|
||||||
|
const nextBtn = document.getElementById("taskNextPageBtn");
|
||||||
|
const sizeSelect = document.getElementById("taskPageSizeSelect");
|
||||||
|
if (summary) {
|
||||||
|
summary.textContent = meta.total
|
||||||
|
? `showing ${meta.start}-${meta.end} of ${meta.total} · page ${meta.page}/${meta.totalPages}`
|
||||||
|
: "没有可显示的任务";
|
||||||
|
}
|
||||||
|
if (prevBtn) prevBtn.disabled = meta.page <= 1;
|
||||||
|
if (nextBtn) nextBtn.disabled = meta.page >= meta.totalPages;
|
||||||
|
if (sizeSelect) sizeSelect.value = String(state.taskPageSize);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTaskListState(items = filteredTasks()) {
|
||||||
|
const stateEl = document.getElementById("taskListState");
|
||||||
|
if (!stateEl) return;
|
||||||
|
if (state.taskListLoading) {
|
||||||
|
stateEl.textContent = "正在加载任务列表…";
|
||||||
|
stateEl.classList.add("show");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
stateEl.textContent = "没有匹配任务,调整筛选条件后重试。";
|
||||||
|
stateEl.classList.add("show");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateEl.classList.remove("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTasks(onSelect, onRowAction = null) {
|
||||||
|
const wrap = document.getElementById("taskList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const items = filteredTasks();
|
||||||
|
renderTaskStatusSummary(items);
|
||||||
|
renderTaskListState(items);
|
||||||
|
const meta = renderTaskPagination(items);
|
||||||
|
if (state.taskListLoading) {
|
||||||
|
wrap.innerHTML = `<div class="task-table-loading">正在加载任务表…</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!meta.items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "task-table";
|
||||||
|
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
|
||||||
|
table.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>${headerLabel("任务", "title", sort)}</th>
|
||||||
|
<th>${headerLabel("状态", "status", sort)}</th>
|
||||||
|
<th>${headerLabel("关注", "attention", sort)}</th>
|
||||||
|
<th>${headerLabel("纯享评论", "split_comment", sort)}</th>
|
||||||
|
<th>${headerLabel("主视频评论", "full_comment", sort)}</th>
|
||||||
|
<th>${headerLabel("清理", "cleanup", sort)}</th>
|
||||||
|
<th>${headerLabel("下次重试", "next_retry", sort)}</th>
|
||||||
|
<th>${headerLabel("更新时间", "updated", sort)}</th>
|
||||||
|
<th>快捷操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
`;
|
||||||
|
const tbody = table.querySelector("tbody");
|
||||||
|
for (const item of meta.items) {
|
||||||
|
const delivery = item.delivery_state || {};
|
||||||
|
const attention = attentionState(item);
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.className = item.id === state.selectedTaskId ? "active" : "";
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<div class="task-cell-title">${escapeHtml(item.title)}</div>
|
||||||
|
<div class="task-cell-subtitle">${escapeHtml(item.id)}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayStatus(item.status))}</span></td>
|
||||||
|
<td><span class="pill ${attentionClass(attention)}">${escapeHtml(displayAttention(attention))}</span></td>
|
||||||
|
<td><span class="pill ${statusClass(delivery.split_comment || "")}">${escapeHtml(displayDelivery(delivery.split_comment || "-"))}</span></td>
|
||||||
|
<td><span class="pill ${statusClass(delivery.full_video_timeline_comment || "")}">${escapeHtml(displayDelivery(delivery.full_video_timeline_comment || "-"))}</span></td>
|
||||||
|
<td><span class="pill ${statusClass(cleanupState(delivery))}">${escapeHtml(displayDelivery(cleanupState(delivery)))}</span></td>
|
||||||
|
<td>
|
||||||
|
${item.retry_state?.next_retry_at ? `<div>${escapeHtml(formatDate(item.retry_state.next_retry_at))}</div>` : `<span class="muted-note">-</span>`}
|
||||||
|
${item.retry_state?.retry_remaining_seconds != null ? `<div class="muted-note">${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>` : ""}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>${escapeHtml(formatDate(item.updated_at))}</div>
|
||||||
|
${item.retry_state?.next_retry_at ? `<div class="muted-note">retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))}</div>` : ""}
|
||||||
|
</td>
|
||||||
|
<td class="task-table-actions">
|
||||||
|
<button class="secondary compact inline-action-btn" data-task-action="open">打开</button>
|
||||||
|
<button class="compact inline-action-btn" data-task-action="run">${attention === "manual_now" || attention === "retry_now" ? "重跑" : "执行"}</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
row.onclick = () => onSelect(item.id);
|
||||||
|
row.querySelectorAll("[data-task-action]").forEach((button) => {
|
||||||
|
button.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (button.dataset.taskAction === "open") return onSelect(item.id);
|
||||||
|
if (button.dataset.taskAction === "run" && onRowAction) return onRowAction("run", item.id);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
table.querySelectorAll("[data-sort-field]").forEach((button) => {
|
||||||
|
button.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const select = document.getElementById("taskSortSelect");
|
||||||
|
if (!select) return;
|
||||||
|
select.value = headerSortValue(button.dataset.sortField, select.value);
|
||||||
|
select.dispatchEvent(new Event("change"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
wrap.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTaskDetail(payload, onStepSelect) {
|
||||||
|
const { task, steps, artifacts, history, timeline } = payload;
|
||||||
|
renderTaskHero(task, steps);
|
||||||
|
renderRetryPanel(task);
|
||||||
|
|
||||||
|
const detail = document.getElementById("taskDetail");
|
||||||
|
detail.innerHTML = "";
|
||||||
|
[
|
||||||
|
["Task ID", task.id],
|
||||||
|
["Status", task.status],
|
||||||
|
["Created", formatDate(task.created_at)],
|
||||||
|
["Updated", formatDate(task.updated_at)],
|
||||||
|
["Source", task.source_path],
|
||||||
|
["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"],
|
||||||
|
].forEach(([key, value]) => {
|
||||||
|
const k = document.createElement("div");
|
||||||
|
k.className = "detail-key";
|
||||||
|
k.textContent = key;
|
||||||
|
const v = document.createElement("div");
|
||||||
|
v.textContent = value || "-";
|
||||||
|
detail.appendChild(k);
|
||||||
|
detail.appendChild(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
let summaryText = "暂无最近结果";
|
||||||
|
const latestAction = history.items[0];
|
||||||
|
if (latestAction) {
|
||||||
|
summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`;
|
||||||
|
} else {
|
||||||
|
const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"];
|
||||||
|
const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status));
|
||||||
|
const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0];
|
||||||
|
if (summaryStep) {
|
||||||
|
summaryText = summaryStep.error_message
|
||||||
|
? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}`
|
||||||
|
: `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const delivery = task.delivery_state || {};
|
||||||
|
const summaryEl = document.getElementById("taskSummary");
|
||||||
|
summaryEl.innerHTML = `
|
||||||
|
<div class="summary-title">Recent Result</div>
|
||||||
|
<div class="summary-text">${escapeHtml(summaryText)}</div>
|
||||||
|
<div class="summary-title" style="margin-top:14px;">Delivery State</div>
|
||||||
|
<div class="delivery-grid">
|
||||||
|
${renderDeliveryState("Split Comment", delivery.split_comment || "-")}
|
||||||
|
${renderDeliveryState("Full Timeline", delivery.full_video_timeline_comment || "-")}
|
||||||
|
${renderDeliveryState("Full Video BV", delivery.full_video_bvid_resolved ? "resolved" : "unresolved")}
|
||||||
|
${renderDeliveryState("Source Video", delivery.source_video_present ? "present" : "removed")}
|
||||||
|
${renderDeliveryState("Split Videos", delivery.split_videos_present ? "present" : "removed")}
|
||||||
|
${renderDeliveryState(
|
||||||
|
"Cleanup Policy",
|
||||||
|
`source=${delivery.cleanup_enabled?.delete_source_video_after_collection_synced ? "on" : "off"} / split=${delivery.cleanup_enabled?.delete_split_videos_after_collection_synced ? "on" : "off"}`,
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
renderStepList(steps, onStepSelect);
|
||||||
|
renderArtifactList(artifacts);
|
||||||
|
renderHistoryList(history);
|
||||||
|
renderTimelineList(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeliveryState(label, value, forcedClass = null) {
|
||||||
|
const klass = forcedClass === null ? statusClass(value) : forcedClass;
|
||||||
|
return `
|
||||||
|
<div class="delivery-card">
|
||||||
|
<div class="delivery-label">${escapeHtml(label)}</div>
|
||||||
|
<div class="delivery-value"><span class="pill ${klass}">${escapeHtml(String(value))}</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTaskWorkspaceState(mode, message = "") {
|
||||||
|
const stateEl = document.getElementById("taskWorkspaceState");
|
||||||
|
const hero = document.getElementById("taskHero");
|
||||||
|
const retry = document.getElementById("taskRetryPanel");
|
||||||
|
const detail = document.getElementById("taskDetail");
|
||||||
|
const summary = document.getElementById("taskSummary");
|
||||||
|
const stepList = document.getElementById("stepList");
|
||||||
|
const artifactList = document.getElementById("artifactList");
|
||||||
|
const historyList = document.getElementById("historyList");
|
||||||
|
const timelineList = document.getElementById("timelineList");
|
||||||
|
if (!stateEl) return;
|
||||||
|
|
||||||
|
stateEl.className = "task-workspace-state show";
|
||||||
|
if (mode === "loading") stateEl.classList.add("loading");
|
||||||
|
if (mode === "error") stateEl.classList.add("error");
|
||||||
|
stateEl.textContent =
|
||||||
|
message ||
|
||||||
|
(mode === "loading"
|
||||||
|
? "正在加载任务详情…"
|
||||||
|
: mode === "error"
|
||||||
|
? "任务详情加载失败。"
|
||||||
|
: "选择一个任务后,这里会显示当前链路、重试状态和最近动作。");
|
||||||
|
|
||||||
|
if (mode === "ready") {
|
||||||
|
stateEl.className = "task-workspace-state";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hero.className = "task-hero empty";
|
||||||
|
hero.textContent = stateEl.textContent;
|
||||||
|
retry.className = "retry-banner";
|
||||||
|
retry.style.display = "none";
|
||||||
|
retry.textContent = "";
|
||||||
|
detail.innerHTML = "";
|
||||||
|
summary.textContent = mode === "error" ? stateEl.textContent : "暂无最近结果";
|
||||||
|
stepList.innerHTML = "";
|
||||||
|
artifactList.innerHTML = "";
|
||||||
|
historyList.innerHTML = "";
|
||||||
|
timelineList.innerHTML = "";
|
||||||
|
}
|
||||||
815
src/biliup_next/app/static/dashboard.css
Normal file
815
src/biliup_next/app/static/dashboard.css
Normal file
@ -0,0 +1,815 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f3efe8;
|
||||||
|
--paper: rgba(255, 252, 247, 0.92);
|
||||||
|
--paper-strong: rgba(255, 255, 255, 0.98);
|
||||||
|
--ink: #1d1a16;
|
||||||
|
--muted: #6b6159;
|
||||||
|
--line: rgba(29, 26, 22, 0.12);
|
||||||
|
--line-strong: rgba(29, 26, 22, 0.2);
|
||||||
|
--accent: #b24b1a;
|
||||||
|
--accent-2: #0e6c62;
|
||||||
|
--warn: #9a690f;
|
||||||
|
--good-bg: rgba(14, 108, 98, 0.12);
|
||||||
|
--warn-bg: rgba(154, 105, 15, 0.12);
|
||||||
|
--hot-bg: rgba(178, 75, 26, 0.12);
|
||||||
|
--shadow: 0 24px 70px rgba(57, 37, 16, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(178, 75, 26, 0.14), transparent 30%),
|
||||||
|
radial-gradient(circle at top right, rgba(14, 108, 98, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f7f2ea 0%, #efe7dc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: min(1680px, calc(100vw - 28px));
|
||||||
|
margin: 18px auto 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.panel,
|
||||||
|
.topbar {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 22px;
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 0.92;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-copy {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav,
|
||||||
|
.button-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-token {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn,
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: rgba(255,255,255,0.84);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
background: linear-gradient(135deg, rgba(178, 75, 26, 0.12), rgba(255,255,255,0.95));
|
||||||
|
border-color: rgba(178, 75, 26, 0.28);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.compact {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 18px 22px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(24px, 3vw, 38px);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip,
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(29, 26, 22, 0.07);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner,
|
||||||
|
.retry-banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.show,
|
||||||
|
.retry-banner.show { display: block; }
|
||||||
|
.banner.ok { background: var(--good-bg); color: var(--accent-2); }
|
||||||
|
.banner.warn,
|
||||||
|
.retry-banner.warn { background: var(--warn-bg); color: var(--warn); }
|
||||||
|
.banner.err,
|
||||||
|
.retry-banner.hot { background: var(--hot-bg); color: var(--accent); }
|
||||||
|
.retry-banner.good { background: var(--good-bg); color: var(--accent-2); }
|
||||||
|
|
||||||
|
.view {
|
||||||
|
display: none;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.active { display: grid; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid.two-up {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.row-card,
|
||||||
|
.task-card,
|
||||||
|
.service-card,
|
||||||
|
.summary-card,
|
||||||
|
.timeline-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.row-card,
|
||||||
|
.task-card,
|
||||||
|
.service-card,
|
||||||
|
.summary-card,
|
||||||
|
.timeline-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255,255,255,0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid,
|
||||||
|
.field-grid,
|
||||||
|
.task-filters {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid,
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-filters {
|
||||||
|
grid-template-columns: 1.2fr .8fr .8fr;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-index-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pagination-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-state {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.6);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-state.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid { margin-top: 10px; }
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
pre {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea,
|
||||||
|
pre {
|
||||||
|
font: 13px/1.55 "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea { min-height: 320px; resize: vertical; }
|
||||||
|
pre { margin: 0; min-height: 240px; overflow: auto; }
|
||||||
|
|
||||||
|
.muted-note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list,
|
||||||
|
.timeline-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-card.active {
|
||||||
|
border-color: rgba(178, 75, 26, 0.34);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-wrap {
|
||||||
|
max-height: calc(100vh - 320px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 860px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table th,
|
||||||
|
.task-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(243, 239, 232, 0.96);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sort-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sort-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sort-btn:hover {
|
||||||
|
background: rgba(178, 75, 26, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table tbody tr:hover {
|
||||||
|
background: rgba(178, 75, 26, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table tbody tr.active {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-loading {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-cell-title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table .pill {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-action-btn {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-action-btn:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-cell-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row,
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.good { background: var(--good-bg); color: var(--accent-2); }
|
||||||
|
.pill.warn { background: var(--warn-bg); color: var(--warn); }
|
||||||
|
.pill.hot { background: var(--hot-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
.tasks-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace-state {
|
||||||
|
display: none;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,0.62);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace-state.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace-state.loading {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-bg);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace-state.error {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--hot-bg);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-hero {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(249,242,234,0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-hero.empty { color: var(--muted); }
|
||||||
|
|
||||||
|
.task-hero-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-hero-subtitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-hero-delivery {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(260px, .85fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-key {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card-title,
|
||||||
|
.timeline-title {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-card-metrics,
|
||||||
|
.timeline-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-metric,
|
||||||
|
.timeline-meta-line {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-metric strong,
|
||||||
|
.timeline-meta-line strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-path {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar,
|
||||||
|
.settings-groups,
|
||||||
|
.settings-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255,255,255,0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group.featured {
|
||||||
|
border-color: rgba(178, 75, 26, 0.24);
|
||||||
|
background: linear-gradient(180deg, rgba(255,249,243,0.96), rgba(255,255,255,0.76));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-desc,
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field.dirty {
|
||||||
|
background: rgba(178, 75, 26, 0.08);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field.error {
|
||||||
|
background: rgba(178, 75, 26, 0.1);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field .button-row {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-advanced {
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255,255,255,0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(29, 26, 22, 0.08);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-layout {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-index-panel {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card.active {
|
||||||
|
border-color: rgba(14, 108, 98, 0.34);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(14, 108, 98, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
.app-shell,
|
||||||
|
.tasks-layout,
|
||||||
|
.logs-workspace,
|
||||||
|
.panel-grid.two-up,
|
||||||
|
.detail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.app-shell {
|
||||||
|
width: min(100vw - 20px, 100%);
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
.topbar,
|
||||||
|
.sidebar,
|
||||||
|
.panel {
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
.stats,
|
||||||
|
.hero-meta-grid,
|
||||||
|
.filter-grid,
|
||||||
|
.field-grid,
|
||||||
|
.task-filters,
|
||||||
|
.task-pagination-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.task-pagination-toolbar {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
805
src/biliup_next/app/static/dashboard.js
Normal file
805
src/biliup_next/app/static/dashboard.js
Normal file
@ -0,0 +1,805 @@
|
|||||||
|
let selectedTaskId = null;
|
||||||
|
let selectedStepName = null;
|
||||||
|
let currentTasks = [];
|
||||||
|
let currentSettings = {};
|
||||||
|
let currentSettingsSchema = null;
|
||||||
|
let currentView = "overview";
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
|
||||||
|
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
|
||||||
|
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBanner(message, kind) {
|
||||||
|
const el = document.getElementById("banner");
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = `banner show ${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds == null || Number.isNaN(Number(seconds))) return "-";
|
||||||
|
const total = Math.max(0, Number(seconds));
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(view) {
|
||||||
|
currentView = view;
|
||||||
|
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.view === view);
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".view").forEach((section) => {
|
||||||
|
section.classList.toggle("active", section.dataset.view === view);
|
||||||
|
});
|
||||||
|
const titleMap = {
|
||||||
|
overview: "Overview",
|
||||||
|
tasks: "Tasks",
|
||||||
|
settings: "Settings",
|
||||||
|
logs: "Logs",
|
||||||
|
};
|
||||||
|
document.getElementById("viewTitle").textContent = titleMap[view] || "Control";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, options) {
|
||||||
|
const token = localStorage.getItem("biliup_next_token") || "";
|
||||||
|
const opts = options ? { ...options } : {};
|
||||||
|
opts.headers = { ...(opts.headers || {}) };
|
||||||
|
if (token) opts.headers["X-Biliup-Token"] = token;
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", "20");
|
||||||
|
const status = document.getElementById("historyStatusFilter")?.value || "";
|
||||||
|
const actionName = document.getElementById("historyActionFilter")?.value.trim() || "";
|
||||||
|
const currentOnly = document.getElementById("historyCurrentTask")?.checked;
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (actionName) params.set("action_name", actionName);
|
||||||
|
if (currentOnly && selectedTaskId) params.set("task_id", selectedTaskId);
|
||||||
|
return `/history?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredTasks() {
|
||||||
|
const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase();
|
||||||
|
const status = document.getElementById("taskStatusFilter")?.value || "";
|
||||||
|
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
|
||||||
|
|
||||||
|
let items = currentTasks.filter((task) => {
|
||||||
|
const haystack = `${task.id} ${task.title}`.toLowerCase();
|
||||||
|
if (search && !haystack.includes(search)) return false;
|
||||||
|
if (status && task.status !== status) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusRank = {
|
||||||
|
failed_manual: 0,
|
||||||
|
failed_retryable: 1,
|
||||||
|
running: 2,
|
||||||
|
created: 3,
|
||||||
|
transcribed: 4,
|
||||||
|
songs_detected: 5,
|
||||||
|
split_done: 6,
|
||||||
|
published: 7,
|
||||||
|
collection_synced: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
items = [...items].sort((a, b) => {
|
||||||
|
if (sort === "updated_asc") return String(a.updated_at).localeCompare(String(b.updated_at));
|
||||||
|
if (sort === "title_asc") return String(a.title).localeCompare(String(b.title));
|
||||||
|
if (sort === "status_group") {
|
||||||
|
const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return String(b.updated_at).localeCompare(String(a.updated_at));
|
||||||
|
}
|
||||||
|
return String(b.updated_at).localeCompare(String(a.updated_at));
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
const historyUrl = buildHistoryUrl();
|
||||||
|
const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history] = await Promise.all([
|
||||||
|
fetchJson("/health"),
|
||||||
|
fetchJson("/doctor"),
|
||||||
|
fetchJson("/tasks?limit=100"),
|
||||||
|
fetchJson("/modules"),
|
||||||
|
fetchJson("/settings"),
|
||||||
|
fetchJson("/settings/schema"),
|
||||||
|
fetchJson("/runtime/services"),
|
||||||
|
fetchJson("/logs"),
|
||||||
|
fetchJson(historyUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
currentTasks = tasks.items;
|
||||||
|
currentSettings = settings;
|
||||||
|
currentSettingsSchema = settingsSchema;
|
||||||
|
|
||||||
|
document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || "";
|
||||||
|
document.getElementById("healthValue").textContent = health.ok ? "OK" : "FAIL";
|
||||||
|
document.getElementById("doctorValue").textContent = doctor.ok ? "OK" : "FAIL";
|
||||||
|
document.getElementById("tasksValue").textContent = tasks.items.length;
|
||||||
|
document.getElementById("overviewHealthValue").textContent = health.ok ? "OK" : "FAIL";
|
||||||
|
document.getElementById("overviewDoctorValue").textContent = doctor.ok ? "OK" : "FAIL";
|
||||||
|
document.getElementById("overviewTasksValue").textContent = tasks.items.length;
|
||||||
|
|
||||||
|
renderSettingsForm();
|
||||||
|
syncSettingsEditorFromState();
|
||||||
|
renderTasks();
|
||||||
|
renderModules(modules.items);
|
||||||
|
renderDoctor(doctor.checks);
|
||||||
|
renderServices(services.items);
|
||||||
|
renderLogsList(logs.items);
|
||||||
|
renderRecentActions(history.items);
|
||||||
|
|
||||||
|
if (!selectedTaskId && currentTasks.length) selectedTaskId = currentTasks[0].id;
|
||||||
|
if (selectedTaskId) await loadTaskDetail(selectedTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks() {
|
||||||
|
const wrap = document.getElementById("taskList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const items = filteredTasks();
|
||||||
|
if (!items.length) {
|
||||||
|
wrap.innerHTML = `<div class="row-card"><strong>没有匹配任务</strong><div class="muted-note">调整搜索、状态或排序条件后重试。</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `task-card ${item.id === selectedTaskId ? "active" : ""}`;
|
||||||
|
const retryText = item.retry_state?.next_retry_at ? `next retry ${formatDate(item.retry_state.next_retry_at)}` : "";
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="task-title">${escapeHtml(item.title)}</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||||
|
<span class="pill">${escapeHtml(item.id)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(item.updated_at))}</div>
|
||||||
|
${retryText ? `<div class="muted-note">${escapeHtml(retryText)}</div>` : ""}
|
||||||
|
`;
|
||||||
|
el.onclick = async () => {
|
||||||
|
selectedTaskId = item.id;
|
||||||
|
setView("tasks");
|
||||||
|
renderTasks();
|
||||||
|
await loadTaskDetail(item.id);
|
||||||
|
};
|
||||||
|
wrap.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskHero(task, steps) {
|
||||||
|
const wrap = document.getElementById("taskHero");
|
||||||
|
const succeeded = steps.items.filter((step) => step.status === "succeeded").length;
|
||||||
|
const running = steps.items.filter((step) => step.status === "running").length;
|
||||||
|
const failed = steps.items.filter((step) => step.status.startsWith("failed")).length;
|
||||||
|
wrap.className = "task-hero";
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="task-hero-title">${escapeHtml(task.title)}</div>
|
||||||
|
<div class="task-hero-subtitle">${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}</div>
|
||||||
|
<div class="hero-meta-grid">
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-label">Task Status</div>
|
||||||
|
<div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(task.status)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-label">Succeeded Steps</div>
|
||||||
|
<div class="mini-stat-value">${succeeded}/${steps.items.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-stat">
|
||||||
|
<div class="mini-stat-label">Running / Failed</div>
|
||||||
|
<div class="mini-stat-value">${running} / ${failed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRetryPanel(task) {
|
||||||
|
const wrap = document.getElementById("taskRetryPanel");
|
||||||
|
const retry = task.retry_state;
|
||||||
|
if (!retry || !retry.next_retry_at) {
|
||||||
|
wrap.className = "retry-banner";
|
||||||
|
wrap.style.display = "none";
|
||||||
|
wrap.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.style.display = "block";
|
||||||
|
wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`;
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<strong>${escapeHtml(retry.step_name)}</strong>
|
||||||
|
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
|
||||||
|
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskDetail(taskId) {
|
||||||
|
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||||
|
fetchJson(`/tasks/${taskId}`),
|
||||||
|
fetchJson(`/tasks/${taskId}/steps`),
|
||||||
|
fetchJson(`/tasks/${taskId}/artifacts`),
|
||||||
|
fetchJson(`/tasks/${taskId}/history`),
|
||||||
|
fetchJson(`/tasks/${taskId}/timeline`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderTaskHero(task, steps);
|
||||||
|
renderRetryPanel(task);
|
||||||
|
|
||||||
|
const detail = document.getElementById("taskDetail");
|
||||||
|
detail.innerHTML = "";
|
||||||
|
const pairs = [
|
||||||
|
["Task ID", task.id],
|
||||||
|
["Status", task.status],
|
||||||
|
["Created", formatDate(task.created_at)],
|
||||||
|
["Updated", formatDate(task.updated_at)],
|
||||||
|
["Source", task.source_path],
|
||||||
|
["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"],
|
||||||
|
];
|
||||||
|
for (const [key, value] of pairs) {
|
||||||
|
const k = document.createElement("div");
|
||||||
|
k.className = "detail-key";
|
||||||
|
k.textContent = key;
|
||||||
|
const v = document.createElement("div");
|
||||||
|
v.textContent = value || "-";
|
||||||
|
detail.appendChild(k);
|
||||||
|
detail.appendChild(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaryText = "暂无最近结果";
|
||||||
|
const latestAction = history.items[0];
|
||||||
|
if (latestAction) {
|
||||||
|
summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`;
|
||||||
|
} else {
|
||||||
|
const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"];
|
||||||
|
const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status));
|
||||||
|
const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0];
|
||||||
|
if (summaryStep) {
|
||||||
|
summaryText = summaryStep.error_message
|
||||||
|
? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}`
|
||||||
|
: `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("taskSummary").textContent = summaryText;
|
||||||
|
|
||||||
|
const stepWrap = document.getElementById("stepList");
|
||||||
|
stepWrap.innerHTML = "";
|
||||||
|
for (const step of steps.items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `row-card ${selectedStepName === step.step_name ? "active" : ""}`;
|
||||||
|
row.style.cursor = "pointer";
|
||||||
|
const retryBlock = step.next_retry_at ? `
|
||||||
|
<div class="step-card-metrics">
|
||||||
|
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
|
||||||
|
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
|
||||||
|
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
|
||||||
|
</div>
|
||||||
|
` : "";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(step.step_name)}</strong>
|
||||||
|
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
|
||||||
|
<span class="pill">retry ${step.retry_count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
|
||||||
|
<div class="step-card-metrics">
|
||||||
|
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
|
||||||
|
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
|
||||||
|
</div>
|
||||||
|
${retryBlock}
|
||||||
|
`;
|
||||||
|
row.onclick = () => {
|
||||||
|
selectedStepName = step.step_name;
|
||||||
|
loadTaskDetail(taskId).catch((err) => showBanner(`任务详情刷新失败: ${err}`, "err"));
|
||||||
|
};
|
||||||
|
stepWrap.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactWrap = document.getElementById("artifactList");
|
||||||
|
artifactWrap.innerHTML = "";
|
||||||
|
for (const artifact of artifacts.items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
|
||||||
|
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
|
||||||
|
`;
|
||||||
|
artifactWrap.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyWrap = document.getElementById("historyList");
|
||||||
|
historyWrap.innerHTML = "";
|
||||||
|
for (const item of history.items) {
|
||||||
|
let details = "";
|
||||||
|
try {
|
||||||
|
details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2);
|
||||||
|
} catch {
|
||||||
|
details = item.details_json || "";
|
||||||
|
}
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(item.action_name)}</strong>
|
||||||
|
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.summary)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||||
|
<pre>${escapeHtml(details)}</pre>
|
||||||
|
`;
|
||||||
|
historyWrap.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineWrap = document.getElementById("timelineList");
|
||||||
|
timelineWrap.innerHTML = "";
|
||||||
|
for (const item of timeline.items) {
|
||||||
|
const retryNote = item.retry_state?.next_retry_at
|
||||||
|
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
|
||||||
|
: "";
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "timeline-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="timeline-title">
|
||||||
|
<strong>${escapeHtml(item.title)}</strong>
|
||||||
|
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||||
|
<span class="pill">${escapeHtml(item.kind)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
|
||||||
|
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
|
||||||
|
${retryNote}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
timelineWrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSettingsEditorFromState() {
|
||||||
|
document.getElementById("settingsEditor").value = JSON.stringify(currentSettings, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupOrder(groupName) {
|
||||||
|
return Number(currentSettingsSchema.group_ui?.[groupName]?.order || 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareFieldEntries(a, b) {
|
||||||
|
const orderA = Number(a[1].ui_order || 9999);
|
||||||
|
const orderB = Number(b[1].ui_order || 9999);
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return String(a[0]).localeCompare(String(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSettingsField(groupName, fieldName, fieldSchema) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "settings-field";
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "settings-label";
|
||||||
|
label.textContent = fieldSchema.title || `${groupName}.${fieldName}`;
|
||||||
|
if (fieldSchema.ui_widget) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "settings-badge";
|
||||||
|
badge.textContent = fieldSchema.ui_widget;
|
||||||
|
label.appendChild(badge);
|
||||||
|
}
|
||||||
|
if (fieldSchema.ui_featured === true) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "settings-badge";
|
||||||
|
badge.textContent = "featured";
|
||||||
|
label.appendChild(badge);
|
||||||
|
}
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const value = currentSettings[groupName]?.[fieldName];
|
||||||
|
let input;
|
||||||
|
if (fieldSchema.type === "boolean") {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = "checkbox";
|
||||||
|
input.checked = Boolean(value);
|
||||||
|
} else if (Array.isArray(fieldSchema.enum)) {
|
||||||
|
input = document.createElement("select");
|
||||||
|
for (const optionValue of fieldSchema.enum) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(optionValue);
|
||||||
|
option.textContent = String(optionValue);
|
||||||
|
if (value === optionValue) option.selected = true;
|
||||||
|
input.appendChild(option);
|
||||||
|
}
|
||||||
|
} else if (fieldSchema.type === "array") {
|
||||||
|
input = document.createElement("textarea");
|
||||||
|
input.style.minHeight = "96px";
|
||||||
|
input.value = JSON.stringify(value ?? [], null, 2);
|
||||||
|
} else {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text");
|
||||||
|
input.value = value ?? "";
|
||||||
|
if (fieldSchema.type === "integer") {
|
||||||
|
if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum);
|
||||||
|
input.step = "1";
|
||||||
|
}
|
||||||
|
if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder;
|
||||||
|
}
|
||||||
|
input.dataset.group = groupName;
|
||||||
|
input.dataset.field = fieldName;
|
||||||
|
input.onchange = handleSettingsFieldChange;
|
||||||
|
row.appendChild(input);
|
||||||
|
|
||||||
|
if (fieldSchema.description || fieldSchema.sensitive) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "hint";
|
||||||
|
let text = fieldSchema.description || "";
|
||||||
|
if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`;
|
||||||
|
hint.textContent = text;
|
||||||
|
row.appendChild(hint);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSettingsGroup(groupName, fields, featured) {
|
||||||
|
const entries = Object.entries(fields);
|
||||||
|
if (!entries.length) return null;
|
||||||
|
const group = document.createElement("div");
|
||||||
|
group.className = `settings-group ${featured ? "featured" : ""}`.trim();
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.textContent = currentSettingsSchema.group_ui?.[groupName]?.title || groupName;
|
||||||
|
group.appendChild(title);
|
||||||
|
const descText = currentSettingsSchema.group_ui?.[groupName]?.description;
|
||||||
|
if (descText) {
|
||||||
|
const desc = document.createElement("div");
|
||||||
|
desc.className = "group-desc";
|
||||||
|
desc.textContent = descText;
|
||||||
|
group.appendChild(desc);
|
||||||
|
}
|
||||||
|
const fieldWrap = document.createElement("div");
|
||||||
|
fieldWrap.className = "settings-fields";
|
||||||
|
for (const [fieldName, fieldSchema] of entries) {
|
||||||
|
fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema));
|
||||||
|
}
|
||||||
|
group.appendChild(fieldWrap);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSettingsForm() {
|
||||||
|
const wrap = document.getElementById("settingsForm");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
if (!currentSettingsSchema?.groups) return;
|
||||||
|
const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const featuredContainer = document.createElement("div");
|
||||||
|
featuredContainer.className = "settings-groups";
|
||||||
|
const advancedDetails = document.createElement("details");
|
||||||
|
advancedDetails.className = "settings-advanced";
|
||||||
|
const advancedSummary = document.createElement("summary");
|
||||||
|
advancedSummary.textContent = "Advanced Settings";
|
||||||
|
advancedDetails.appendChild(advancedSummary);
|
||||||
|
const advancedContainer = document.createElement("div");
|
||||||
|
advancedContainer.className = "settings-groups";
|
||||||
|
|
||||||
|
const groupEntries = Object.entries(currentSettingsSchema.groups).sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0]));
|
||||||
|
for (const [groupName, fields] of groupEntries) {
|
||||||
|
const featuredFields = {};
|
||||||
|
const advancedFields = {};
|
||||||
|
const fieldEntries = Object.entries(fields).sort((a, b) => compareFieldEntries(a, b));
|
||||||
|
for (const [fieldName, fieldSchema] of fieldEntries) {
|
||||||
|
const key = `${groupName}.${fieldName}`.toLowerCase();
|
||||||
|
if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) continue;
|
||||||
|
if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema;
|
||||||
|
else advancedFields[fieldName] = fieldSchema;
|
||||||
|
}
|
||||||
|
const featuredGroup = createSettingsGroup(groupName, featuredFields, true);
|
||||||
|
if (featuredGroup) featuredContainer.appendChild(featuredGroup);
|
||||||
|
const advancedGroup = createSettingsGroup(groupName, advancedFields, false);
|
||||||
|
if (advancedGroup) advancedContainer.appendChild(advancedGroup);
|
||||||
|
}
|
||||||
|
if (!featuredContainer.children.length && !advancedContainer.children.length) {
|
||||||
|
wrap.innerHTML = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (featuredContainer.children.length) wrap.appendChild(featuredContainer);
|
||||||
|
if (advancedContainer.children.length) {
|
||||||
|
advancedDetails.appendChild(advancedContainer);
|
||||||
|
wrap.appendChild(advancedDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSettingsFieldChange(event) {
|
||||||
|
const input = event.target;
|
||||||
|
const group = input.dataset.group;
|
||||||
|
const field = input.dataset.field;
|
||||||
|
const fieldSchema = currentSettingsSchema.groups[group][field];
|
||||||
|
let value;
|
||||||
|
if (fieldSchema.type === "boolean") value = input.checked;
|
||||||
|
else if (fieldSchema.type === "integer") value = Number(input.value);
|
||||||
|
else if (fieldSchema.type === "array") {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(input.value || "[]");
|
||||||
|
if (!Array.isArray(value)) throw new Error("not array");
|
||||||
|
} catch {
|
||||||
|
showBanner(`${group}.${field} 必须是 JSON 数组`, "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else value = input.value;
|
||||||
|
if (!currentSettings[group]) currentSettings[group] = {};
|
||||||
|
currentSettings[group][field] = value;
|
||||||
|
syncSettingsEditorFromState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentActions(items) {
|
||||||
|
const wrap = document.getElementById("recentActionList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(item.action_name)}</strong>
|
||||||
|
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
|
||||||
|
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModules(items) {
|
||||||
|
const wrap = document.getElementById("moduleList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDoctor(checks) {
|
||||||
|
const wrap = document.getElementById("doctorChecks");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const check of checks) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
|
||||||
|
<div class="muted-note">${escapeHtml(check.detail)}</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServices(items) {
|
||||||
|
const wrap = document.getElementById("serviceList");
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "service-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="step-card-title">
|
||||||
|
<strong>${escapeHtml(item.id)}</strong>
|
||||||
|
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
|
||||||
|
<span class="pill">${escapeHtml(item.sub_state)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
|
||||||
|
<div class="button-row" style="margin-top:12px;">
|
||||||
|
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
|
||||||
|
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
|
||||||
|
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
}
|
||||||
|
wrap.querySelectorAll("button[data-service]").forEach((btn) => {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
if (["stop", "restart"].includes(btn.dataset.action)) {
|
||||||
|
const ok = window.confirm(`确认执行 ${btn.dataset.action} ${btn.dataset.service} ?`);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(`/runtime/services/${btn.dataset.service}/${btn.dataset.action}`, { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`${payload.id} ${payload.action} 完成`, payload.command_ok ? "ok" : "warn");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`service 操作失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogsList(items) {
|
||||||
|
const select = document.getElementById("logSelect");
|
||||||
|
if (!select.options.length) {
|
||||||
|
for (const item of items) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = item.name;
|
||||||
|
option.textContent = item.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLog() {
|
||||||
|
const name = document.getElementById("logSelect").value;
|
||||||
|
if (!name) return;
|
||||||
|
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
|
||||||
|
if (document.getElementById("filterCurrentTask").checked && selectedTaskId) {
|
||||||
|
const currentTask = currentTasks.find((item) => item.id === selectedTaskId);
|
||||||
|
if (currentTask?.title) url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||||||
|
}
|
||||||
|
const payload = await fetchJson(url);
|
||||||
|
document.getElementById("logPath").textContent = payload.path;
|
||||||
|
document.getElementById("logContent").textContent = payload.content || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||||
|
button.onclick = () => setView(button.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("refreshBtn").onclick = async () => {
|
||||||
|
await loadOverview();
|
||||||
|
showBanner("视图已刷新", "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("runOnceBtn").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchJson("/worker/run-once", { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(String(err), "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("saveSettingsBtn").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(document.getElementById("settingsEditor").value);
|
||||||
|
await fetchJson("/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
await loadOverview();
|
||||||
|
showBanner("Settings 已保存", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`保存失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("syncFormToJsonBtn").onclick = () => {
|
||||||
|
syncSettingsEditorFromState();
|
||||||
|
showBanner("表单已同步到 JSON", "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("syncJsonToFormBtn").onclick = () => {
|
||||||
|
try {
|
||||||
|
currentSettings = JSON.parse(document.getElementById("settingsEditor").value);
|
||||||
|
renderSettingsForm();
|
||||||
|
syncSettingsEditorFromState();
|
||||||
|
showBanner("JSON 已重绘到表单", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`JSON 解析失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settingsSearch").oninput = () => renderSettingsForm();
|
||||||
|
document.getElementById("taskSearchInput").oninput = () => renderTasks();
|
||||||
|
document.getElementById("taskStatusFilter").onchange = () => renderTasks();
|
||||||
|
document.getElementById("taskSortSelect").onchange = () => renderTasks();
|
||||||
|
|
||||||
|
document.getElementById("importStageBtn").onclick = async () => {
|
||||||
|
const sourcePath = document.getElementById("stageSourcePath").value.trim();
|
||||||
|
if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn");
|
||||||
|
try {
|
||||||
|
const result = await fetchJson("/stage/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source_path: sourcePath }),
|
||||||
|
});
|
||||||
|
document.getElementById("stageSourcePath").value = "";
|
||||||
|
showBanner(`已导入到 stage: ${result.target_path}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`导入失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("uploadStageBtn").onclick = async () => {
|
||||||
|
const input = document.getElementById("stageFileInput");
|
||||||
|
if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", input.files[0]);
|
||||||
|
const res = await fetch("/stage/upload", { method: "POST", body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || JSON.stringify(data));
|
||||||
|
input.value = "";
|
||||||
|
showBanner(`已上传到 stage: ${data.target_path}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`上传失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||||
|
document.getElementById("logSelect").onchange = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||||
|
document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err"));
|
||||||
|
|
||||||
|
document.getElementById("saveTokenBtn").onclick = async () => {
|
||||||
|
const token = document.getElementById("tokenInput").value.trim();
|
||||||
|
localStorage.setItem("biliup_next_token", token);
|
||||||
|
try {
|
||||||
|
await loadOverview();
|
||||||
|
showBanner("Token 已保存并生效", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`Token 验证失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("runTaskBtn").onclick = async () => {
|
||||||
|
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/run`, { method: "POST" });
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`任务已推进,processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`任务执行失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("retryStepBtn").onclick = async () => {
|
||||||
|
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||||
|
if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/retry-step`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step_name: selectedStepName }),
|
||||||
|
});
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`已重试 step=${selectedStepName},processed=${result.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`重试失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("resetStepBtn").onclick = async () => {
|
||||||
|
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||||
|
if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||||
|
const ok = window.confirm(`确认重置到 step=${selectedStepName} 并清理其后的产物吗?`);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/reset-to-step`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step_name: selectedStepName }),
|
||||||
|
});
|
||||||
|
await loadOverview();
|
||||||
|
showBanner(`已重置并重跑 step=${selectedStepName},processed=${result.run.processed.length}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(`重置失败: ${err}`, "err");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setView("overview");
|
||||||
|
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));
|
||||||
29
src/biliup_next/app/task_actions.py
Normal file
29
src/biliup_next/app/task_actions.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from biliup_next.app.bootstrap import ensure_initialized
|
||||||
|
from biliup_next.app.task_audit import record_task_action
|
||||||
|
from biliup_next.app.task_runner import process_task
|
||||||
|
from biliup_next.infra.task_reset import TaskResetService
|
||||||
|
|
||||||
|
|
||||||
|
def run_task_action(task_id: str) -> dict[str, object]:
|
||||||
|
result = process_task(task_id)
|
||||||
|
state = ensure_initialized()
|
||||||
|
record_task_action(state["repo"], task_id, "task_run", "ok", "task run invoked", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def retry_step_action(task_id: str, step_name: str) -> dict[str, object]:
|
||||||
|
result = process_task(task_id, reset_step=step_name)
|
||||||
|
state = ensure_initialized()
|
||||||
|
record_task_action(state["repo"], task_id, "retry_step", "ok", f"retry step invoked: {step_name}", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def reset_to_step_action(task_id: str, step_name: str) -> dict[str, object]:
|
||||||
|
state = ensure_initialized()
|
||||||
|
reset_result = TaskResetService(state["repo"]).reset_to_step(task_id, step_name)
|
||||||
|
process_result = process_task(task_id)
|
||||||
|
payload = {"reset": reset_result, "run": process_result}
|
||||||
|
record_task_action(state["repo"], task_id, "reset_to_step", "ok", f"reset to step invoked: {step_name}", payload)
|
||||||
|
return payload
|
||||||
19
src/biliup_next/app/task_audit.py
Normal file
19
src/biliup_next/app/task_audit.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from biliup_next.core.models import ActionRecord, utc_now_iso
|
||||||
|
|
||||||
|
|
||||||
|
def record_task_action(repo, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None: # type: ignore[no-untyped-def]
|
||||||
|
repo.add_action_record(
|
||||||
|
ActionRecord(
|
||||||
|
id=None,
|
||||||
|
task_id=task_id,
|
||||||
|
action_name=action_name,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
details_json=json.dumps(details, ensure_ascii=False),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
)
|
||||||
129
src/biliup_next/app/task_engine.py
Normal file
129
src/biliup_next/app/task_engine.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from biliup_next.app.retry_meta import retry_meta_for_step
|
||||||
|
|
||||||
|
|
||||||
|
def settings_for(state: dict[str, object], group: str) -> dict[str, object]:
|
||||||
|
settings = dict(state["settings"][group])
|
||||||
|
settings.update(state["settings"]["paths"])
|
||||||
|
if group == "comment" and "collection" in state["settings"]:
|
||||||
|
collection_settings = state["settings"]["collection"]
|
||||||
|
if "allow_fuzzy_full_video_match" in collection_settings:
|
||||||
|
settings.setdefault("allow_fuzzy_full_video_match", collection_settings["allow_fuzzy_full_video_match"])
|
||||||
|
if group == "collection" and "cleanup" in state["settings"]:
|
||||||
|
settings.update(state["settings"]["cleanup"])
|
||||||
|
if "publish" in state["settings"]:
|
||||||
|
publish_settings = state["settings"]["publish"]
|
||||||
|
if "biliup_path" in publish_settings:
|
||||||
|
settings.setdefault("biliup_path", publish_settings["biliup_path"])
|
||||||
|
if "cookie_file" in publish_settings:
|
||||||
|
settings.setdefault("cookie_file", publish_settings["cookie_file"])
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def infer_error_step_name(task, steps: dict[str, object]) -> str: # type: ignore[no-untyped-def]
|
||||||
|
if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}:
|
||||||
|
return "transcribe"
|
||||||
|
if task.status == "transcribed":
|
||||||
|
return "song_detect"
|
||||||
|
if task.status == "songs_detected":
|
||||||
|
return "split"
|
||||||
|
if task.status in {"published", "collection_synced"}:
|
||||||
|
if steps.get("comment") and steps["comment"].status in {"running", "pending", "failed_retryable"}:
|
||||||
|
return "comment"
|
||||||
|
if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}:
|
||||||
|
return "collection_a"
|
||||||
|
return "collection_b"
|
||||||
|
if task.status == "commented":
|
||||||
|
if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}:
|
||||||
|
return "collection_a"
|
||||||
|
return "collection_b"
|
||||||
|
return "publish"
|
||||||
|
|
||||||
|
|
||||||
|
def retry_wait_payload(task_id: str, step, state: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def]
|
||||||
|
if step.status != "failed_retryable":
|
||||||
|
return None
|
||||||
|
meta = retry_meta_for_step(step, {"publish": settings_for(state, "publish")})
|
||||||
|
if meta is None or meta["retry_due"]:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"step": step.step_name,
|
||||||
|
"waiting_for_retry": True,
|
||||||
|
"remaining_seconds": meta["retry_remaining_seconds"],
|
||||||
|
"retry_count": step.retry_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def next_runnable_step(task, steps: dict[str, object], state: dict[str, object]) -> tuple[str | None, dict[str, object] | None]: # type: ignore[no-untyped-def]
|
||||||
|
if task.status == "failed_retryable":
|
||||||
|
failed = next((step for step in steps.values() if step.status == "failed_retryable"), None)
|
||||||
|
if failed is None:
|
||||||
|
return None, None
|
||||||
|
wait_payload = retry_wait_payload(task.id, failed, state)
|
||||||
|
if wait_payload is not None:
|
||||||
|
return None, wait_payload
|
||||||
|
return failed.step_name, None
|
||||||
|
|
||||||
|
if task.status == "created":
|
||||||
|
step = steps.get("transcribe")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "transcribe", None
|
||||||
|
if task.status == "transcribed":
|
||||||
|
step = steps.get("song_detect")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "song_detect", None
|
||||||
|
if task.status == "songs_detected":
|
||||||
|
step = steps.get("split")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "split", None
|
||||||
|
if task.status == "split_done":
|
||||||
|
step = steps.get("publish")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "publish", None
|
||||||
|
if task.status in {"published", "collection_synced"}:
|
||||||
|
if state["settings"]["comment"].get("enabled", True):
|
||||||
|
step = steps.get("comment")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "comment", None
|
||||||
|
if state["settings"]["collection"].get("enabled", True):
|
||||||
|
step = steps.get("collection_a")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "collection_a", None
|
||||||
|
step = steps.get("collection_b")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "collection_b", None
|
||||||
|
if task.status == "commented" and state["settings"]["collection"].get("enabled", True):
|
||||||
|
step = steps.get("collection_a")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "collection_a", None
|
||||||
|
step = steps.get("collection_b")
|
||||||
|
if step and step.status in {"pending", "failed_retryable"}:
|
||||||
|
return "collection_b", None
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def execute_step(state: dict[str, object], task_id: str, step_name: str) -> dict[str, object]:
|
||||||
|
if step_name == "transcribe":
|
||||||
|
artifact = state["transcribe_service"].run(task_id, settings_for(state, "transcribe"))
|
||||||
|
return {"task_id": task_id, "step": "transcribe", "artifact": artifact.path}
|
||||||
|
if step_name == "song_detect":
|
||||||
|
songs_json, songs_txt = state["song_detect_service"].run(task_id, settings_for(state, "song_detect"))
|
||||||
|
return {"task_id": task_id, "step": "song_detect", "songs_json": songs_json.path, "songs_txt": songs_txt.path}
|
||||||
|
if step_name == "split":
|
||||||
|
clips = state["split_service"].run(task_id, settings_for(state, "split"))
|
||||||
|
return {"task_id": task_id, "step": "split", "clip_count": len(clips)}
|
||||||
|
if step_name == "publish":
|
||||||
|
publish_record = state["publish_service"].run(task_id, settings_for(state, "publish"))
|
||||||
|
return {"task_id": task_id, "step": "publish", "bvid": publish_record.bvid}
|
||||||
|
if step_name == "comment":
|
||||||
|
comment_result = state["comment_service"].run(task_id, settings_for(state, "comment"))
|
||||||
|
return {"task_id": task_id, "step": "comment", "result": comment_result}
|
||||||
|
if step_name == "collection_a":
|
||||||
|
collection_result = state["collection_service"].run(task_id, "a", settings_for(state, "collection"))
|
||||||
|
return {"task_id": task_id, "step": "collection_a", "result": collection_result}
|
||||||
|
if step_name == "collection_b":
|
||||||
|
collection_result = state["collection_service"].run(task_id, "b", settings_for(state, "collection"))
|
||||||
|
return {"task_id": task_id, "step": "collection_b", "result": collection_result}
|
||||||
|
raise RuntimeError(f"unsupported step: {step_name}")
|
||||||
67
src/biliup_next/app/task_policies.py
Normal file
67
src/biliup_next/app/task_policies.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from biliup_next.app.retry_meta import publish_retry_schedule_seconds
|
||||||
|
from biliup_next.app.task_engine import infer_error_step_name, settings_for as task_engine_settings_for
|
||||||
|
from biliup_next.core.models import utc_now_iso
|
||||||
|
|
||||||
|
|
||||||
|
def settings_for(state: dict[str, object], group: str) -> dict[str, object]:
|
||||||
|
return task_engine_settings_for(state, group)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_disabled_step_fallbacks(state: dict[str, object], task, repo) -> bool: # type: ignore[no-untyped-def]
|
||||||
|
if task.status in {"published", "collection_synced"} and not state["settings"]["comment"].get("enabled", True):
|
||||||
|
repo.update_step_status(task.id, "comment", "succeeded", finished_at=utc_now_iso())
|
||||||
|
return True
|
||||||
|
if task.status in {"published", "commented", "collection_synced"} and not state["settings"]["collection"].get("enabled", True):
|
||||||
|
now = utc_now_iso()
|
||||||
|
repo.update_step_status(task.id, "collection_a", "succeeded", finished_at=now)
|
||||||
|
repo.update_step_status(task.id, "collection_b", "succeeded", finished_at=now)
|
||||||
|
repo.update_task_status(task.id, "collection_synced", now)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_failure(task, repo, state: dict[str, object], exc) -> dict[str, object]: # type: ignore[no-untyped-def]
|
||||||
|
current_task = repo.get_task(task.id) or task
|
||||||
|
current_steps = {step.step_name: step for step in repo.list_steps(task.id)}
|
||||||
|
step_name = infer_error_step_name(current_task, current_steps)
|
||||||
|
current_retry = 0
|
||||||
|
for step in repo.list_steps(task.id):
|
||||||
|
if step.step_name == step_name:
|
||||||
|
current_retry = step.retry_count
|
||||||
|
break
|
||||||
|
next_retry_count = current_retry + 1
|
||||||
|
next_status = "failed_retryable" if exc.retryable else "failed_manual"
|
||||||
|
next_retry_delay_seconds: int | None = None
|
||||||
|
if exc.retryable and step_name == "publish":
|
||||||
|
schedule = publish_retry_schedule_seconds(settings_for(state, "publish"))
|
||||||
|
if next_retry_count > len(schedule):
|
||||||
|
next_status = "failed_manual"
|
||||||
|
else:
|
||||||
|
next_retry_delay_seconds = schedule[next_retry_count - 1]
|
||||||
|
failed_at = utc_now_iso()
|
||||||
|
repo.update_step_status(
|
||||||
|
task.id,
|
||||||
|
step_name,
|
||||||
|
next_status,
|
||||||
|
error_code=exc.code,
|
||||||
|
error_message=exc.message,
|
||||||
|
retry_count=next_retry_count,
|
||||||
|
finished_at=failed_at,
|
||||||
|
)
|
||||||
|
repo.update_task_status(task.id, next_status, failed_at)
|
||||||
|
payload = {
|
||||||
|
"task_id": task.id,
|
||||||
|
"step": step_name,
|
||||||
|
"error": exc.to_dict(),
|
||||||
|
"retry_count": next_retry_count,
|
||||||
|
"retry_status": next_status,
|
||||||
|
}
|
||||||
|
if next_retry_delay_seconds is not None:
|
||||||
|
payload["next_retry_delay_seconds"] = next_retry_delay_seconds
|
||||||
|
return {
|
||||||
|
"step_name": step_name,
|
||||||
|
"payload": payload,
|
||||||
|
"summary": exc.message,
|
||||||
|
}
|
||||||
74
src/biliup_next/app/task_runner.py
Normal file
74
src/biliup_next/app/task_runner.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from biliup_next.app.bootstrap import ensure_initialized
|
||||||
|
from biliup_next.app.task_audit import record_task_action
|
||||||
|
from biliup_next.app.task_engine import (
|
||||||
|
execute_step,
|
||||||
|
next_runnable_step,
|
||||||
|
)
|
||||||
|
from biliup_next.app.task_policies import apply_disabled_step_fallbacks
|
||||||
|
from biliup_next.app.task_policies import resolve_failure
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import utc_now_iso
|
||||||
|
|
||||||
|
|
||||||
|
def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]:
|
||||||
|
state = ensure_initialized()
|
||||||
|
repo = state["repo"]
|
||||||
|
task = repo.get_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
return {"processed": [], "error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}}
|
||||||
|
|
||||||
|
processed: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
if include_stage_scan:
|
||||||
|
ingest_settings = dict(state["settings"]["ingest"])
|
||||||
|
ingest_settings.update(state["settings"]["paths"])
|
||||||
|
stage_scan = state["ingest_service"].scan_stage(ingest_settings)
|
||||||
|
processed.append({"stage_scan": stage_scan})
|
||||||
|
record_task_action(repo, task_id, "stage_scan", "ok", "stage scan completed", stage_scan)
|
||||||
|
|
||||||
|
if reset_step:
|
||||||
|
step_names = {step.step_name for step in repo.list_steps(task_id)}
|
||||||
|
if reset_step not in step_names:
|
||||||
|
return {"processed": processed, "error": {"code": "STEP_NOT_FOUND", "message": f"step not found: {reset_step}"}}
|
||||||
|
repo.update_step_status(
|
||||||
|
task_id,
|
||||||
|
reset_step,
|
||||||
|
"pending",
|
||||||
|
error_code=None,
|
||||||
|
error_message=None,
|
||||||
|
started_at=None,
|
||||||
|
finished_at=None,
|
||||||
|
)
|
||||||
|
repo.update_task_status(task_id, task.status, utc_now_iso())
|
||||||
|
processed.append({"task_id": task_id, "step": reset_step, "reset": True})
|
||||||
|
record_task_action(repo, task_id, "retry_step", "ok", f"step reset to pending: {reset_step}", {"step_name": reset_step})
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
current_task = repo.get_task(task.id) or task
|
||||||
|
current_steps = {step.step_name: step for step in repo.list_steps(task.id)}
|
||||||
|
|
||||||
|
if apply_disabled_step_fallbacks(state, current_task, repo):
|
||||||
|
continue
|
||||||
|
|
||||||
|
step_name, waiting_payload = next_runnable_step(current_task, current_steps, state)
|
||||||
|
if waiting_payload is not None:
|
||||||
|
processed.append(waiting_payload)
|
||||||
|
return {"processed": processed}
|
||||||
|
if step_name is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
payload = execute_step(state, task.id, step_name)
|
||||||
|
if current_task.status == "failed_retryable":
|
||||||
|
payload["retry"] = True
|
||||||
|
record_task_action(repo, task_id, step_name, "ok", f"{step_name} retry succeeded", payload)
|
||||||
|
else:
|
||||||
|
record_task_action(repo, task_id, step_name, "ok", f"{step_name} succeeded", payload)
|
||||||
|
processed.append(payload)
|
||||||
|
except ModuleError as exc:
|
||||||
|
failure = resolve_failure(task, repo, state, exc)
|
||||||
|
processed.append(failure["payload"])
|
||||||
|
record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"])
|
||||||
|
return {"processed": processed}
|
||||||
30
src/biliup_next/app/worker.py
Normal file
30
src/biliup_next/app/worker.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from biliup_next.app.scheduler import (
|
||||||
|
run_scheduler_cycle,
|
||||||
|
serialize_scheduled_task,
|
||||||
|
)
|
||||||
|
from biliup_next.app.task_runner import process_task
|
||||||
|
|
||||||
|
|
||||||
|
def run_once() -> dict[str, object]:
|
||||||
|
processed: list[dict[str, object]] = []
|
||||||
|
cycle = run_scheduler_cycle(include_stage_scan=True, limit=200)
|
||||||
|
processed.append({"stage_scan": cycle.preview["stage_scan"]})
|
||||||
|
processed.extend(cycle.deferred)
|
||||||
|
for scheduled_task in cycle.scheduled:
|
||||||
|
processed.extend(process_task(scheduled_task.task_id)["processed"])
|
||||||
|
return {
|
||||||
|
"processed": processed,
|
||||||
|
"scheduler": {
|
||||||
|
**cycle.preview,
|
||||||
|
"scheduled": [serialize_scheduled_task(scheduled_task) for scheduled_task in cycle.scheduled],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_forever(interval_seconds: int = 5) -> None:
|
||||||
|
while True:
|
||||||
|
run_once()
|
||||||
|
time.sleep(interval_seconds)
|
||||||
230
src/biliup_next/core/config.py
Normal file
230
src/biliup_next/core/config.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SettingsBundle:
|
||||||
|
schema: dict[str, Any]
|
||||||
|
settings: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsService:
|
||||||
|
SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__"
|
||||||
|
|
||||||
|
def __init__(self, root_dir: Path):
|
||||||
|
self.root_dir = root_dir
|
||||||
|
self.config_dir = self.root_dir / "config"
|
||||||
|
self.schema_path = self.config_dir / "settings.schema.json"
|
||||||
|
self.settings_path = self.config_dir / "settings.json"
|
||||||
|
self.staged_path = self.config_dir / "settings.staged.json"
|
||||||
|
|
||||||
|
def load(self) -> SettingsBundle:
|
||||||
|
schema = self._read_json(self.schema_path)
|
||||||
|
settings = self._read_json(self.settings_path)
|
||||||
|
settings = self._apply_schema_defaults(settings, schema)
|
||||||
|
settings = self._apply_legacy_env_overrides(settings, schema)
|
||||||
|
settings = self._normalize_paths(settings)
|
||||||
|
self.validate(settings, schema)
|
||||||
|
return SettingsBundle(schema=schema, settings=settings)
|
||||||
|
|
||||||
|
def validate(self, settings: dict[str, Any], schema: dict[str, Any]) -> None:
|
||||||
|
groups = schema.get("groups", {})
|
||||||
|
for group_name, fields in groups.items():
|
||||||
|
if group_name not in settings:
|
||||||
|
raise ConfigError(f"缺少配置分组: {group_name}")
|
||||||
|
group_value = settings[group_name]
|
||||||
|
if not isinstance(group_value, dict):
|
||||||
|
raise ConfigError(f"配置分组类型错误: {group_name}")
|
||||||
|
for field_name, field_schema in fields.items():
|
||||||
|
if field_name not in group_value:
|
||||||
|
raise ConfigError(f"缺少配置项: {group_name}.{field_name}")
|
||||||
|
self._validate_field(group_name, field_name, group_value[field_name], field_schema)
|
||||||
|
|
||||||
|
def save_staged(self, settings: dict[str, Any]) -> None:
|
||||||
|
schema = self._read_json(self.schema_path)
|
||||||
|
settings = self._apply_schema_defaults(settings, schema)
|
||||||
|
self.validate(settings, schema)
|
||||||
|
self._write_json(self.staged_path, settings)
|
||||||
|
|
||||||
|
def load_redacted(self) -> SettingsBundle:
|
||||||
|
bundle = self.load()
|
||||||
|
return SettingsBundle(schema=bundle.schema, settings=self._redact_sensitive(bundle.settings, bundle.schema))
|
||||||
|
|
||||||
|
def save_staged_from_redacted(self, settings: dict[str, Any]) -> None:
|
||||||
|
bundle = self.load()
|
||||||
|
schema = bundle.schema
|
||||||
|
current = bundle.settings
|
||||||
|
merged = self._restore_sensitive_values(current, settings, schema)
|
||||||
|
merged = self._apply_schema_defaults(merged, schema)
|
||||||
|
self.validate(merged, schema)
|
||||||
|
self._write_json(self.staged_path, merged)
|
||||||
|
|
||||||
|
def promote_staged(self) -> None:
|
||||||
|
staged = self._read_json(self.staged_path)
|
||||||
|
schema = self._read_json(self.schema_path)
|
||||||
|
staged = self._apply_schema_defaults(staged, schema)
|
||||||
|
self.validate(staged, schema)
|
||||||
|
self._write_json(self.settings_path, staged)
|
||||||
|
|
||||||
|
def _validate_field(self, group: str, name: str, value: Any, field_schema: dict[str, Any]) -> None:
|
||||||
|
expected = field_schema.get("type")
|
||||||
|
if expected == "string" and not isinstance(value, str):
|
||||||
|
raise ConfigError(f"{group}.{name} 必须是字符串")
|
||||||
|
if expected == "integer":
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
|
raise ConfigError(f"{group}.{name} 必须是整数")
|
||||||
|
minimum = field_schema.get("minimum")
|
||||||
|
if minimum is not None and value < minimum:
|
||||||
|
raise ConfigError(f"{group}.{name} 不能小于 {minimum}")
|
||||||
|
if expected == "boolean" and not isinstance(value, bool):
|
||||||
|
raise ConfigError(f"{group}.{name} 必须是布尔值")
|
||||||
|
if expected == "array":
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ConfigError(f"{group}.{name} 必须是数组")
|
||||||
|
item_schema = field_schema.get("items")
|
||||||
|
if isinstance(item_schema, dict):
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
self._validate_field(group, f"{name}[{index}]", item, item_schema)
|
||||||
|
enum = field_schema.get("enum")
|
||||||
|
if enum is not None and value not in enum:
|
||||||
|
raise ConfigError(f"{group}.{name} 必须属于 {enum}")
|
||||||
|
|
||||||
|
def _apply_schema_defaults(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
merged = json.loads(json.dumps(settings))
|
||||||
|
groups = schema.get("groups", {})
|
||||||
|
for group_name, fields in groups.items():
|
||||||
|
group_value = merged.setdefault(group_name, {})
|
||||||
|
if not isinstance(group_value, dict):
|
||||||
|
raise ConfigError(f"配置分组类型错误: {group_name}")
|
||||||
|
for field_name, field_schema in fields.items():
|
||||||
|
if field_name in group_value:
|
||||||
|
continue
|
||||||
|
if "default" not in field_schema:
|
||||||
|
continue
|
||||||
|
group_value[field_name] = self._clone_default(field_schema["default"])
|
||||||
|
return merged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clone_default(value: Any) -> Any:
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
raise ConfigError(f"配置文件不存在: {path}")
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
def _apply_legacy_env_overrides(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
env_path = self.root_dir.parent / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return settings
|
||||||
|
env_map: dict[str, str] = {}
|
||||||
|
with env_path.open("r", encoding="utf-8") as f:
|
||||||
|
for raw_line in f:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env_map[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
("transcribe", "groq_api_key"): env_map.get("GROQ_API_KEY"),
|
||||||
|
("song_detect", "codex_cmd"): self._resolve_legacy_path(env_map.get("CODEX_CMD")),
|
||||||
|
("transcribe", "ffmpeg_bin"): self._resolve_legacy_path(env_map.get("FFMPEG_BIN")),
|
||||||
|
("split", "ffmpeg_bin"): self._resolve_legacy_path(env_map.get("FFMPEG_BIN")),
|
||||||
|
("ingest", "ffprobe_bin"): self._resolve_legacy_path(env_map.get("FFPROBE_BIN")),
|
||||||
|
("publish", "biliup_path"): self._resolve_legacy_path(env_map.get("BILIUP_PATH")),
|
||||||
|
("publish", "cookie_file"): self._resolve_legacy_path(env_map.get("BILIUP_COOKIE_FILE")),
|
||||||
|
("paths", "cookies_file"): self._resolve_legacy_path(env_map.get("BILIUP_COOKIE_FILE")),
|
||||||
|
}
|
||||||
|
merged = json.loads(json.dumps(settings))
|
||||||
|
defaults = schema.get("groups", {})
|
||||||
|
for (group, field), value in overrides.items():
|
||||||
|
default_value = defaults.get(group, {}).get(field, {}).get("default")
|
||||||
|
current_value = merged.get(group, {}).get(field)
|
||||||
|
if value and (current_value in ("", None) or current_value == default_value):
|
||||||
|
merged[group][field] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _resolve_legacy_path(self, value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
if value.startswith("./") or value.startswith("../"):
|
||||||
|
return str((self.root_dir.parent / value).resolve())
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _normalize_paths(self, settings: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = json.loads(json.dumps(settings))
|
||||||
|
for group, field in (
|
||||||
|
("runtime", "database_path"),
|
||||||
|
("paths", "stage_dir"),
|
||||||
|
("paths", "backup_dir"),
|
||||||
|
("paths", "session_dir"),
|
||||||
|
("paths", "cookies_file"),
|
||||||
|
("paths", "upload_config_file"),
|
||||||
|
("ingest", "ffprobe_bin"),
|
||||||
|
("transcribe", "ffmpeg_bin"),
|
||||||
|
("split", "ffmpeg_bin"),
|
||||||
|
("song_detect", "codex_cmd"),
|
||||||
|
("publish", "biliup_path"),
|
||||||
|
("publish", "cookie_file"),
|
||||||
|
):
|
||||||
|
value = normalized[group][field]
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized[group][field] = self._resolve_project_path(value)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _resolve_project_path(self, value: str) -> str:
|
||||||
|
path = Path(value)
|
||||||
|
if path.is_absolute():
|
||||||
|
return str(path)
|
||||||
|
if "/" not in value and "\\" not in value:
|
||||||
|
return value
|
||||||
|
return str((self.root_dir / path).resolve())
|
||||||
|
|
||||||
|
def _redact_sensitive(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
redacted = json.loads(json.dumps(settings))
|
||||||
|
for group_name, field_name in self._iter_sensitive_fields(schema):
|
||||||
|
value = redacted.get(group_name, {}).get(field_name)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
redacted[group_name][field_name] = self.SECRET_PLACEHOLDER
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
def _restore_sensitive_values(
|
||||||
|
self,
|
||||||
|
current: dict[str, Any],
|
||||||
|
submitted: dict[str, Any],
|
||||||
|
schema: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
merged = json.loads(json.dumps(submitted))
|
||||||
|
for group_name, field_name in self._iter_sensitive_fields(schema):
|
||||||
|
submitted_value = merged.get(group_name, {}).get(field_name)
|
||||||
|
current_value = current.get(group_name, {}).get(field_name, "")
|
||||||
|
if submitted_value == self.SECRET_PLACEHOLDER and isinstance(current_value, str):
|
||||||
|
merged[group_name][field_name] = current_value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_sensitive_fields(schema: dict[str, Any]) -> list[tuple[str, str]]:
|
||||||
|
items: list[tuple[str, str]] = []
|
||||||
|
for group_name, fields in schema.get("groups", {}).items():
|
||||||
|
for field_name, field_schema in fields.items():
|
||||||
|
if field_schema.get("sensitive") is True:
|
||||||
|
items.append((group_name, field_name))
|
||||||
|
return items
|
||||||
15
src/biliup_next/core/errors.py
Normal file
15
src/biliup_next/core/errors.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ModuleError(Exception):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
retryable: bool
|
||||||
|
details: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
80
src/biliup_next/core/models.py
Normal file
80
src/biliup_next/core/models.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Task:
|
||||||
|
id: str
|
||||||
|
source_type: str
|
||||||
|
source_path: str
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TaskStep:
|
||||||
|
id: int | None
|
||||||
|
task_id: str
|
||||||
|
step_name: str
|
||||||
|
status: str
|
||||||
|
error_code: str | None
|
||||||
|
error_message: str | None
|
||||||
|
retry_count: int
|
||||||
|
started_at: str | None
|
||||||
|
finished_at: str | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Artifact:
|
||||||
|
id: int | None
|
||||||
|
task_id: str
|
||||||
|
artifact_type: str
|
||||||
|
path: str
|
||||||
|
metadata_json: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PublishRecord:
|
||||||
|
id: int | None
|
||||||
|
task_id: str
|
||||||
|
platform: str
|
||||||
|
aid: str | None
|
||||||
|
bvid: str | None
|
||||||
|
title: str
|
||||||
|
published_at: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ActionRecord:
|
||||||
|
id: int | None
|
||||||
|
task_id: str | None
|
||||||
|
action_name: str
|
||||||
|
status: str
|
||||||
|
summary: str
|
||||||
|
details_json: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
54
src/biliup_next/core/providers.py
Normal file
54
src/biliup_next/core/providers.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from biliup_next.core.models import Artifact, PublishRecord, Task
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ProviderManifest:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
provider_type: str
|
||||||
|
entrypoint: str
|
||||||
|
capabilities: list[str]
|
||||||
|
config_schema: str | None = None
|
||||||
|
enabled_by_default: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class IngestProvider(Protocol):
|
||||||
|
manifest: ProviderManifest
|
||||||
|
|
||||||
|
def validate_source(self, source_path: Path, settings: dict[str, Any]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeProvider(Protocol):
|
||||||
|
manifest: ProviderManifest
|
||||||
|
|
||||||
|
def transcribe(self, task: Task, source_video: Artifact, settings: dict[str, Any]) -> Artifact:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SongDetector(Protocol):
|
||||||
|
manifest: ProviderManifest
|
||||||
|
|
||||||
|
def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class PublishProvider(Protocol):
|
||||||
|
manifest: ProviderManifest
|
||||||
|
|
||||||
|
def publish(self, task: Task, clip_videos: list[Artifact], settings: dict[str, Any]) -> PublishRecord:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SplitProvider(Protocol):
|
||||||
|
manifest: ProviderManifest
|
||||||
|
|
||||||
|
def split(self, task: Task, songs_json: Artifact, source_video: Artifact, settings: dict[str, Any]) -> list[Artifact]:
|
||||||
|
...
|
||||||
39
src/biliup_next/core/registry.py
Normal file
39
src/biliup_next/core/registry.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._providers: dict[str, dict[str, Any]] = {}
|
||||||
|
self._manifests: dict[str, list[ProviderManifest]] = {}
|
||||||
|
|
||||||
|
def register(self, provider_type: str, provider_id: str, provider: Any, manifest: ProviderManifest) -> None:
|
||||||
|
self._providers.setdefault(provider_type, {})
|
||||||
|
self._manifests.setdefault(provider_type, [])
|
||||||
|
if provider_id in self._providers[provider_type]:
|
||||||
|
raise RegistryError(f"重复注册 provider: {provider_type}.{provider_id}")
|
||||||
|
self._providers[provider_type][provider_id] = provider
|
||||||
|
self._manifests[provider_type].append(manifest)
|
||||||
|
|
||||||
|
def get(self, provider_type: str, provider_id: str) -> Any:
|
||||||
|
try:
|
||||||
|
return self._providers[provider_type][provider_id]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise RegistryError(f"未注册 provider: {provider_type}.{provider_id}") from exc
|
||||||
|
|
||||||
|
def list_manifests(self) -> list[dict[str, object]]:
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for provider_type, manifests in sorted(self._manifests.items()):
|
||||||
|
for manifest in manifests:
|
||||||
|
payload = asdict(manifest)
|
||||||
|
payload["provider_type"] = provider_type
|
||||||
|
items.append(payload)
|
||||||
|
return items
|
||||||
179
src/biliup_next/infra/adapters/bilibili_collection_legacy.py
Normal file
179
src/biliup_next/infra/adapters/bilibili_collection_legacy.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import Task
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyBilibiliCollectionProvider:
|
||||||
|
manifest = ProviderManifest(
|
||||||
|
id="bilibili_collection",
|
||||||
|
name="Legacy Bilibili Collection Provider",
|
||||||
|
version="0.1.0",
|
||||||
|
provider_type="collection_provider",
|
||||||
|
entrypoint="biliup_next.infra.adapters.bilibili_collection_legacy:LegacyBilibiliCollectionProvider",
|
||||||
|
capabilities=["collection"],
|
||||||
|
enabled_by_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._section_cache: dict[int, int | None] = {}
|
||||||
|
|
||||||
|
def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]:
|
||||||
|
session_dir = Path(str(settings["session_dir"])) / task.title
|
||||||
|
cookies = self._load_cookies(Path(str(settings["cookies_file"])))
|
||||||
|
csrf = cookies.get("bili_jct")
|
||||||
|
if not csrf:
|
||||||
|
raise ModuleError(code="COOKIE_CSRF_MISSING", message="Cookie 缺少 bili_jct", retryable=False)
|
||||||
|
session = requests.Session()
|
||||||
|
session.cookies.update(cookies)
|
||||||
|
session.headers.update(
|
||||||
|
{
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Referer": "https://member.bilibili.com/platform/upload-manager/distribution",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if target == "a":
|
||||||
|
season_id = int(settings["season_id_a"])
|
||||||
|
bvid = resolve_full_video_bvid(task.title, session_dir, settings)
|
||||||
|
if not bvid:
|
||||||
|
(session_dir / "collection_a_done.flag").touch()
|
||||||
|
return {"status": "skipped", "reason": "full_video_bvid_not_found"}
|
||||||
|
flag_path = session_dir / "collection_a_done.flag"
|
||||||
|
else:
|
||||||
|
season_id = int(settings["season_id_b"])
|
||||||
|
bvid_path = session_dir / "bvid.txt"
|
||||||
|
if not bvid_path.exists():
|
||||||
|
raise ModuleError(code="COLLECTION_BVID_MISSING", message=f"缺少 bvid.txt: {session_dir}", retryable=True)
|
||||||
|
bvid = bvid_path.read_text(encoding="utf-8").strip()
|
||||||
|
flag_path = session_dir / "collection_b_done.flag"
|
||||||
|
|
||||||
|
if season_id <= 0:
|
||||||
|
flag_path.touch()
|
||||||
|
return {"status": "skipped", "reason": "season_disabled"}
|
||||||
|
|
||||||
|
section_id = self._resolve_section_id(session, season_id)
|
||||||
|
if not section_id:
|
||||||
|
raise ModuleError(code="COLLECTION_SECTION_NOT_FOUND", message=f"未找到合集 section: {season_id}", retryable=True)
|
||||||
|
|
||||||
|
info = self._get_video_info(session, bvid)
|
||||||
|
episodes = [info]
|
||||||
|
add_result = self._add_videos_batch(session, csrf, section_id, episodes)
|
||||||
|
if add_result["status"] == "failed":
|
||||||
|
raise ModuleError(
|
||||||
|
code="COLLECTION_ADD_FAILED",
|
||||||
|
message=add_result["message"],
|
||||||
|
retryable=True,
|
||||||
|
details=add_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
flag_path.touch()
|
||||||
|
if add_result["status"] == "added":
|
||||||
|
append_key = "append_collection_a_new_to_end" if target == "a" else "append_collection_b_new_to_end"
|
||||||
|
if settings.get(append_key, True):
|
||||||
|
self._move_videos_to_section_end(session, csrf, section_id, [info["aid"]])
|
||||||
|
return {"status": add_result["status"], "target": target, "bvid": bvid, "season_id": season_id}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_cookies(path: Path) -> dict[str, str]:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if "cookie_info" in data:
|
||||||
|
return {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _resolve_section_id(self, session: requests.Session, season_id: int) -> int | None:
|
||||||
|
if season_id in self._section_cache:
|
||||||
|
return self._section_cache[season_id]
|
||||||
|
result = session.get("https://member.bilibili.com/x2/creative/web/seasons", params={"pn": 1, "ps": 50}, timeout=15).json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
return None
|
||||||
|
for season in result.get("data", {}).get("seasons", []):
|
||||||
|
if season.get("season", {}).get("id") == season_id:
|
||||||
|
sections = season.get("sections", {}).get("sections", [])
|
||||||
|
section_id = sections[0]["id"] if sections else None
|
||||||
|
self._section_cache[season_id] = section_id
|
||||||
|
return section_id
|
||||||
|
self._section_cache[season_id] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_video_info(session: requests.Session, bvid: str) -> dict[str, object]:
|
||||||
|
result = session.get("https://api.bilibili.com/x/web-interface/view", params={"bvid": bvid}, timeout=15).json()
|
||||||
|
if result.get("code") != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="COLLECTION_VIDEO_INFO_FAILED",
|
||||||
|
message=f"获取视频信息失败: {result.get('message')}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
data = result["data"]
|
||||||
|
return {"aid": data["aid"], "cid": data["cid"], "title": data["title"], "charging_pay": 0}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_videos_batch(session: requests.Session, csrf: str, section_id: int, episodes: list[dict[str, object]]) -> dict[str, object]:
|
||||||
|
time.sleep(random.uniform(5.0, 10.0))
|
||||||
|
result = session.post(
|
||||||
|
"https://member.bilibili.com/x2/creative/web/season/section/episodes/add",
|
||||||
|
params={"csrf": csrf},
|
||||||
|
json={"sectionId": section_id, "episodes": episodes},
|
||||||
|
timeout=20,
|
||||||
|
).json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
return {"status": "added"}
|
||||||
|
if result.get("code") == 20080:
|
||||||
|
return {"status": "already_exists", "message": result.get("message", "")}
|
||||||
|
return {"status": "failed", "message": result.get("message", "unknown error"), "code": result.get("code")}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _move_videos_to_section_end(session: requests.Session, csrf: str, section_id: int, added_aids: list[int]) -> bool:
|
||||||
|
detail = session.get(
|
||||||
|
"https://member.bilibili.com/x2/creative/web/season/section",
|
||||||
|
params={"id": section_id},
|
||||||
|
timeout=20,
|
||||||
|
).json()
|
||||||
|
if detail.get("code") != 0:
|
||||||
|
return False
|
||||||
|
section = detail.get("data", {}).get("section", {})
|
||||||
|
episodes = detail.get("data", {}).get("episodes", []) or []
|
||||||
|
if not episodes:
|
||||||
|
return True
|
||||||
|
target_aids = {int(aid) for aid in added_aids}
|
||||||
|
existing = []
|
||||||
|
appended = []
|
||||||
|
for episode in episodes:
|
||||||
|
item = {"id": episode.get("id")}
|
||||||
|
if item["id"] is None:
|
||||||
|
continue
|
||||||
|
if episode.get("aid") in target_aids:
|
||||||
|
appended.append(item)
|
||||||
|
else:
|
||||||
|
existing.append(item)
|
||||||
|
ordered = existing + appended
|
||||||
|
payload = {
|
||||||
|
"section": {
|
||||||
|
"id": section["id"],
|
||||||
|
"seasonId": section["seasonId"],
|
||||||
|
"title": section["title"],
|
||||||
|
"type": section["type"],
|
||||||
|
},
|
||||||
|
"sorts": [{"id": item["id"], "sort": idx + 1} for idx, item in enumerate(ordered)],
|
||||||
|
}
|
||||||
|
result = session.post(
|
||||||
|
"https://member.bilibili.com/x2/creative/web/season/section/edit",
|
||||||
|
params={"csrf": csrf},
|
||||||
|
json=payload,
|
||||||
|
timeout=20,
|
||||||
|
).json()
|
||||||
|
return result.get("code") == 0
|
||||||
179
src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py
Normal file
179
src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import Task
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyBilibiliTopCommentProvider:
|
||||||
|
manifest = ProviderManifest(
|
||||||
|
id="bilibili_top_comment",
|
||||||
|
name="Legacy Bilibili Top Comment Provider",
|
||||||
|
version="0.1.0",
|
||||||
|
provider_type="comment_provider",
|
||||||
|
entrypoint="biliup_next.infra.adapters.bilibili_top_comment_legacy:LegacyBilibiliTopCommentProvider",
|
||||||
|
capabilities=["comment"],
|
||||||
|
enabled_by_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def comment(self, task: Task, settings: dict[str, Any]) -> dict[str, object]:
|
||||||
|
session_dir = Path(str(settings["session_dir"])) / task.title
|
||||||
|
songs_path = session_dir / "songs.txt"
|
||||||
|
songs_json_path = session_dir / "songs.json"
|
||||||
|
bvid_path = session_dir / "bvid.txt"
|
||||||
|
if not songs_path.exists() or not bvid_path.exists():
|
||||||
|
raise ModuleError(
|
||||||
|
code="COMMENT_INPUT_MISSING",
|
||||||
|
message=f"缺少评论所需文件: {session_dir}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
timeline_content = songs_path.read_text(encoding="utf-8").strip()
|
||||||
|
split_content = self._build_split_comment_content(songs_json_path, songs_path)
|
||||||
|
if not timeline_content and not split_content:
|
||||||
|
self._touch_comment_flags(session_dir, split_done=True, full_done=True)
|
||||||
|
return {"status": "skipped", "reason": "comment_content_empty"}
|
||||||
|
|
||||||
|
cookies = self._load_cookies(Path(str(settings["cookies_file"])))
|
||||||
|
csrf = cookies.get("bili_jct")
|
||||||
|
if not csrf:
|
||||||
|
raise ModuleError(code="COOKIE_CSRF_MISSING", message="Cookie 缺少 bili_jct", retryable=False)
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.cookies.update(cookies)
|
||||||
|
session.headers.update(
|
||||||
|
{
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Referer": "https://www.bilibili.com/",
|
||||||
|
"Origin": "https://www.bilibili.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
split_result = {"status": "skipped", "reason": "disabled"}
|
||||||
|
full_result = {"status": "skipped", "reason": "disabled"}
|
||||||
|
split_done = (session_dir / "comment_split_done.flag").exists()
|
||||||
|
full_done = (session_dir / "comment_full_done.flag").exists()
|
||||||
|
|
||||||
|
if settings.get("post_split_comment", True) and not split_done:
|
||||||
|
split_bvid = bvid_path.read_text(encoding="utf-8").strip()
|
||||||
|
if split_content:
|
||||||
|
split_result = self._post_and_top_comment(session, csrf, split_bvid, split_content, "split")
|
||||||
|
else:
|
||||||
|
split_result = {"status": "skipped", "reason": "split_comment_empty"}
|
||||||
|
split_done = True
|
||||||
|
(session_dir / "comment_split_done.flag").touch()
|
||||||
|
elif not split_done:
|
||||||
|
split_done = True
|
||||||
|
(session_dir / "comment_split_done.flag").touch()
|
||||||
|
|
||||||
|
if settings.get("post_full_video_timeline_comment", True) and not full_done:
|
||||||
|
full_bvid = resolve_full_video_bvid(task.title, session_dir, settings)
|
||||||
|
if full_bvid and timeline_content:
|
||||||
|
full_result = self._post_and_top_comment(session, csrf, full_bvid, timeline_content, "full")
|
||||||
|
else:
|
||||||
|
full_result = {"status": "skipped", "reason": "full_video_bvid_not_found" if not full_bvid else "timeline_comment_empty"}
|
||||||
|
full_done = True
|
||||||
|
(session_dir / "comment_full_done.flag").touch()
|
||||||
|
elif not full_done:
|
||||||
|
full_done = True
|
||||||
|
(session_dir / "comment_full_done.flag").touch()
|
||||||
|
|
||||||
|
if split_done and full_done:
|
||||||
|
(session_dir / "comment_done.flag").touch()
|
||||||
|
return {"status": "ok", "split": split_result, "full": full_result}
|
||||||
|
|
||||||
|
def _post_and_top_comment(
|
||||||
|
self,
|
||||||
|
session: requests.Session,
|
||||||
|
csrf: str,
|
||||||
|
bvid: str,
|
||||||
|
content: str,
|
||||||
|
target: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
view = session.get("https://api.bilibili.com/x/web-interface/view", params={"bvid": bvid}, timeout=15).json()
|
||||||
|
if view.get("code") != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="COMMENT_VIEW_FAILED",
|
||||||
|
message=f"获取{target}视频信息失败: {view.get('message')}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
aid = view["data"]["aid"]
|
||||||
|
add_res = session.post(
|
||||||
|
"https://api.bilibili.com/x/v2/reply/add",
|
||||||
|
data={"type": 1, "oid": aid, "message": content, "plat": 1, "csrf": csrf},
|
||||||
|
timeout=15,
|
||||||
|
).json()
|
||||||
|
if add_res.get("code") != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="COMMENT_POST_FAILED",
|
||||||
|
message=f"发布{target}评论失败: {add_res.get('message')}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
rpid = add_res["data"]["rpid"]
|
||||||
|
time.sleep(3)
|
||||||
|
top_res = session.post(
|
||||||
|
"https://api.bilibili.com/x/v2/reply/top",
|
||||||
|
data={"type": 1, "oid": aid, "rpid": rpid, "action": 1, "csrf": csrf},
|
||||||
|
timeout=15,
|
||||||
|
).json()
|
||||||
|
if top_res.get("code") != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="COMMENT_TOP_FAILED",
|
||||||
|
message=f"置顶{target}评论失败: {top_res.get('message')}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "bvid": bvid, "aid": aid, "rpid": rpid}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_split_comment_content(songs_json_path: Path, songs_txt_path: Path) -> str:
|
||||||
|
if songs_json_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(songs_json_path.read_text(encoding="utf-8"))
|
||||||
|
lines = []
|
||||||
|
for index, song in enumerate(data.get("songs", []), 1):
|
||||||
|
title = str(song.get("title", "")).strip()
|
||||||
|
artist = str(song.get("artist", "")).strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
suffix = f" — {artist}" if artist else ""
|
||||||
|
lines.append(f"{index}. {title}{suffix}")
|
||||||
|
if lines:
|
||||||
|
return "\n".join(lines)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if songs_txt_path.exists():
|
||||||
|
lines = []
|
||||||
|
for index, raw in enumerate(songs_txt_path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
parts = text.split(" ", 1)
|
||||||
|
song_text = parts[1] if len(parts) == 2 and ":" in parts[0] else text
|
||||||
|
lines.append(f"{index}. {song_text}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_cookies(path: Path) -> dict[str, str]:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if "cookie_info" in data:
|
||||||
|
return {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])}
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _touch_comment_flags(session_dir: Path, *, split_done: bool, full_done: bool) -> None:
|
||||||
|
if split_done:
|
||||||
|
(session_dir / "comment_split_done.flag").touch()
|
||||||
|
if full_done:
|
||||||
|
(session_dir / "comment_full_done.flag").touch()
|
||||||
|
if split_done and full_done:
|
||||||
|
(session_dir / "comment_done.flag").touch()
|
||||||
176
src/biliup_next/infra/adapters/biliup_publish_legacy.py
Normal file
176
src/biliup_next/infra/adapters/biliup_publish_legacy.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import PublishRecord, Task, utc_now_iso
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
from biliup_next.infra.legacy_paths import legacy_project_root
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyBiliupPublishProvider:
|
||||||
|
manifest = ProviderManifest(
|
||||||
|
id="biliup_cli",
|
||||||
|
name="Legacy biliup CLI Publish Provider",
|
||||||
|
version="0.1.0",
|
||||||
|
provider_type="publish_provider",
|
||||||
|
entrypoint="biliup_next.infra.adapters.biliup_publish_legacy:LegacyBiliupPublishProvider",
|
||||||
|
capabilities=["publish"],
|
||||||
|
enabled_by_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, next_root: Path):
|
||||||
|
self.next_root = next_root
|
||||||
|
self.legacy_root = legacy_project_root(next_root)
|
||||||
|
|
||||||
|
def publish(self, task: Task, clip_videos: list, settings: dict[str, Any]) -> PublishRecord:
|
||||||
|
work_dir = Path(str(settings.get("session_dir", str(self.legacy_root / "session")))) / task.title
|
||||||
|
bvid_file = work_dir / "bvid.txt"
|
||||||
|
upload_done = work_dir / "upload_done.flag"
|
||||||
|
config = self._load_upload_config(Path(str(settings.get("upload_config_file", str(self.legacy_root / "upload_config.json")))))
|
||||||
|
if bvid_file.exists():
|
||||||
|
bvid = bvid_file.read_text(encoding="utf-8").strip()
|
||||||
|
return PublishRecord(
|
||||||
|
id=None,
|
||||||
|
task_id=task.id,
|
||||||
|
platform="bilibili",
|
||||||
|
aid=None,
|
||||||
|
bvid=bvid,
|
||||||
|
title=task.title,
|
||||||
|
published_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
|
||||||
|
video_files = [artifact.path for artifact in clip_videos]
|
||||||
|
if not video_files:
|
||||||
|
raise ModuleError(
|
||||||
|
code="PUBLISH_NO_CLIPS",
|
||||||
|
message=f"没有可上传的切片: {task.id}",
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = self._parse_filename(task.title, config)
|
||||||
|
streamer = parsed.get("streamer", task.title)
|
||||||
|
date = parsed.get("date", "")
|
||||||
|
|
||||||
|
songs_txt = work_dir / "songs.txt"
|
||||||
|
songs_list = songs_txt.read_text(encoding="utf-8").strip() if songs_txt.exists() else ""
|
||||||
|
songs_json = work_dir / "songs.json"
|
||||||
|
song_count = 0
|
||||||
|
if songs_json.exists():
|
||||||
|
song_count = len(json.loads(songs_json.read_text(encoding="utf-8")).get("songs", []))
|
||||||
|
|
||||||
|
quote = self._get_random_quote(config)
|
||||||
|
template_vars = {
|
||||||
|
"streamer": streamer,
|
||||||
|
"date": date,
|
||||||
|
"song_count": song_count,
|
||||||
|
"songs_list": songs_list,
|
||||||
|
"daily_quote": quote.get("text", ""),
|
||||||
|
"quote_author": quote.get("author", ""),
|
||||||
|
}
|
||||||
|
template = config.get("template", {})
|
||||||
|
title = template.get("title", "{streamer}_{date}").format(**template_vars)
|
||||||
|
description = template.get("description", "{songs_list}").format(**template_vars)
|
||||||
|
dynamic = template.get("dynamic", "").format(**template_vars)
|
||||||
|
tags = template.get("tag", "翻唱,唱歌,音乐").format(**template_vars)
|
||||||
|
streamer_cfg = config.get("streamers", {})
|
||||||
|
if streamer in streamer_cfg:
|
||||||
|
tags = streamer_cfg[streamer].get("tags", tags)
|
||||||
|
|
||||||
|
upload_settings = config.get("upload_settings", {})
|
||||||
|
tid = upload_settings.get("tid", 31)
|
||||||
|
biliup_path = str(settings.get("biliup_path", str(self.legacy_root / "biliup")))
|
||||||
|
cookie_file = str(settings.get("cookie_file", str(self.legacy_root / "cookies.json")))
|
||||||
|
|
||||||
|
subprocess.run([biliup_path, "-u", cookie_file, "renew"], capture_output=True, text=True)
|
||||||
|
|
||||||
|
first_batch = video_files[:5]
|
||||||
|
remaining_batches = [video_files[i:i + 5] for i in range(5, len(video_files), 5)]
|
||||||
|
upload_cmd = [
|
||||||
|
biliup_path, "-u", cookie_file, "upload",
|
||||||
|
*first_batch,
|
||||||
|
"--title", title,
|
||||||
|
"--tid", str(tid),
|
||||||
|
"--tag", tags,
|
||||||
|
"--copyright", str(upload_settings.get("copyright", 2)),
|
||||||
|
"--source", upload_settings.get("source", "直播回放"),
|
||||||
|
"--desc", description,
|
||||||
|
]
|
||||||
|
if dynamic:
|
||||||
|
upload_cmd.extend(["--dynamic", dynamic])
|
||||||
|
|
||||||
|
bvid = self._run_upload(upload_cmd, "首批上传")
|
||||||
|
bvid_file.write_text(bvid, encoding="utf-8")
|
||||||
|
|
||||||
|
for idx, batch in enumerate(remaining_batches, 2):
|
||||||
|
append_cmd = [biliup_path, "-u", cookie_file, "append", "--vid", bvid, *batch]
|
||||||
|
self._run_append(append_cmd, f"追加第 {idx} 批")
|
||||||
|
|
||||||
|
upload_done.touch()
|
||||||
|
return PublishRecord(
|
||||||
|
id=None,
|
||||||
|
task_id=task.id,
|
||||||
|
platform="bilibili",
|
||||||
|
aid=None,
|
||||||
|
bvid=bvid,
|
||||||
|
title=title,
|
||||||
|
published_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_upload(self, cmd: list[str], label: str) -> str:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
match = re.search(r'"bvid":"(BV[A-Za-z0-9]+)"', result.stdout) or re.search(r'(BV[A-Za-z0-9]+)', result.stdout)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
raise ModuleError(
|
||||||
|
code="PUBLISH_UPLOAD_FAILED",
|
||||||
|
message=f"{label}失败",
|
||||||
|
retryable=True,
|
||||||
|
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_append(self, cmd: list[str], label: str) -> None:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
raise ModuleError(
|
||||||
|
code="PUBLISH_APPEND_FAILED",
|
||||||
|
message=f"{label}失败",
|
||||||
|
retryable=True,
|
||||||
|
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_upload_config(self, path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def _parse_filename(self, filename: str, config: dict[str, Any] | None = None) -> dict[str, str]:
|
||||||
|
config = config or {}
|
||||||
|
patterns = config.get("filename_patterns", {}).get("patterns", [])
|
||||||
|
for pattern_config in patterns:
|
||||||
|
regex = pattern_config.get("regex")
|
||||||
|
if not regex:
|
||||||
|
continue
|
||||||
|
match = re.match(regex, filename)
|
||||||
|
if match:
|
||||||
|
data = match.groupdict()
|
||||||
|
date_format = pattern_config.get("date_format", "{date}")
|
||||||
|
try:
|
||||||
|
data["date"] = date_format.format(**data)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
return {"streamer": filename, "date": ""}
|
||||||
|
|
||||||
|
def _get_random_quote(self, config: dict[str, Any]) -> dict[str, str]:
|
||||||
|
quotes = config.get("quotes", [])
|
||||||
|
if not quotes:
|
||||||
|
return {"text": "", "author": ""}
|
||||||
|
return random.choice(quotes)
|
||||||
140
src/biliup_next/infra/adapters/codex_legacy.py
Normal file
140
src/biliup_next/infra/adapters/codex_legacy.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import Artifact, Task, utc_now_iso
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
from biliup_next.infra.legacy_paths import legacy_project_root
|
||||||
|
|
||||||
|
SONG_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"songs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"start": {"type": "string"},
|
||||||
|
"end": {"type": "string"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"artist": {"type": "string"},
|
||||||
|
"confidence": {"type": "number"},
|
||||||
|
"evidence": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["start", "end", "title", "artist", "confidence", "evidence"],
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["songs"],
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
|
||||||
|
TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件。
|
||||||
|
任务:
|
||||||
|
1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。
|
||||||
|
2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则:
|
||||||
|
- 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。
|
||||||
|
- 这样可以尽量保留歌曲可能存在的前奏。
|
||||||
|
3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌.
|
||||||
|
4. 忽略纯聊天片段。
|
||||||
|
5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。
|
||||||
|
6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。
|
||||||
|
7. 仔细分析每一句歌词,识别出相关歌曲后, 使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间
|
||||||
|
8.歌曲标注规则:
|
||||||
|
- 可以在歌曲名称后使用括号 () 添加补充说明。
|
||||||
|
- 常见标注示例:
|
||||||
|
- (片段):歌曲演唱时间较短,例如 < 60 秒
|
||||||
|
- (清唱):无伴奏演唱
|
||||||
|
- (副歌):只演唱副歌部分
|
||||||
|
- 标注应简洁,仅在确有必要时使用。
|
||||||
|
9. 通过歌曲起始和结束时间自检, 一般歌曲长度在5分钟以内, 1分钟以上, 可疑片段重新联网搜索检查.
|
||||||
|
最后请严格按照 Schema 生成 JSON 数据。"""
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyCodexSongDetector:
|
||||||
|
manifest = ProviderManifest(
|
||||||
|
id="codex",
|
||||||
|
name="Legacy Codex Song Detector",
|
||||||
|
version="0.1.0",
|
||||||
|
provider_type="song_detector",
|
||||||
|
entrypoint="biliup_next.infra.adapters.codex_legacy:LegacyCodexSongDetector",
|
||||||
|
capabilities=["song_detect"],
|
||||||
|
enabled_by_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, next_root: Path):
|
||||||
|
self.next_root = next_root
|
||||||
|
self.legacy_root = legacy_project_root(next_root)
|
||||||
|
|
||||||
|
def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]:
|
||||||
|
work_dir = Path(subtitle_srt.path).parent
|
||||||
|
schema_path = work_dir / "song_schema.json"
|
||||||
|
schema_path.write_text(json.dumps(SONG_SCHEMA, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"CODEX_CMD": str(settings.get("codex_cmd", "codex")),
|
||||||
|
}
|
||||||
|
cmd = [
|
||||||
|
str(settings.get("codex_cmd", "codex")),
|
||||||
|
"exec",
|
||||||
|
TASK_PROMPT.replace("\n", " "),
|
||||||
|
"--full-auto",
|
||||||
|
"--sandbox", "workspace-write",
|
||||||
|
"--output-schema", "./song_schema.json",
|
||||||
|
"-o", "songs.json",
|
||||||
|
"--skip-git-repo-check",
|
||||||
|
"--json",
|
||||||
|
]
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(work_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="SONG_DETECT_FAILED",
|
||||||
|
message="codex exec 执行失败",
|
||||||
|
retryable=True,
|
||||||
|
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||||
|
)
|
||||||
|
songs_json = work_dir / "songs.json"
|
||||||
|
songs_txt = work_dir / "songs.txt"
|
||||||
|
if songs_json.exists() and not songs_txt.exists():
|
||||||
|
data = json.loads(songs_json.read_text(encoding="utf-8"))
|
||||||
|
with songs_txt.open("w", encoding="utf-8") as f:
|
||||||
|
for song in data.get("songs", []):
|
||||||
|
start_time = song["start"].split(",")[0].split(".")[0]
|
||||||
|
f.write(f"{start_time} {song['title']} — {song['artist']}\n")
|
||||||
|
if not songs_json.exists() or not songs_txt.exists():
|
||||||
|
raise ModuleError(
|
||||||
|
code="SONG_DETECT_OUTPUT_MISSING",
|
||||||
|
message=f"未生成 songs.json/songs.txt: {work_dir}",
|
||||||
|
retryable=True,
|
||||||
|
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Artifact(
|
||||||
|
id=None,
|
||||||
|
task_id=task.id,
|
||||||
|
artifact_type="songs_json",
|
||||||
|
path=str(songs_json),
|
||||||
|
metadata_json=json.dumps({"provider": "codex_legacy"}),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
),
|
||||||
|
Artifact(
|
||||||
|
id=None,
|
||||||
|
task_id=task.id,
|
||||||
|
artifact_type="songs_txt",
|
||||||
|
path=str(songs_txt),
|
||||||
|
metadata_json=json.dumps({"provider": "codex_legacy"}),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
92
src/biliup_next/infra/adapters/ffmpeg_split_legacy.py
Normal file
92
src/biliup_next/infra/adapters/ffmpeg_split_legacy.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import Artifact, Task, utc_now_iso
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
from biliup_next.infra.legacy_paths import legacy_project_root
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyFfmpegSplitProvider:
|
||||||
|
manifest = ProviderManifest(
|
||||||
|
id="ffmpeg_copy",
|
||||||
|
name="Legacy FFmpeg Split Provider",
|
||||||
|
version="0.1.0",
|
||||||
|
provider_type="split_provider",
|
||||||
|
entrypoint="biliup_next.infra.adapters.ffmpeg_split_legacy:LegacyFfmpegSplitProvider",
|
||||||
|
capabilities=["split"],
|
||||||
|
enabled_by_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, next_root: Path):
|
||||||
|
self.next_root = next_root
|
||||||
|
self.legacy_root = legacy_project_root(next_root)
|
||||||
|
|
||||||
|
def split(self, task: Task, songs_json: Artifact, source_video: Artifact, settings: dict[str, Any]) -> list[Artifact]:
|
||||||
|
work_dir = Path(songs_json.path).parent
|
||||||
|
split_dir = work_dir / "split_video"
|
||||||
|
split_done = work_dir / "split_done.flag"
|
||||||
|
if split_done.exists() and split_dir.exists():
|
||||||
|
return self._collect_existing_clips(task.id, split_dir)
|
||||||
|
|
||||||
|
with Path(songs_json.path).open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
songs = data.get("songs", [])
|
||||||
|
if not songs:
|
||||||
|
raise ModuleError(
|
||||||
|
code="SPLIT_SONGS_EMPTY",
|
||||||
|
message=f"songs.json 中没有歌曲: {songs_json.path}",
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
split_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ffmpeg_bin = str(settings.get("ffmpeg_bin", "ffmpeg"))
|
||||||
|
video_path = Path(source_video.path)
|
||||||
|
for idx, song in enumerate(songs, 1):
|
||||||
|
start = str(song.get("start", "00:00:00,000")).replace(",", ".")
|
||||||
|
end = str(song.get("end", "00:00:00,000")).replace(",", ".")
|
||||||
|
title = str(song.get("title", "UNKNOWN")).replace("/", "_").replace("\\", "_")
|
||||||
|
output_path = split_dir / f"{idx:02d}_{title}{video_path.suffix}"
|
||||||
|
if output_path.exists():
|
||||||
|
continue
|
||||||
|
cmd = [
|
||||||
|
ffmpeg_bin,
|
||||||
|
"-y",
|
||||||
|
"-ss", start,
|
||||||
|
"-to", end,
|
||||||
|
"-i", str(video_path),
|
||||||
|
"-c", "copy",
|
||||||
|
"-map_metadata", "0",
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="SPLIT_FFMPEG_FAILED",
|
||||||
|
message=f"ffmpeg 切割失败: {output_path.name}",
|
||||||
|
retryable=True,
|
||||||
|
details={"stderr": result.stderr[-2000:]},
|
||||||
|
)
|
||||||
|
|
||||||
|
split_done.touch()
|
||||||
|
return self._collect_existing_clips(task.id, split_dir)
|
||||||
|
|
||||||
|
def _collect_existing_clips(self, task_id: str, split_dir: Path) -> list[Artifact]:
|
||||||
|
artifacts: list[Artifact] = []
|
||||||
|
for path in sorted(split_dir.iterdir()):
|
||||||
|
if path.is_file():
|
||||||
|
artifacts.append(
|
||||||
|
Artifact(
|
||||||
|
id=None,
|
||||||
|
task_id=task_id,
|
||||||
|
artifact_type="clip_video",
|
||||||
|
path=str(path),
|
||||||
|
metadata_json=json.dumps({"provider": "ffmpeg_copy"}),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return artifacts
|
||||||
68
src/biliup_next/infra/adapters/full_video_locator.py
Normal file
68
src/biliup_next/infra/adapters/full_video_locator.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_title(text: str) -> str:
|
||||||
|
return re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_biliup_list(settings: dict[str, Any], *, max_pages: int = 5) -> list[dict[str, str]]:
|
||||||
|
cmd = [
|
||||||
|
str(settings["biliup_path"]),
|
||||||
|
"-u",
|
||||||
|
str(settings["cookie_file"]),
|
||||||
|
"list",
|
||||||
|
"--max-pages",
|
||||||
|
str(max_pages),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ModuleError(code="BILIUP_NOT_FOUND", message=f"找不到 biliup: {settings['biliup_path']}", retryable=False) from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="BILIUP_LIST_FAILED",
|
||||||
|
message="biliup list 执行失败",
|
||||||
|
retryable=True,
|
||||||
|
details={"stderr": (result.stderr or "")[-1000:]},
|
||||||
|
)
|
||||||
|
|
||||||
|
videos: list[dict[str, str]] = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if not line.startswith("BV"):
|
||||||
|
continue
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) >= 3 and "开放浏览" not in parts[2]:
|
||||||
|
continue
|
||||||
|
if len(parts) >= 2:
|
||||||
|
videos.append({"bvid": parts[0].strip(), "title": parts[1].strip()})
|
||||||
|
return videos
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_full_video_bvid(title: str, session_dir: Path, settings: dict[str, Any]) -> str | None:
|
||||||
|
bvid_file = session_dir / "full_video_bvid.txt"
|
||||||
|
if bvid_file.exists():
|
||||||
|
value = bvid_file.read_text(encoding="utf-8").strip()
|
||||||
|
if value.startswith("BV"):
|
||||||
|
return value
|
||||||
|
|
||||||
|
videos = fetch_biliup_list(settings)
|
||||||
|
normalized_title = normalize_title(title)
|
||||||
|
for video in videos:
|
||||||
|
if normalize_title(video["title"]) == normalized_title:
|
||||||
|
bvid_file.write_text(video["bvid"], encoding="utf-8")
|
||||||
|
return video["bvid"]
|
||||||
|
|
||||||
|
if settings.get("allow_fuzzy_full_video_match", False):
|
||||||
|
for video in videos:
|
||||||
|
normalized_video_title = normalize_title(video["title"])
|
||||||
|
if normalized_title in normalized_video_title or normalized_video_title in normalized_title:
|
||||||
|
bvid_file.write_text(video["bvid"], encoding="utf-8")
|
||||||
|
return video["bvid"]
|
||||||
|
return None
|
||||||
79
src/biliup_next/infra/adapters/groq_legacy.py
Normal file
79
src/biliup_next/infra/adapters/groq_legacy.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.errors import ModuleError
|
||||||
|
from biliup_next.core.models import Artifact, Task, utc_now_iso
|
||||||
|
from biliup_next.core.providers import ProviderManifest
|
||||||
|
from biliup_next.infra.legacy_paths import legacy_project_root
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyGroqTranscribeProvider:
|
||||||
|
manifest = ProviderManifest(
|
||||||
|
id="groq",
|
||||||
|
name="Legacy Groq Transcribe Provider",
|
||||||
|
version="0.1.0",
|
||||||
|
provider_type="transcribe_provider",
|
||||||
|
entrypoint="biliup_next.infra.adapters.groq_legacy:LegacyGroqTranscribeProvider",
|
||||||
|
capabilities=["transcribe"],
|
||||||
|
enabled_by_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, next_root: Path):
|
||||||
|
self.next_root = next_root
|
||||||
|
self.legacy_root = legacy_project_root(next_root)
|
||||||
|
self.python_bin = self._resolve_python_bin()
|
||||||
|
|
||||||
|
def transcribe(self, task: Task, source_video: Artifact, settings: dict[str, Any]) -> Artifact:
|
||||||
|
session_dir = Path(str(settings.get("session_dir", str(self.legacy_root / "session"))))
|
||||||
|
work_dir = (session_dir / task.title).resolve()
|
||||||
|
cmd = [
|
||||||
|
self.python_bin,
|
||||||
|
"video2srt.py",
|
||||||
|
source_video.path,
|
||||||
|
str(work_dir),
|
||||||
|
]
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"GROQ_API_KEY": str(settings.get("groq_api_key", "")),
|
||||||
|
"FFMPEG_BIN": str(settings.get("ffmpeg_bin", "ffmpeg")),
|
||||||
|
}
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(self.legacy_root),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ModuleError(
|
||||||
|
code="TRANSCRIBE_FAILED",
|
||||||
|
message="legacy video2srt.py 执行失败",
|
||||||
|
retryable=True,
|
||||||
|
details={"stderr": result.stderr[-2000:], "stdout": result.stdout[-2000:]},
|
||||||
|
)
|
||||||
|
srt_path = work_dir / f"{task.title}.srt"
|
||||||
|
if not srt_path.exists():
|
||||||
|
raise ModuleError(
|
||||||
|
code="TRANSCRIBE_OUTPUT_MISSING",
|
||||||
|
message=f"未找到字幕文件: {srt_path}",
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
return Artifact(
|
||||||
|
id=None,
|
||||||
|
task_id=task.id,
|
||||||
|
artifact_type="subtitle_srt",
|
||||||
|
path=str(srt_path),
|
||||||
|
metadata_json=json.dumps({"provider": "groq_legacy"}),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_python_bin(self) -> str:
|
||||||
|
venv_python = self.legacy_root / ".venv" / "bin" / "python"
|
||||||
|
if venv_python.exists():
|
||||||
|
return str(venv_python)
|
||||||
|
return "python"
|
||||||
27
src/biliup_next/infra/comment_flag_migration.py
Normal file
27
src/biliup_next/infra/comment_flag_migration.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class CommentFlagMigrationService:
|
||||||
|
def migrate(self, session_dir: Path) -> dict[str, int]:
|
||||||
|
migrated_split_flags = 0
|
||||||
|
legacy_untracked_full = 0
|
||||||
|
if not session_dir.exists():
|
||||||
|
return {"migrated_split_flags": 0, "legacy_untracked_full": 0}
|
||||||
|
|
||||||
|
for folder in sorted(p for p in session_dir.iterdir() if p.is_dir()):
|
||||||
|
comment_done = folder / "comment_done.flag"
|
||||||
|
split_done = folder / "comment_split_done.flag"
|
||||||
|
full_done = folder / "comment_full_done.flag"
|
||||||
|
if not comment_done.exists():
|
||||||
|
continue
|
||||||
|
if not split_done.exists():
|
||||||
|
split_done.touch()
|
||||||
|
migrated_split_flags += 1
|
||||||
|
if not full_done.exists():
|
||||||
|
legacy_untracked_full += 1
|
||||||
|
return {
|
||||||
|
"migrated_split_flags": migrated_split_flags,
|
||||||
|
"legacy_untracked_full": legacy_untracked_full,
|
||||||
|
}
|
||||||
78
src/biliup_next/infra/db.py
Normal file
78
src/biliup_next/infra/db.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS task_steps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS artifacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
artifact_type TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
aid TEXT,
|
||||||
|
bvid TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
published_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS action_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT,
|
||||||
|
action_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
self.db_path = db_path
|
||||||
|
|
||||||
|
def connect(self) -> sqlite3.Connection:
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
conn.commit()
|
||||||
68
src/biliup_next/infra/legacy_asset_sync.py
Normal file
68
src/biliup_next/infra/legacy_asset_sync.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from biliup_next.core.config import SettingsService
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyAssetSync:
|
||||||
|
def __init__(self, root_dir: Path):
|
||||||
|
self.root_dir = root_dir
|
||||||
|
self.runtime_dir = self.root_dir / "runtime"
|
||||||
|
self.settings_service = SettingsService(root_dir)
|
||||||
|
|
||||||
|
def sync(self) -> dict[str, Any]:
|
||||||
|
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
bundle = self.settings_service.load()
|
||||||
|
settings = json.loads(json.dumps(bundle.settings))
|
||||||
|
|
||||||
|
copied: list[dict[str, str]] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
copied_pairs: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
mapping = [
|
||||||
|
("paths", "cookies_file", "runtime/cookies.json"),
|
||||||
|
("paths", "upload_config_file", "runtime/upload_config.json"),
|
||||||
|
("publish", "cookie_file", "runtime/cookies.json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for group, field, target_rel in mapping:
|
||||||
|
current = Path(str(settings[group][field]))
|
||||||
|
current_abs = current if current.is_absolute() else (self.root_dir / current).resolve()
|
||||||
|
target_abs = (self.root_dir / target_rel).resolve()
|
||||||
|
if current_abs == target_abs and target_abs.exists():
|
||||||
|
continue
|
||||||
|
if current_abs.exists():
|
||||||
|
shutil.copy2(current_abs, target_abs)
|
||||||
|
settings[group][field] = target_rel
|
||||||
|
pair = (str(current_abs), str(target_abs))
|
||||||
|
if pair not in copied_pairs:
|
||||||
|
copied_pairs.add(pair)
|
||||||
|
copied.append({"from": pair[0], "to": pair[1]})
|
||||||
|
else:
|
||||||
|
missing.append(f"{group}.{field}:{current_abs}")
|
||||||
|
|
||||||
|
publish_path = Path(str(settings["publish"]["biliup_path"]))
|
||||||
|
publish_abs = publish_path if publish_path.is_absolute() else (self.root_dir / publish_path).resolve()
|
||||||
|
local_biliup = self.root_dir / "runtime" / "biliup"
|
||||||
|
if publish_abs.exists() and publish_abs != local_biliup:
|
||||||
|
shutil.copy2(publish_abs, local_biliup)
|
||||||
|
local_biliup.chmod(0o755)
|
||||||
|
settings["publish"]["biliup_path"] = "runtime/biliup"
|
||||||
|
pair = (str(publish_abs), str(local_biliup))
|
||||||
|
if pair not in copied_pairs:
|
||||||
|
copied_pairs.add(pair)
|
||||||
|
copied.append({"from": pair[0], "to": pair[1]})
|
||||||
|
|
||||||
|
self.settings_service.save_staged(settings)
|
||||||
|
self.settings_service.promote_staged()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"runtime_dir": str(self.runtime_dir),
|
||||||
|
"copied": copied,
|
||||||
|
"missing": missing,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user