From d0cf1fd0dfb73bd9616ba01637df688a80ad0d2a Mon Sep 17 00:00:00 2001 From: theshy Date: Wed, 1 Apr 2026 00:44:58 +0800 Subject: [PATCH] init biliup-next --- .gitignore | 21 + DELIVERY.md | 104 + README.md | 212 ++ config/settings.json | 96 + config/settings.schema.json | 444 ++++ config/settings.standalone.example.json | 59 + docs/adr/0001-modular-monolith.md | 64 + docs/api/openapi.yaml | 245 +++ docs/architecture.md | 198 ++ docs/config-system.md | 193 ++ docs/control-plane-guide.md | 476 +++++ docs/design-principles.md | 238 +++ docs/domain-model.md | 135 ++ docs/migration-plan.md | 192 ++ docs/module-contracts.md | 229 +++ docs/plugin-system.md | 156 ++ docs/state-machine.md | 212 ++ docs/vision.md | 54 + frontend/README.md | 65 + frontend/index.html | 12 + frontend/package-lock.json | 1815 +++++++++++++++++ frontend/package.json | 19 + frontend/src/App.jsx | 495 +++++ frontend/src/api/client.js | 29 + frontend/src/components/LogsPanel.jsx | 87 + frontend/src/components/OverviewPanel.jsx | 166 ++ frontend/src/components/SettingsPanel.jsx | 310 +++ frontend/src/components/StatusBadge.jsx | 6 + frontend/src/components/TaskDetailCard.jsx | 142 ++ frontend/src/components/TaskTable.jsx | 84 + frontend/src/lib/format.js | 51 + frontend/src/main.jsx | 11 + frontend/src/styles.css | 553 +++++ frontend/vite.config.mjs | 34 + install-systemd.sh | 49 + pyproject.toml | 18 + run-api.sh | 22 + run-worker.sh | 21 + runtime/README.md | 16 + setup.sh | 68 + smoke-test.sh | 30 + src/biliup_next.egg-info/PKG-INFO | 6 + src/biliup_next.egg-info/SOURCES.txt | 42 + src/biliup_next.egg-info/dependency_links.txt | 1 + src/biliup_next.egg-info/entry_points.txt | 2 + src/biliup_next.egg-info/requires.txt | 1 + src/biliup_next.egg-info/top_level.txt | 1 + src/biliup_next/__init__.py | 1 + src/biliup_next/app/api_server.py | 530 +++++ src/biliup_next/app/bootstrap.py | 77 + src/biliup_next/app/cli.py | 120 ++ src/biliup_next/app/dashboard.py | 346 ++++ src/biliup_next/app/retry_meta.py | 73 + src/biliup_next/app/scheduler.py | 181 ++ src/biliup_next/app/static/app/actions.js | 215 ++ src/biliup_next/app/static/app/api.js | 52 + .../static/app/components/artifact-list.js | 16 + .../app/components/doctor-check-list.js | 15 + .../app/static/app/components/history-list.js | 23 + .../app/static/app/components/modules-list.js | 15 + .../static/app/components/overview-runtime.js | 11 + .../app/components/overview-task-summary.js | 46 + .../app/components/recent-actions-list.js | 19 + .../app/static/app/components/retry-banner.js | 19 + .../app/static/app/components/service-list.js | 27 + .../app/static/app/components/step-list.js | 34 + .../app/static/app/components/task-hero.js | 22 + .../static/app/components/timeline-list.js | 22 + src/biliup_next/app/static/app/main.js | 210 ++ src/biliup_next/app/static/app/render.js | 18 + src/biliup_next/app/static/app/router.js | 22 + src/biliup_next/app/static/app/state.js | 91 + src/biliup_next/app/static/app/utils.js | 61 + src/biliup_next/app/static/app/views/logs.js | 52 + .../app/static/app/views/overview.js | 98 + .../app/static/app/views/settings.js | 162 ++ src/biliup_next/app/static/app/views/tasks.js | 462 +++++ src/biliup_next/app/static/dashboard.css | 815 ++++++++ src/biliup_next/app/static/dashboard.js | 805 ++++++++ src/biliup_next/app/task_actions.py | 29 + src/biliup_next/app/task_audit.py | 19 + src/biliup_next/app/task_engine.py | 129 ++ src/biliup_next/app/task_policies.py | 67 + src/biliup_next/app/task_runner.py | 74 + src/biliup_next/app/worker.py | 30 + src/biliup_next/core/config.py | 230 +++ src/biliup_next/core/errors.py | 15 + src/biliup_next/core/models.py | 80 + src/biliup_next/core/providers.py | 54 + src/biliup_next/core/registry.py | 39 + .../adapters/bilibili_collection_legacy.py | 179 ++ .../adapters/bilibili_top_comment_legacy.py | 179 ++ .../infra/adapters/biliup_publish_legacy.py | 176 ++ .../infra/adapters/codex_legacy.py | 140 ++ .../infra/adapters/ffmpeg_split_legacy.py | 92 + .../infra/adapters/full_video_locator.py | 68 + src/biliup_next/infra/adapters/groq_legacy.py | 79 + .../infra/comment_flag_migration.py | 27 + src/biliup_next/infra/db.py | 78 + src/biliup_next/infra/legacy_asset_sync.py | 68 + src/biliup_next/infra/legacy_paths.py | 7 + src/biliup_next/infra/log_reader.py | 42 + src/biliup_next/infra/plugin_loader.py | 61 + src/biliup_next/infra/runtime_doctor.py | 54 + src/biliup_next/infra/stage_importer.py | 93 + src/biliup_next/infra/storage_guard.py | 41 + src/biliup_next/infra/systemd_runtime.py | 83 + src/biliup_next/infra/task_repository.py | 458 +++++ src/biliup_next/infra/task_reset.py | 163 ++ src/biliup_next/infra/workspace_cleanup.py | 40 + src/biliup_next/modules/collection/service.py | 34 + src/biliup_next/modules/comment/service.py | 24 + .../modules/ingest/providers/local_file.py | 42 + src/biliup_next/modules/ingest/service.py | 201 ++ src/biliup_next/modules/publish/service.py | 43 + .../modules/song_detect/service.py | 28 + src/biliup_next/modules/split/service.py | 45 + src/biliup_next/modules/transcribe/service.py | 27 + .../collection_bilibili_collection.json | 9 + .../comment_bilibili_top_comment.json | 9 + .../plugins/manifests/ingest_local_file.json | 9 + .../plugins/manifests/publish_biliup_cli.json | 9 + .../plugins/manifests/song_detect_codex.json | 9 + .../plugins/manifests/split_ffmpeg_copy.json | 9 + .../plugins/manifests/transcribe_groq.json | 9 + systemd/biliup-next-api.service.template | 19 + systemd/biliup-next-worker.service.template | 18 + 127 files changed, 15582 insertions(+) create mode 100644 .gitignore create mode 100644 DELIVERY.md create mode 100644 README.md create mode 100644 config/settings.json create mode 100644 config/settings.schema.json create mode 100644 config/settings.standalone.example.json create mode 100644 docs/adr/0001-modular-monolith.md create mode 100644 docs/api/openapi.yaml create mode 100644 docs/architecture.md create mode 100644 docs/config-system.md create mode 100644 docs/control-plane-guide.md create mode 100644 docs/design-principles.md create mode 100644 docs/domain-model.md create mode 100644 docs/migration-plan.md create mode 100644 docs/module-contracts.md create mode 100644 docs/plugin-system.md create mode 100644 docs/state-machine.md create mode 100644 docs/vision.md create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/components/LogsPanel.jsx create mode 100644 frontend/src/components/OverviewPanel.jsx create mode 100644 frontend/src/components/SettingsPanel.jsx create mode 100644 frontend/src/components/StatusBadge.jsx create mode 100644 frontend/src/components/TaskDetailCard.jsx create mode 100644 frontend/src/components/TaskTable.jsx create mode 100644 frontend/src/lib/format.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/vite.config.mjs create mode 100755 install-systemd.sh create mode 100644 pyproject.toml create mode 100755 run-api.sh create mode 100755 run-worker.sh create mode 100644 runtime/README.md create mode 100755 setup.sh create mode 100755 smoke-test.sh create mode 100644 src/biliup_next.egg-info/PKG-INFO create mode 100644 src/biliup_next.egg-info/SOURCES.txt create mode 100644 src/biliup_next.egg-info/dependency_links.txt create mode 100644 src/biliup_next.egg-info/entry_points.txt create mode 100644 src/biliup_next.egg-info/requires.txt create mode 100644 src/biliup_next.egg-info/top_level.txt create mode 100644 src/biliup_next/__init__.py create mode 100644 src/biliup_next/app/api_server.py create mode 100644 src/biliup_next/app/bootstrap.py create mode 100644 src/biliup_next/app/cli.py create mode 100644 src/biliup_next/app/dashboard.py create mode 100644 src/biliup_next/app/retry_meta.py create mode 100644 src/biliup_next/app/scheduler.py create mode 100644 src/biliup_next/app/static/app/actions.js create mode 100644 src/biliup_next/app/static/app/api.js create mode 100644 src/biliup_next/app/static/app/components/artifact-list.js create mode 100644 src/biliup_next/app/static/app/components/doctor-check-list.js create mode 100644 src/biliup_next/app/static/app/components/history-list.js create mode 100644 src/biliup_next/app/static/app/components/modules-list.js create mode 100644 src/biliup_next/app/static/app/components/overview-runtime.js create mode 100644 src/biliup_next/app/static/app/components/overview-task-summary.js create mode 100644 src/biliup_next/app/static/app/components/recent-actions-list.js create mode 100644 src/biliup_next/app/static/app/components/retry-banner.js create mode 100644 src/biliup_next/app/static/app/components/service-list.js create mode 100644 src/biliup_next/app/static/app/components/step-list.js create mode 100644 src/biliup_next/app/static/app/components/task-hero.js create mode 100644 src/biliup_next/app/static/app/components/timeline-list.js create mode 100644 src/biliup_next/app/static/app/main.js create mode 100644 src/biliup_next/app/static/app/render.js create mode 100644 src/biliup_next/app/static/app/router.js create mode 100644 src/biliup_next/app/static/app/state.js create mode 100644 src/biliup_next/app/static/app/utils.js create mode 100644 src/biliup_next/app/static/app/views/logs.js create mode 100644 src/biliup_next/app/static/app/views/overview.js create mode 100644 src/biliup_next/app/static/app/views/settings.js create mode 100644 src/biliup_next/app/static/app/views/tasks.js create mode 100644 src/biliup_next/app/static/dashboard.css create mode 100644 src/biliup_next/app/static/dashboard.js create mode 100644 src/biliup_next/app/task_actions.py create mode 100644 src/biliup_next/app/task_audit.py create mode 100644 src/biliup_next/app/task_engine.py create mode 100644 src/biliup_next/app/task_policies.py create mode 100644 src/biliup_next/app/task_runner.py create mode 100644 src/biliup_next/app/worker.py create mode 100644 src/biliup_next/core/config.py create mode 100644 src/biliup_next/core/errors.py create mode 100644 src/biliup_next/core/models.py create mode 100644 src/biliup_next/core/providers.py create mode 100644 src/biliup_next/core/registry.py create mode 100644 src/biliup_next/infra/adapters/bilibili_collection_legacy.py create mode 100644 src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py create mode 100644 src/biliup_next/infra/adapters/biliup_publish_legacy.py create mode 100644 src/biliup_next/infra/adapters/codex_legacy.py create mode 100644 src/biliup_next/infra/adapters/ffmpeg_split_legacy.py create mode 100644 src/biliup_next/infra/adapters/full_video_locator.py create mode 100644 src/biliup_next/infra/adapters/groq_legacy.py create mode 100644 src/biliup_next/infra/comment_flag_migration.py create mode 100644 src/biliup_next/infra/db.py create mode 100644 src/biliup_next/infra/legacy_asset_sync.py create mode 100644 src/biliup_next/infra/legacy_paths.py create mode 100644 src/biliup_next/infra/log_reader.py create mode 100644 src/biliup_next/infra/plugin_loader.py create mode 100644 src/biliup_next/infra/runtime_doctor.py create mode 100644 src/biliup_next/infra/stage_importer.py create mode 100644 src/biliup_next/infra/storage_guard.py create mode 100644 src/biliup_next/infra/systemd_runtime.py create mode 100644 src/biliup_next/infra/task_repository.py create mode 100644 src/biliup_next/infra/task_reset.py create mode 100644 src/biliup_next/infra/workspace_cleanup.py create mode 100644 src/biliup_next/modules/collection/service.py create mode 100644 src/biliup_next/modules/comment/service.py create mode 100644 src/biliup_next/modules/ingest/providers/local_file.py create mode 100644 src/biliup_next/modules/ingest/service.py create mode 100644 src/biliup_next/modules/publish/service.py create mode 100644 src/biliup_next/modules/song_detect/service.py create mode 100644 src/biliup_next/modules/split/service.py create mode 100644 src/biliup_next/modules/transcribe/service.py create mode 100644 src/biliup_next/plugins/manifests/collection_bilibili_collection.json create mode 100644 src/biliup_next/plugins/manifests/comment_bilibili_top_comment.json create mode 100644 src/biliup_next/plugins/manifests/ingest_local_file.json create mode 100644 src/biliup_next/plugins/manifests/publish_biliup_cli.json create mode 100644 src/biliup_next/plugins/manifests/song_detect_codex.json create mode 100644 src/biliup_next/plugins/manifests/split_ffmpeg_copy.json create mode 100644 src/biliup_next/plugins/manifests/transcribe_groq.json create mode 100644 systemd/biliup-next-api.service.template create mode 100644 systemd/biliup-next-worker.service.template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76a4955 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/DELIVERY.md b/DELIVERY.md new file mode 100644 index 0000000..266e409 --- /dev/null +++ b/DELIVERY.md @@ -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,本地可用,但不等于完整权限系统 diff --git a/README.md b/README.md new file mode 100644 index 0000000..712f167 --- /dev/null +++ b/README.md @@ -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` diff --git a/config/settings.json b/config/settings.json new file mode 100644 index 0000000..c38b8df --- /dev/null +++ b/config/settings.json @@ -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 + } +} diff --git a/config/settings.schema.json b/config/settings.schema.json new file mode 100644 index 0000000..58dc983 --- /dev/null +++ b/config/settings.schema.json @@ -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 目录中的纯享切片视频。" + } + } + } +} diff --git a/config/settings.standalone.example.json b/config/settings.standalone.example.json new file mode 100644 index 0000000..460f420 --- /dev/null +++ b/config/settings.standalone.example.json @@ -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 + } +} diff --git a/docs/adr/0001-modular-monolith.md b/docs/adr/0001-modular-monolith.md new file mode 100644 index 0000000..39f33dd --- /dev/null +++ b/docs/adr/0001-modular-monolith.md @@ -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 作为主状态存储 +- 是否引入事件总线 +- 插件机制如何注册 +- 管理台采用什么技术栈 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..69e7923 --- /dev/null +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e1f20fe --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/config-system.md b/docs/config-system.md new file mode 100644 index 0000000..ec6b614 --- /dev/null +++ b/docs/config-system.md @@ -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 和代码维护两套不同的字段定义 diff --git a/docs/control-plane-guide.md b/docs/control-plane-guide.md new file mode 100644 index 0000000..a5e7109 --- /dev/null +++ b/docs/control-plane-guide.md @@ -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。 diff --git a/docs/design-principles.md b/docs/design-principles.md new file mode 100644 index 0000000..6af3e15 --- /dev/null +++ b/docs/design-principles.md @@ -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 和稳定领域模型之上 diff --git a/docs/domain-model.md b/docs/domain-model.md new file mode 100644 index 0000000..ba6e2d9 --- /dev/null +++ b/docs/domain-model.md @@ -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` 之前不能进入识歌步骤 diff --git a/docs/migration-plan.md b/docs/migration-plan.md new file mode 100644 index 0000000..172c015 --- /dev/null +++ b/docs/migration-plan.md @@ -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 可以完整观察任务 +- 旧脚本不再是主入口 diff --git a/docs/module-contracts.md b/docs/module-contracts.md new file mode 100644 index 0000000..6462580 --- /dev/null +++ b/docs/module-contracts.md @@ -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 命令细节 diff --git a/docs/plugin-system.md b/docs/plugin-system.md new file mode 100644 index 0000000..4f2c859 --- /dev/null +++ b/docs/plugin-system.md @@ -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 管理 +- 等边界稳定后,再考虑开放外部插件目录 diff --git a/docs/state-machine.md b/docs/state-machine.md new file mode 100644 index 0000000..99aa9ca --- /dev/null +++ b/docs/state-machine.md @@ -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 文件作为权威状态来源 diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..eb11222 --- /dev/null +++ b/docs/vision.md @@ -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. 逐步切换生产流量,最终替换旧脚本体系。 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..4952a4b --- /dev/null +++ b/frontend/README.md @@ -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/` 入口 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2814bf3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + biliup-next Frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2d3c008 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1815 @@ +{ + "name": "biliup-next-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "biliup-next-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7ca172a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..f2a2c45 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+

{title}

+

{description}

+
+ ); +} + +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 ( +
+
+
+
+

Tasks Workspace

+

Task Table

+
+
{loading ? "syncing..." : `${filtered.length} visible`}
+
+
+ setSearch(event.target.value)} + placeholder="搜索任务标题或 task id" + /> + + + + +
+ +
+ +
+ ); +} + +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 ( + { + 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 ( + { + 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 ( + { + 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 ( + 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 ( +
+ +
+
+
+

Migration Workspace

+

{view}

+
+
+ API {health ? "ok" : "down"} + Doctor {doctorOk ? "ready" : "warn"} + {tasks.length} tasks +
+
+ {banner ?
{banner.text}
: null} + {currentView} +
+
+ ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..a61d758 --- /dev/null +++ b/frontend/src/api/client.js @@ -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; +} diff --git a/frontend/src/components/LogsPanel.jsx b/frontend/src/components/LogsPanel.jsx new file mode 100644 index 0000000..dcb0dc0 --- /dev/null +++ b/frontend/src/components/LogsPanel.jsx @@ -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 ( +
+
+
+
+

Logs Workspace

+

Log Index

+
+
{visibleLogs.length} logs
+
+
+ setSearch(event.target.value)} placeholder="搜索日志文件" /> +
+
+ {visibleLogs.map((item) => ( + + ))} + {!visibleLogs.length ?

{loading ? "loading..." : "暂无日志文件"}

: null} +
+
+ +
+
+
+

Log Detail

+

{selectedLogName || "选择一个日志"}

+
+ +
+
+ setLineFilter(event.target.value)} placeholder="过滤日志行内容" /> + + +
+
{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}
+
+
+ ); +} diff --git a/frontend/src/components/OverviewPanel.jsx b/frontend/src/components/OverviewPanel.jsx new file mode 100644 index 0000000..795e00e --- /dev/null +++ b/frontend/src/components/OverviewPanel.jsx @@ -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 ( +
+ {label} + {value} + {tone ? {tone} : null} +
+ ); +} + +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 ( +
+
+ + + +
+ +
+
+
+

Import To Stage

+
+
+ setStageSourcePath(event.target.value)} + placeholder="/absolute/path/to/video.mp4" + /> + +
+
+ setStageFile(event.target.files?.[0] || null)} + /> + +
+

只会导入到 `biliup-next/data/workspace/stage/`,不会移动原文件。

+
+ +
+
+

Runtime Services

+ +
+
+ {serviceItems.map((service) => ( +
+
+ {service.id} +
{service.description}
+
+
+ {service.active_state} + + + +
+
+ ))} + {!serviceItems.length ?

{loading ? "loading..." : "暂无服务数据"}

: null} +
+
+ +
+
+

Scheduler Queue

+ +
+
+
scheduled{scheduled.length}
+
deferred{deferred.length}
+
scanned{scheduler?.summary?.scanned_count ?? 0}
+
truncated{scheduler?.summary?.truncated_count ?? 0}
+
+
+
+ +
+
+

Attention Summary

+
+ {Object.entries(attentionCounts).map(([key, count]) => ( +
+ {attentionLabel(key)} + {count} +
+ ))} + {!Object.keys(attentionCounts).length ?

暂无任务摘要

: null} +
+
+ +
+
+

Recent Actions

+ +
+
+ {actionItems.slice(0, 8).map((item) => ( +
+ {item.action_name} + {item.status} +
+ ))} + {!actionItems.length ?

{loading ? "loading..." : "暂无动作记录"}

: null} +
+
+
+
+ ); +} diff --git a/frontend/src/components/SettingsPanel.jsx b/frontend/src/components/SettingsPanel.jsx new file mode 100644 index 0000000..f5e5520 --- /dev/null +++ b/frontend/src/components/SettingsPanel.jsx @@ -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 ( + onChange(groupName, fieldName, event.target.checked)} + /> + ); + } + if (Array.isArray(schema.enum)) { + return ( + + ); + } + if (schema.type === "array") { + return ( + + +

敏感字段显示为 `__BILIUP_NEXT_SECRET__`。保留占位符表示不改原值,改为空字符串表示清空。

+ + + +
+
+
+

Log Index

+
+ + + +
+
+ +
+
正在加载日志索引…
+
+
+
+
+

Log Content

+
+ +
-
+
-
+
+

+              
+
+

Logs Guide

+
+
+ 优先看当前任务 +
勾选“按当前任务标题过滤”,可快速聚焦任务链路。
+
+
+ 先看系统,再看任务 +
如果服务异常,先看 `systemd` 状态;如果单任务异常,再看 steps/history/timeline。
+
+
+ 上传异常 +
优先看 `upload.log`、任务时间线里的 publish error,以及下一次重试时间。
+
+
+
+
+
+
+ + + + + +""" diff --git a/src/biliup_next/app/retry_meta.py b/src/biliup_next/app/retry_meta.py new file mode 100644 index 0000000..44a6402 --- /dev/null +++ b/src/biliup_next/app/retry_meta.py @@ -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(), + } diff --git a/src/biliup_next/app/scheduler.py b/src/biliup_next/app/scheduler.py new file mode 100644 index 0000000..58fae33 --- /dev/null +++ b/src/biliup_next/app/scheduler.py @@ -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"]) diff --git a/src/biliup_next/app/static/app/actions.js b/src/biliup_next/app/static/app/actions.js new file mode 100644 index 0000000..43ea6bf --- /dev/null +++ b/src/biliup_next/app/static/app/actions.js @@ -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"); + } + }; +} diff --git a/src/biliup_next/app/static/app/api.js b/src/biliup_next/app/static/app/api.js new file mode 100644 index 0000000..a6c0509 --- /dev/null +++ b/src/biliup_next/app/static/app/api.js @@ -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 }; +} diff --git a/src/biliup_next/app/static/app/components/artifact-list.js b/src/biliup_next/app/static/app/components/artifact-list.js new file mode 100644 index 0000000..4df0078 --- /dev/null +++ b/src/biliup_next/app/static/app/components/artifact-list.js @@ -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 = ` +
${escapeHtml(artifact.artifact_type)}
+
${escapeHtml(artifact.path)}
+
${escapeHtml(formatDate(artifact.created_at))}
+ `; + artifactWrap.appendChild(row); + }); +} diff --git a/src/biliup_next/app/static/app/components/doctor-check-list.js b/src/biliup_next/app/static/app/components/doctor-check-list.js new file mode 100644 index 0000000..20fad3f --- /dev/null +++ b/src/biliup_next/app/static/app/components/doctor-check-list.js @@ -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 = ` +
${escapeHtml(check.name)}${check.ok ? "ok" : "fail"}
+
${escapeHtml(check.detail)}
+ `; + wrap.appendChild(row); + } +} diff --git a/src/biliup_next/app/static/app/components/history-list.js b/src/biliup_next/app/static/app/components/history-list.js new file mode 100644 index 0000000..bf7070d --- /dev/null +++ b/src/biliup_next/app/static/app/components/history-list.js @@ -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 = ` +
${escapeHtml(item.action_name)}${escapeHtml(item.status)}
+
${escapeHtml(item.summary)}
+
${escapeHtml(formatDate(item.created_at))}
+
${escapeHtml(details)}
+ `; + historyWrap.appendChild(row); + }); +} diff --git a/src/biliup_next/app/static/app/components/modules-list.js b/src/biliup_next/app/static/app/components/modules-list.js new file mode 100644 index 0000000..d8b33d7 --- /dev/null +++ b/src/biliup_next/app/static/app/components/modules-list.js @@ -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 = ` +
${escapeHtml(item.id)}${escapeHtml(item.provider_type)}
+
${escapeHtml(item.entrypoint)}
+ `; + wrap.appendChild(row); + } +} diff --git a/src/biliup_next/app/static/app/components/overview-runtime.js b/src/biliup_next/app/static/app/components/overview-runtime.js new file mode 100644 index 0000000..1f81047 --- /dev/null +++ b/src/biliup_next/app/static/app/components/overview-runtime.js @@ -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; +} diff --git a/src/biliup_next/app/static/app/components/overview-task-summary.js b/src/biliup_next/app/static/app/components/overview-task-summary.js new file mode 100644 index 0000000..f66129e --- /dev/null +++ b/src/biliup_next/app/static/app/components/overview-task-summary.js @@ -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 = ` +
+ Waiting Retry +
${waitingRetry.length} 个任务正在等待下一次重试
+
+
+ Retry Due +
${dueRetry.length} 个任务已到重试时间
+
+
+ Manual Attention +
${failedManual.length} 个任务需要人工处理
+
+ `; +} diff --git a/src/biliup_next/app/static/app/components/recent-actions-list.js b/src/biliup_next/app/static/app/components/recent-actions-list.js new file mode 100644 index 0000000..278d71c --- /dev/null +++ b/src/biliup_next/app/static/app/components/recent-actions-list.js @@ -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 = ` +
+ ${escapeHtml(item.action_name)} + ${escapeHtml(item.status)} +
+
${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}
+
${escapeHtml(formatDate(item.created_at))}
+ `; + wrap.appendChild(row); + } +} diff --git a/src/biliup_next/app/static/app/components/retry-banner.js b/src/biliup_next/app/static/app/components/retry-banner.js new file mode 100644 index 0000000..4486cd3 --- /dev/null +++ b/src/biliup_next/app/static/app/components/retry-banner.js @@ -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 = ` + ${escapeHtml(retry.step_name)} + ${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"} +
next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}
+ `; +} diff --git a/src/biliup_next/app/static/app/components/service-list.js b/src/biliup_next/app/static/app/components/service-list.js new file mode 100644 index 0000000..195302d --- /dev/null +++ b/src/biliup_next/app/static/app/components/service-list.js @@ -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 = ` +
+ ${escapeHtml(item.id)} + ${escapeHtml(item.active_state)} + ${escapeHtml(item.sub_state)} +
+
${escapeHtml(item.fragment_path || item.description || "")}
+
+ + + +
+ `; + wrap.appendChild(row); + } + wrap.querySelectorAll("button[data-service]").forEach((btn) => { + btn.onclick = () => onServiceAction(btn.dataset.service, btn.dataset.action); + }); +} diff --git a/src/biliup_next/app/static/app/components/step-list.js b/src/biliup_next/app/static/app/components/step-list.js new file mode 100644 index 0000000..6f1a621 --- /dev/null +++ b/src/biliup_next/app/static/app/components/step-list.js @@ -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 ? ` +
+
Next Retry ${escapeHtml(formatDate(step.next_retry_at))}
+
Remaining ${escapeHtml(formatDuration(step.retry_remaining_seconds))}
+
Wait Policy ${escapeHtml(formatDuration(step.retry_wait_seconds))}
+
+ ` : ""; + row.innerHTML = ` +
+ ${escapeHtml(step.step_name)} + ${escapeHtml(step.status)} + retry ${step.retry_count} +
+
${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}
+
+
Started ${escapeHtml(formatDate(step.started_at))}
+
Finished ${escapeHtml(formatDate(step.finished_at))}
+
+ ${retryBlock} + `; + row.onclick = () => onStepSelect(step.step_name); + stepWrap.appendChild(row); + }); +} diff --git a/src/biliup_next/app/static/app/components/task-hero.js b/src/biliup_next/app/static/app/components/task-hero.js new file mode 100644 index 0000000..ee65929 --- /dev/null +++ b/src/biliup_next/app/static/app/components/task-hero.js @@ -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 = ` +
${escapeHtml(task.title)}
+
${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}
+
+
Task Status
${escapeHtml(task.status)}
+
Succeeded Steps
${succeeded}/${steps.items.length}
+
Running / Failed
${running} / ${failed}
+
+
+ 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"} +
+ `; +} diff --git a/src/biliup_next/app/static/app/components/timeline-list.js b/src/biliup_next/app/static/app/components/timeline-list.js new file mode 100644 index 0000000..0b4edbe --- /dev/null +++ b/src/biliup_next/app/static/app/components/timeline-list.js @@ -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 + ? `
Next Retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}
` + : ""; + const row = document.createElement("div"); + row.className = "timeline-card"; + row.innerHTML = ` +
${escapeHtml(item.title)}${escapeHtml(item.status)}${escapeHtml(item.kind)}
+
+
${escapeHtml(item.summary || "-")}
+
Time ${escapeHtml(formatDate(item.time))}
+ ${retryNote} +
+ `; + timelineWrap.appendChild(row); + }); +} diff --git a/src/biliup_next/app/static/app/main.js b/src/biliup_next/app/static/app/main.js new file mode 100644 index 0000000..3503f97 --- /dev/null +++ b/src/biliup_next/app/static/app/main.js @@ -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")); diff --git a/src/biliup_next/app/static/app/render.js b/src/biliup_next/app/static/app/render.js new file mode 100644 index 0000000..395e874 --- /dev/null +++ b/src/biliup_next/app/static/app/render.js @@ -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"; +} diff --git a/src/biliup_next/app/static/app/router.js b/src/biliup_next/app/static/app/router.js new file mode 100644 index 0000000..2717f2e --- /dev/null +++ b/src/biliup_next/app/static/app/router.js @@ -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(); +} diff --git a/src/biliup_next/app/static/app/state.js b/src/biliup_next/app/static/app/state.js new file mode 100644 index 0000000..e5e3323 --- /dev/null +++ b/src/biliup_next/app/static/app/state.js @@ -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; +} diff --git a/src/biliup_next/app/static/app/utils.js b/src/biliup_next/app/static/app/utils.js new file mode 100644 index 0000000..0327edf --- /dev/null +++ b/src/biliup_next/app/static/app/utils.js @@ -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}`; +} diff --git a/src/biliup_next/app/static/app/views/logs.js b/src/biliup_next/app/static/app/views/logs.js new file mode 100644 index 0000000..e697046 --- /dev/null +++ b/src/biliup_next/app/static/app/views/logs.js @@ -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 = ` +
${escapeHtml(item.name)}
+
${escapeHtml(item.path || "")}
+ `; + 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; +} diff --git a/src/biliup_next/app/static/app/views/overview.js b/src/biliup_next/app/static/app/views/overview.js new file mode 100644 index 0000000..cb85479 --- /dev/null +++ b/src/biliup_next/app/static/app/views/overview.js @@ -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 = ` + Scheduler Strategy +
max_tasks_per_cycle=${escapeHtml(String(strategy.max_tasks_per_cycle ?? "-"))}, candidate_scan_limit=${escapeHtml(String(strategy.candidate_scan_limit ?? "-"))}
+
prioritize_retry_due=${escapeHtml(String(strategy.prioritize_retry_due ?? "-"))}, oldest_first=${escapeHtml(String(strategy.oldest_first ?? "-"))}
+
status_priority=${escapeHtml((strategy.status_priority || []).join(" > ") || "-")}
+ `; + list.appendChild(strategyRow); + + const skipped = summaryData.skipped_counts || {}; + const skippedRow = document.createElement("div"); + skippedRow.className = "row-card"; + skippedRow.innerHTML = ` + Unscheduled Reasons +
failed_manual=${escapeHtml(String(skipped.failed_manual || 0))}
+
no_runnable_step=${escapeHtml(String(skipped.no_runnable_step || 0))}
+ `; + 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 = `当前无排队任务
scheduler 本轮没有挑出需要执行或等待重试的任务。
`; + list.appendChild(empty); + } else { + items.slice(0, 12).forEach((item) => { + const row = document.createElement("div"); + row.className = "row-card"; + row.innerHTML = ` +
+ ${escapeHtml(item.task_id)} + ${escapeHtml(item.queue)} + ${item.step_name ? `${escapeHtml(item.step_name)}` : ""} + ${item.task_status ? `${escapeHtml(item.task_status)}` : ""} +
+
${escapeHtml(item.reason || (item.waiting_for_retry ? "waiting_for_retry" : "-"))}
+ ${item.remaining_seconds != null ? `
remaining ${escapeHtml(String(item.remaining_seconds))}s
` : ""} + `; + 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 = `${escapeHtml(label)}
${escapeHtml(String(value))}
`; + stageScan.appendChild(row); + }); +} diff --git a/src/biliup_next/app/static/app/views/settings.js b/src/biliup_next/app/static/app/views/settings.js new file mode 100644 index 0000000..480479d --- /dev/null +++ b/src/biliup_next/app/static/app/views/settings.js @@ -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 = "Advanced Settings"; + 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 = `

${escapeHtml(state.currentSettingsSchema.group_ui?.[groupName]?.title || groupName)}

`; + 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 = `
没有匹配的配置项
调整搜索关键字后重试。
`; + return; + } + + if (featuredContainer.children.length) wrap.appendChild(featuredContainer); + if (advancedContainer.children.length) { + advancedDetails.appendChild(advancedContainer); + wrap.appendChild(advancedDetails); + } + syncSettingsEditorFromState(); +} diff --git a/src/biliup_next/app/static/app/views/tasks.js b/src/biliup_next/app/static/app/views/tasks.js new file mode 100644 index 0000000..cfb553b --- /dev/null +++ b/src/biliup_next/app/static/app/views/tasks.js @@ -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 ``; +} + +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 = `
正在加载任务表…
`; + 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 = ` + + + ${headerLabel("任务", "title", sort)} + ${headerLabel("状态", "status", sort)} + ${headerLabel("关注", "attention", sort)} + ${headerLabel("纯享评论", "split_comment", sort)} + ${headerLabel("主视频评论", "full_comment", sort)} + ${headerLabel("清理", "cleanup", sort)} + ${headerLabel("下次重试", "next_retry", sort)} + ${headerLabel("更新时间", "updated", sort)} + 快捷操作 + + + + `; + 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 = ` + +
${escapeHtml(item.title)}
+
${escapeHtml(item.id)}
+ + ${escapeHtml(displayStatus(item.status))} + ${escapeHtml(displayAttention(attention))} + ${escapeHtml(displayDelivery(delivery.split_comment || "-"))} + ${escapeHtml(displayDelivery(delivery.full_video_timeline_comment || "-"))} + ${escapeHtml(displayDelivery(cleanupState(delivery)))} + + ${item.retry_state?.next_retry_at ? `
${escapeHtml(formatDate(item.retry_state.next_retry_at))}
` : `-`} + ${item.retry_state?.retry_remaining_seconds != null ? `
${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}
` : ""} + + +
${escapeHtml(formatDate(item.updated_at))}
+ ${item.retry_state?.next_retry_at ? `
retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))}
` : ""} + + + + + + `; + 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 = ` +
Recent Result
+
${escapeHtml(summaryText)}
+
Delivery State
+
+ ${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"}`, + "" + )} +
+ `; + + renderStepList(steps, onStepSelect); + renderArtifactList(artifacts); + renderHistoryList(history); + renderTimelineList(timeline); +} + +function renderDeliveryState(label, value, forcedClass = null) { + const klass = forcedClass === null ? statusClass(value) : forcedClass; + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(String(value))}
+
+ `; +} + +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 = ""; +} diff --git a/src/biliup_next/app/static/dashboard.css b/src/biliup_next/app/static/dashboard.css new file mode 100644 index 0000000..22622b2 --- /dev/null +++ b/src/biliup_next/app/static/dashboard.css @@ -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; + } +} diff --git a/src/biliup_next/app/static/dashboard.js b/src/biliup_next/app/static/dashboard.js new file mode 100644 index 0000000..eacaa69 --- /dev/null +++ b/src/biliup_next/app/static/dashboard.js @@ -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 = `
没有匹配任务
调整搜索、状态或排序条件后重试。
`; + 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 = ` +
${escapeHtml(item.title)}
+
+ ${escapeHtml(item.status)} + ${escapeHtml(item.id)} +
+
${escapeHtml(formatDate(item.updated_at))}
+ ${retryText ? `
${escapeHtml(retryText)}
` : ""} + `; + 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 = ` +
${escapeHtml(task.title)}
+
${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}
+
+
+
Task Status
+
${escapeHtml(task.status)}
+
+
+
Succeeded Steps
+
${succeeded}/${steps.items.length}
+
+
+
Running / Failed
+
${running} / ${failed}
+
+
+ `; +} + +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 = ` + ${escapeHtml(retry.step_name)} + ${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"} +
next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}
+ `; +} + +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 ? ` +
+
Next Retry ${escapeHtml(formatDate(step.next_retry_at))}
+
Remaining ${escapeHtml(formatDuration(step.retry_remaining_seconds))}
+
Wait Policy ${escapeHtml(formatDuration(step.retry_wait_seconds))}
+
+ ` : ""; + row.innerHTML = ` +
+ ${escapeHtml(step.step_name)} + ${escapeHtml(step.status)} + retry ${step.retry_count} +
+
${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}
+
+
Started ${escapeHtml(formatDate(step.started_at))}
+
Finished ${escapeHtml(formatDate(step.finished_at))}
+
+ ${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 = ` +
${escapeHtml(artifact.artifact_type)}
+
${escapeHtml(artifact.path)}
+
${escapeHtml(formatDate(artifact.created_at))}
+ `; + 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 = ` +
+ ${escapeHtml(item.action_name)} + ${escapeHtml(item.status)} +
+
${escapeHtml(item.summary)}
+
${escapeHtml(formatDate(item.created_at))}
+
${escapeHtml(details)}
+ `; + historyWrap.appendChild(row); + } + + const timelineWrap = document.getElementById("timelineList"); + timelineWrap.innerHTML = ""; + for (const item of timeline.items) { + const retryNote = item.retry_state?.next_retry_at + ? `
Next Retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}
` + : ""; + const row = document.createElement("div"); + row.className = "timeline-card"; + row.innerHTML = ` +
+ ${escapeHtml(item.title)} + ${escapeHtml(item.status)} + ${escapeHtml(item.kind)} +
+
+
${escapeHtml(item.summary || "-")}
+
Time ${escapeHtml(formatDate(item.time))}
+ ${retryNote} +
+ `; + 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 = `
没有匹配的配置项
调整搜索关键字后重试。
`; + 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 = ` +
+ ${escapeHtml(item.action_name)} + ${escapeHtml(item.status)} +
+
${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}
+
${escapeHtml(formatDate(item.created_at))}
+ `; + 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 = ` +
${escapeHtml(item.id)}${escapeHtml(item.provider_type)}
+
${escapeHtml(item.entrypoint)}
+ `; + 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 = ` +
${escapeHtml(check.name)}${check.ok ? "ok" : "fail"}
+
${escapeHtml(check.detail)}
+ `; + 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 = ` +
+ ${escapeHtml(item.id)} + ${escapeHtml(item.active_state)} + ${escapeHtml(item.sub_state)} +
+
${escapeHtml(item.fragment_path || item.description || "")}
+
+ + + +
+ `; + 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")); diff --git a/src/biliup_next/app/task_actions.py b/src/biliup_next/app/task_actions.py new file mode 100644 index 0000000..c7804cf --- /dev/null +++ b/src/biliup_next/app/task_actions.py @@ -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 diff --git a/src/biliup_next/app/task_audit.py b/src/biliup_next/app/task_audit.py new file mode 100644 index 0000000..0a71069 --- /dev/null +++ b/src/biliup_next/app/task_audit.py @@ -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(), + ) + ) diff --git a/src/biliup_next/app/task_engine.py b/src/biliup_next/app/task_engine.py new file mode 100644 index 0000000..93c3bb0 --- /dev/null +++ b/src/biliup_next/app/task_engine.py @@ -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}") diff --git a/src/biliup_next/app/task_policies.py b/src/biliup_next/app/task_policies.py new file mode 100644 index 0000000..34fe65f --- /dev/null +++ b/src/biliup_next/app/task_policies.py @@ -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, + } diff --git a/src/biliup_next/app/task_runner.py b/src/biliup_next/app/task_runner.py new file mode 100644 index 0000000..a488fdb --- /dev/null +++ b/src/biliup_next/app/task_runner.py @@ -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} diff --git a/src/biliup_next/app/worker.py b/src/biliup_next/app/worker.py new file mode 100644 index 0000000..3417725 --- /dev/null +++ b/src/biliup_next/app/worker.py @@ -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) diff --git a/src/biliup_next/core/config.py b/src/biliup_next/core/config.py new file mode 100644 index 0000000..45f5a81 --- /dev/null +++ b/src/biliup_next/core/config.py @@ -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 diff --git a/src/biliup_next/core/errors.py b/src/biliup_next/core/errors.py new file mode 100644 index 0000000..6fab874 --- /dev/null +++ b/src/biliup_next/core/errors.py @@ -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) diff --git a/src/biliup_next/core/models.py b/src/biliup_next/core/models.py new file mode 100644 index 0000000..949e5fe --- /dev/null +++ b/src/biliup_next/core/models.py @@ -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) diff --git a/src/biliup_next/core/providers.py b/src/biliup_next/core/providers.py new file mode 100644 index 0000000..2faecbf --- /dev/null +++ b/src/biliup_next/core/providers.py @@ -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]: + ... diff --git a/src/biliup_next/core/registry.py b/src/biliup_next/core/registry.py new file mode 100644 index 0000000..ed55a13 --- /dev/null +++ b/src/biliup_next/core/registry.py @@ -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 diff --git a/src/biliup_next/infra/adapters/bilibili_collection_legacy.py b/src/biliup_next/infra/adapters/bilibili_collection_legacy.py new file mode 100644 index 0000000..c4a2124 --- /dev/null +++ b/src/biliup_next/infra/adapters/bilibili_collection_legacy.py @@ -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 diff --git a/src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py b/src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py new file mode 100644 index 0000000..5500856 --- /dev/null +++ b/src/biliup_next/infra/adapters/bilibili_top_comment_legacy.py @@ -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() diff --git a/src/biliup_next/infra/adapters/biliup_publish_legacy.py b/src/biliup_next/infra/adapters/biliup_publish_legacy.py new file mode 100644 index 0000000..4b85c3d --- /dev/null +++ b/src/biliup_next/infra/adapters/biliup_publish_legacy.py @@ -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) diff --git a/src/biliup_next/infra/adapters/codex_legacy.py b/src/biliup_next/infra/adapters/codex_legacy.py new file mode 100644 index 0000000..dba648f --- /dev/null +++ b/src/biliup_next/infra/adapters/codex_legacy.py @@ -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(), + ), + ) diff --git a/src/biliup_next/infra/adapters/ffmpeg_split_legacy.py b/src/biliup_next/infra/adapters/ffmpeg_split_legacy.py new file mode 100644 index 0000000..fe8dc31 --- /dev/null +++ b/src/biliup_next/infra/adapters/ffmpeg_split_legacy.py @@ -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 diff --git a/src/biliup_next/infra/adapters/full_video_locator.py b/src/biliup_next/infra/adapters/full_video_locator.py new file mode 100644 index 0000000..40197e9 --- /dev/null +++ b/src/biliup_next/infra/adapters/full_video_locator.py @@ -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 diff --git a/src/biliup_next/infra/adapters/groq_legacy.py b/src/biliup_next/infra/adapters/groq_legacy.py new file mode 100644 index 0000000..6addea6 --- /dev/null +++ b/src/biliup_next/infra/adapters/groq_legacy.py @@ -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" diff --git a/src/biliup_next/infra/comment_flag_migration.py b/src/biliup_next/infra/comment_flag_migration.py new file mode 100644 index 0000000..7f175be --- /dev/null +++ b/src/biliup_next/infra/comment_flag_migration.py @@ -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, + } diff --git a/src/biliup_next/infra/db.py b/src/biliup_next/infra/db.py new file mode 100644 index 0000000..4a2a7c2 --- /dev/null +++ b/src/biliup_next/infra/db.py @@ -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() diff --git a/src/biliup_next/infra/legacy_asset_sync.py b/src/biliup_next/infra/legacy_asset_sync.py new file mode 100644 index 0000000..5f60760 --- /dev/null +++ b/src/biliup_next/infra/legacy_asset_sync.py @@ -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, + } diff --git a/src/biliup_next/infra/legacy_paths.py b/src/biliup_next/infra/legacy_paths.py new file mode 100644 index 0000000..ede257f --- /dev/null +++ b/src/biliup_next/infra/legacy_paths.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from pathlib import Path + + +def legacy_project_root(next_root: Path) -> Path: + return next_root.parent diff --git a/src/biliup_next/infra/log_reader.py b/src/biliup_next/infra/log_reader.py new file mode 100644 index 0000000..2da39a1 --- /dev/null +++ b/src/biliup_next/infra/log_reader.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +ALLOWED_LOG_FILES = { + "monitor.log": Path("/home/theshy/biliup/logs/system/monitor.log"), + "monitorSrt.log": Path("/home/theshy/biliup/logs/system/monitorSrt.log"), + "monitorSongs.log": Path("/home/theshy/biliup/logs/system/monitorSongs.log"), + "upload.log": Path("/home/theshy/biliup/logs/system/upload.log"), + "session_top_comment.py.log": Path("/home/theshy/biliup/logs/system/session_top_comment.py.log"), + "add_to_collection.py.log": Path("/home/theshy/biliup/logs/system/add_to_collection.py.log"), +} + + +class LogReader: + def list_logs(self) -> dict[str, object]: + return { + "items": [ + { + "name": name, + "path": str(path), + "exists": path.exists(), + } + for name, path in sorted(ALLOWED_LOG_FILES.items()) + ] + } + + def tail(self, name: str, lines: int = 200, contains: str | None = None) -> dict[str, object]: + if name not in ALLOWED_LOG_FILES: + raise ValueError(f"unsupported log: {name}") + path = ALLOWED_LOG_FILES[name] + if not path.exists(): + return {"name": name, "path": str(path), "exists": False, "content": ""} + content = path.read_text(encoding="utf-8", errors="replace").splitlines() + if contains: + content = [line for line in content if contains in line] + return { + "name": name, + "path": str(path), + "exists": True, + "content": "\n".join(content[-max(1, min(lines, 1000)):]), + } diff --git a/src/biliup_next/infra/plugin_loader.py b/src/biliup_next/infra/plugin_loader.py new file mode 100644 index 0000000..fb79c71 --- /dev/null +++ b/src/biliup_next/infra/plugin_loader.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib +import inspect +import json +from pathlib import Path +from typing import Any + +from biliup_next.core.providers import ProviderManifest + + +class PluginLoader: + def __init__(self, root_dir: Path): + self.root_dir = root_dir + self.manifests_dir = self.root_dir / "src" / "biliup_next" / "plugins" / "manifests" + + def load_manifests(self) -> list[ProviderManifest]: + manifests: list[ProviderManifest] = [] + if not self.manifests_dir.exists(): + return manifests + for path in sorted(self.manifests_dir.glob("*.json")): + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + manifests.append( + ProviderManifest( + id=data["id"], + name=data["name"], + version=data["version"], + provider_type=data["provider_type"], + entrypoint=data["entrypoint"], + capabilities=data["capabilities"], + config_schema=data.get("config_schema"), + enabled_by_default=data.get("enabled_by_default", True), + ) + ) + return manifests + + def instantiate_provider(self, manifest: ProviderManifest) -> Any: + module_name, _, attr_name = manifest.entrypoint.partition(":") + if not module_name or not attr_name: + raise ValueError(f"invalid provider entrypoint: {manifest.entrypoint}") + module = importlib.import_module(module_name) + provider_cls = getattr(module, attr_name) + kwargs = self._build_constructor_kwargs(provider_cls) + return provider_cls(**kwargs) + + def _build_constructor_kwargs(self, provider_cls: type[Any]) -> dict[str, Any]: + signature = inspect.signature(provider_cls) + kwargs: dict[str, Any] = {} + for name, parameter in signature.parameters.items(): + if name == "self": + continue + if parameter.kind in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}: + continue + if name in {"root", "next_root", "root_dir"}: + kwargs[name] = self.root_dir + continue + if parameter.default is not inspect._empty: + continue + raise ValueError(f"unsupported provider constructor parameter: {provider_cls.__name__}.{name}") + return kwargs diff --git a/src/biliup_next/infra/runtime_doctor.py b/src/biliup_next/infra/runtime_doctor.py new file mode 100644 index 0000000..7ce16ee --- /dev/null +++ b/src/biliup_next/infra/runtime_doctor.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from biliup_next.core.config import SettingsService + + +class RuntimeDoctor: + def __init__(self, root_dir: Path): + self.root_dir = root_dir + self.settings_service = SettingsService(root_dir) + + def run(self) -> dict[str, object]: + bundle = self.settings_service.load() + settings = bundle.settings + checks: list[dict[str, object]] = [] + + for group, name in ( + ("paths", "stage_dir"), + ("paths", "backup_dir"), + ("paths", "session_dir"), + ): + path = (self.root_dir / settings[group][name]).resolve() + checks.append({"name": f"{group}.{name}", "ok": path.exists(), "detail": str(path)}) + + for group, name in ( + ("paths", "cookies_file"), + ("paths", "upload_config_file"), + ): + path = (self.root_dir / settings[group][name]).resolve() + detail = str(path) + if path.exists() and not str(path).startswith(str(self.root_dir)): + detail = f"{path} (external)" + checks.append({"name": f"{group}.{name}", "ok": path.exists(), "detail": detail}) + + for group, name in ( + ("ingest", "ffprobe_bin"), + ("transcribe", "ffmpeg_bin"), + ("song_detect", "codex_cmd"), + ("publish", "biliup_path"), + ): + value = settings[group][name] + found = shutil.which(value) if "/" not in value else str((self.root_dir / value).resolve()) + ok = bool(found) and (Path(found).exists() if "/" in str(found) else True) + detail = str(found or value) + if ok and "/" in detail and not detail.startswith(str(self.root_dir)): + detail = f"{detail} (external)" + checks.append({"name": f"{group}.{name}", "ok": ok, "detail": detail}) + + return { + "ok": all(item["ok"] for item in checks), + "checks": checks, + } diff --git a/src/biliup_next/infra/stage_importer.py b/src/biliup_next/infra/stage_importer.py new file mode 100644 index 0000000..7943fe7 --- /dev/null +++ b/src/biliup_next/infra/stage_importer.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path +from typing import BinaryIO + +from biliup_next.core.errors import ModuleError +from biliup_next.infra.storage_guard import ensure_free_space + + +class StageImporter: + def import_file(self, source_path: Path, stage_dir: Path, *, min_free_bytes: int = 0) -> dict[str, object]: + if not source_path.exists(): + raise FileNotFoundError(f"source not found: {source_path}") + if not source_path.is_file(): + raise IsADirectoryError(f"source is not a file: {source_path}") + stage_dir.mkdir(parents=True, exist_ok=True) + source_size = source_path.stat().st_size + ensure_free_space( + stage_dir, + source_size + max(0, int(min_free_bytes)), + code="STAGE_IMPORT_NO_SPACE", + message=f"stage 剩余空间不足,无法导入: {source_path.name}", + retryable=False, + details={"source_size_bytes": source_size}, + ) + target_path = stage_dir / source_path.name + if target_path.exists(): + target_path = self._unique_target(stage_dir, source_path.name) + temp_path = self._temp_target(stage_dir, target_path.name) + try: + shutil.copyfile(source_path, temp_path) + shutil.copymode(source_path, temp_path) + temp_path.replace(target_path) + except Exception: + temp_path.unlink(missing_ok=True) + raise + return { + "source_path": str(source_path.resolve()), + "target_path": str(target_path.resolve()), + } + + def import_upload(self, filename: str, fileobj: BinaryIO, stage_dir: Path, *, min_free_bytes: int = 0) -> dict[str, object]: + if not filename: + raise ValueError("missing filename") + stage_dir.mkdir(parents=True, exist_ok=True) + ensure_free_space( + stage_dir, + max(0, int(min_free_bytes)), + code="STAGE_UPLOAD_NO_SPACE", + message=f"stage 剩余空间不足,无法接收上传: {Path(filename).name}", + retryable=False, + ) + target_path = stage_dir / Path(filename).name + if target_path.exists(): + target_path = self._unique_target(stage_dir, Path(filename).name) + temp_path = self._temp_target(stage_dir, target_path.name) + try: + with temp_path.open("wb") as f: + shutil.copyfileobj(fileobj, f) + temp_path.replace(target_path) + except OSError as exc: + temp_path.unlink(missing_ok=True) + if getattr(exc, "errno", None) == 28: + raise ModuleError( + code="STAGE_UPLOAD_NO_SPACE", + message=f"stage 剩余空间不足,上传中断: {Path(filename).name}", + retryable=False, + ) from exc + raise + except Exception: + temp_path.unlink(missing_ok=True) + raise + return { + "uploaded_filename": Path(filename).name, + "target_path": str(target_path.resolve()), + } + + @staticmethod + def _unique_target(stage_dir: Path, filename: str) -> Path: + base = Path(filename).stem + suffix = Path(filename).suffix + index = 1 + while True: + candidate = stage_dir / f"{base}.{index}{suffix}" + if not candidate.exists(): + return candidate + index += 1 + + @staticmethod + def _temp_target(stage_dir: Path, filename: str) -> Path: + return stage_dir / f".{filename}.{uuid.uuid4().hex}.part" diff --git a/src/biliup_next/infra/storage_guard.py b/src/biliup_next/infra/storage_guard.py new file mode 100644 index 0000000..b6499a7 --- /dev/null +++ b/src/biliup_next/infra/storage_guard.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from biliup_next.core.errors import ModuleError + + +def mb_to_bytes(value: object) -> int: + try: + number = int(value or 0) + except (TypeError, ValueError): + number = 0 + return max(0, number) * 1024 * 1024 + + +def free_bytes_for_path(path: Path) -> int: + target = path if path.exists() else path.parent + return int(shutil.disk_usage(target).free) + + +def ensure_free_space( + path: Path, + required_free_bytes: int, + *, + code: str, + message: str, + retryable: bool, + details: dict[str, object] | None = None, +) -> None: + free_bytes = free_bytes_for_path(path) + if free_bytes >= required_free_bytes: + return + payload = { + "path": str(path), + "required_free_bytes": int(required_free_bytes), + "available_free_bytes": int(free_bytes), + } + if details: + payload.update(details) + raise ModuleError(code=code, message=message, retryable=retryable, details=payload) diff --git a/src/biliup_next/infra/systemd_runtime.py b/src/biliup_next/infra/systemd_runtime.py new file mode 100644 index 0000000..e8c2a14 --- /dev/null +++ b/src/biliup_next/infra/systemd_runtime.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import subprocess + +ALLOWED_SERVICES = { + "biliup-next-worker.service", + "biliup-next-api.service", + "biliup-python.service", +} +ALLOWED_ACTIONS = {"start", "stop", "restart"} + + +class SystemdRuntime: + def list_services(self) -> dict[str, object]: + items = [] + for service in sorted(ALLOWED_SERVICES): + items.append(self._inspect_service(service)) + return {"items": items} + + def act(self, service: str, action: str) -> dict[str, object]: + if service not in ALLOWED_SERVICES: + raise ValueError(f"unsupported service: {service}") + if action not in ALLOWED_ACTIONS: + raise ValueError(f"unsupported action: {action}") + result = subprocess.run( + ["sudo", "systemctl", action, service], + capture_output=True, + text=True, + check=False, + ) + payload = self._inspect_service(service) + payload["action"] = action + payload["command_ok"] = result.returncode == 0 + payload["stderr"] = (result.stderr or "").strip() + payload["stdout"] = (result.stdout or "").strip() + return payload + + def _inspect_service(self, service: str) -> dict[str, object]: + show = subprocess.run( + [ + "systemctl", + "show", + service, + "--property=Id,Description,LoadState,ActiveState,SubState,MainPID,ExecMainStatus,FragmentPath", + ], + capture_output=True, + text=True, + check=False, + ) + info = { + "id": service, + "description": "", + "load_state": "unknown", + "active_state": "unknown", + "sub_state": "unknown", + "main_pid": 0, + "exec_main_status": None, + "fragment_path": "", + } + for line in (show.stdout or "").splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + if key == "Id": + info["id"] = value + elif key == "Description": + info["description"] = value + elif key == "LoadState": + info["load_state"] = value + elif key == "ActiveState": + info["active_state"] = value + elif key == "SubState": + info["sub_state"] = value + elif key == "MainPID": + try: + info["main_pid"] = int(value) + except ValueError: + info["main_pid"] = 0 + elif key == "ExecMainStatus": + info["exec_main_status"] = value + elif key == "FragmentPath": + info["fragment_path"] = value + return info diff --git a/src/biliup_next/infra/task_repository.py b/src/biliup_next/infra/task_repository.py new file mode 100644 index 0000000..0867c77 --- /dev/null +++ b/src/biliup_next/infra/task_repository.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import json +from pathlib import Path +from datetime import datetime, timezone + +from biliup_next.core.models import ActionRecord, Artifact, PublishRecord, Task, TaskStep +from biliup_next.infra.db import Database + + +TASK_STATUS_ORDER = { + "created": 0, + "transcribed": 1, + "songs_detected": 2, + "split_done": 3, + "published": 4, + "commented": 5, + "collection_synced": 6, + "failed_retryable": 7, + "failed_manual": 8, +} + + +class TaskRepository: + def __init__(self, db: Database): + self.db = db + + def upsert_task(self, task: Task) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO tasks (id, source_type, source_path, title, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + source_type=excluded.source_type, + source_path=excluded.source_path, + title=excluded.title, + status=excluded.status, + updated_at=excluded.updated_at + """, + ( + task.id, + task.source_type, + task.source_path, + task.title, + task.status, + task.created_at, + task.updated_at, + ), + ) + conn.commit() + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + with self.db.connect() as conn: + conn.execute( + "UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?", + (status, updated_at, task_id), + ) + conn.commit() + + def list_tasks(self, limit: int = 100) -> list[Task]: + with self.db.connect() as conn: + rows = conn.execute( + "SELECT id, source_type, source_path, title, status, created_at, updated_at " + "FROM tasks ORDER BY updated_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [Task(**dict(row)) for row in rows] + + def get_task(self, task_id: str) -> Task | None: + with self.db.connect() as conn: + row = conn.execute( + "SELECT id, source_type, source_path, title, status, created_at, updated_at " + "FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + return Task(**dict(row)) if row else None + + def delete_task(self, task_id: str) -> None: + with self.db.connect() as conn: + conn.execute("DELETE FROM action_records WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM publish_records WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM artifacts WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM task_steps WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + conn.commit() + + def replace_steps(self, task_id: str, steps: list[TaskStep]) -> None: + with self.db.connect() as conn: + conn.execute("DELETE FROM task_steps WHERE task_id = ?", (task_id,)) + conn.executemany( + """ + INSERT INTO task_steps ( + task_id, step_name, status, error_code, error_message, + retry_count, started_at, finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + step.task_id, + step.step_name, + step.status, + step.error_code, + step.error_message, + step.retry_count, + step.started_at, + step.finished_at, + ) + for step in steps + ], + ) + conn.commit() + + def list_steps(self, task_id: str) -> list[TaskStep]: + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT id, task_id, step_name, status, error_code, error_message, + retry_count, started_at, finished_at + FROM task_steps + WHERE task_id = ? + ORDER BY id ASC + """, + (task_id,), + ).fetchall() + return [TaskStep(**dict(row)) for row in rows] + + def update_step_status( + self, + task_id: str, + step_name: str, + status: str, + *, + error_code: str | None = None, + error_message: str | None = None, + retry_count: int | None = None, + started_at: str | None = None, + finished_at: str | None = None, + ) -> None: + with self.db.connect() as conn: + current = conn.execute( + """ + SELECT retry_count, started_at, finished_at + FROM task_steps + WHERE task_id = ? AND step_name = ? + """, + (task_id, step_name), + ).fetchone() + if current is None: + raise RuntimeError(f"step not found: {task_id}.{step_name}") + conn.execute( + """ + UPDATE task_steps + SET status = ?, + error_code = ?, + error_message = ?, + retry_count = ?, + started_at = ?, + finished_at = ? + WHERE task_id = ? AND step_name = ? + """, + ( + status, + error_code, + error_message, + retry_count if retry_count is not None else current["retry_count"], + started_at if started_at is not None else current["started_at"], + finished_at if finished_at is not None else current["finished_at"], + task_id, + step_name, + ), + ) + conn.commit() + + def add_artifact(self, artifact: Artifact) -> None: + with self.db.connect() as conn: + existing = conn.execute( + """ + SELECT 1 + FROM artifacts + WHERE task_id = ? AND artifact_type = ? AND path = ? + LIMIT 1 + """, + (artifact.task_id, artifact.artifact_type, artifact.path), + ).fetchone() + if existing: + return + conn.execute( + """ + INSERT INTO artifacts (task_id, artifact_type, path, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + artifact.task_id, + artifact.artifact_type, + artifact.path, + artifact.metadata_json, + artifact.created_at, + ), + ) + conn.commit() + + def list_artifacts(self, task_id: str) -> list[Artifact]: + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT id, task_id, artifact_type, path, metadata_json, created_at + FROM artifacts + WHERE task_id = ? + ORDER BY id ASC + """, + (task_id,), + ).fetchall() + return [Artifact(**dict(row)) for row in rows] + + def delete_artifacts(self, task_id: str, artifact_type: str) -> None: + with self.db.connect() as conn: + conn.execute( + "DELETE FROM artifacts WHERE task_id = ? AND artifact_type = ?", + (task_id, artifact_type), + ) + conn.commit() + + def delete_artifact_by_path(self, task_id: str, path: str) -> None: + with self.db.connect() as conn: + conn.execute( + "DELETE FROM artifacts WHERE task_id = ? AND path = ?", + (task_id, path), + ) + conn.commit() + + def add_publish_record(self, record: PublishRecord) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO publish_records (task_id, platform, aid, bvid, title, published_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + record.task_id, + record.platform, + record.aid, + record.bvid, + record.title, + record.published_at, + ), + ) + conn.commit() + + def add_action_record(self, record: ActionRecord) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO action_records (task_id, action_name, status, summary, details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + record.task_id, + record.action_name, + record.status, + record.summary, + record.details_json, + record.created_at, + ), + ) + conn.commit() + + def list_action_records( + self, + task_id: str | None = None, + limit: int = 100, + *, + action_name: str | None = None, + status: str | None = None, + ) -> list[ActionRecord]: + with self.db.connect() as conn: + conditions: list[str] = [] + params: list[object] = [] + if task_id is not None: + conditions.append("task_id = ?") + params.append(task_id) + if action_name: + conditions.append("action_name = ?") + params.append(action_name) + if status: + conditions.append("status = ?") + params.append(status) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + rows = conn.execute( + f""" + SELECT id, task_id, action_name, status, summary, details_json, created_at + FROM action_records + {where} + ORDER BY id DESC + LIMIT ? + """, + (*params, limit), + ).fetchall() + return [ActionRecord(**dict(row)) for row in rows] + + def bootstrap_from_legacy_sessions(self, session_dir: Path) -> int: + synced = 0 + if not session_dir.exists(): + return synced + for folder in sorted(p for p in session_dir.iterdir() if p.is_dir()): + task_id = folder.name + existing_task = self.get_task(task_id) + derived_status = "created" + if (folder / "transcribe_done.flag").exists(): + derived_status = "transcribed" + if (folder / "songs.json").exists(): + derived_status = "songs_detected" + if (folder / "split_done.flag").exists(): + derived_status = "split_done" + if (folder / "upload_done.flag").exists(): + derived_status = "published" + if (folder / "comment_done.flag").exists(): + derived_status = "commented" + if (folder / "collection_a_done.flag").exists() or (folder / "collection_b_done.flag").exists(): + derived_status = "collection_synced" + effective_status = self._merge_task_status(existing_task.status if existing_task else None, derived_status) + created_at = ( + existing_task.created_at + if existing_task and existing_task.created_at + else self._folder_time_iso(folder) + ) + updated_at = ( + existing_task.updated_at + if existing_task and existing_task.updated_at + else created_at + ) + task = Task( + id=task_id, + source_type=existing_task.source_type if existing_task else "legacy_session", + source_path=existing_task.source_path if existing_task else str(folder), + title=folder.name, + status=effective_status, + created_at=created_at, + updated_at=updated_at, + ) + self.upsert_task(task) + steps = self._merge_steps(folder, task_id) + self.replace_steps(task_id, steps) + self._bootstrap_artifacts(folder, task_id) + synced += 1 + return synced + + def _infer_steps(self, folder: Path, task_id: str) -> list[TaskStep]: + flags = { + "ingest": True, + "transcribe": (folder / "transcribe_done.flag").exists(), + "song_detect": (folder / "songs.json").exists(), + "split": (folder / "split_done.flag").exists(), + "publish": (folder / "upload_done.flag").exists(), + "comment": (folder / "comment_done.flag").exists(), + "collection_a": (folder / "collection_a_done.flag").exists(), + "collection_b": (folder / "collection_b_done.flag").exists(), + } + steps: list[TaskStep] = [] + for name, done in flags.items(): + steps.append( + TaskStep( + id=None, + task_id=task_id, + step_name=name, + status="succeeded" if done else "pending", + error_code=None, + error_message=None, + retry_count=0, + started_at=None, + finished_at=None, + ) + ) + return steps + + def _merge_steps(self, folder: Path, task_id: str) -> list[TaskStep]: + inferred_steps = {step.step_name: step for step in self._infer_steps(folder, task_id)} + current_steps = {step.step_name: step for step in self.list_steps(task_id)} + merged: list[TaskStep] = [] + for step_name, inferred in inferred_steps.items(): + current = current_steps.get(step_name) + if current is None: + merged.append(inferred) + continue + if inferred.status == "succeeded": + merged.append( + TaskStep( + id=None, + task_id=task_id, + step_name=step_name, + status="succeeded", + error_code=None, + error_message=None, + retry_count=current.retry_count, + started_at=current.started_at, + finished_at=current.finished_at, + ) + ) + continue + if current.status != "pending": + merged.append( + TaskStep( + id=None, + task_id=task_id, + step_name=step_name, + status=current.status, + error_code=current.error_code, + error_message=current.error_message, + retry_count=current.retry_count, + started_at=current.started_at, + finished_at=current.finished_at, + ) + ) + continue + merged.append(inferred) + return merged + + @staticmethod + def _merge_task_status(existing_status: str | None, derived_status: str) -> str: + if not existing_status: + return derived_status + existing_rank = TASK_STATUS_ORDER.get(existing_status, -1) + derived_rank = TASK_STATUS_ORDER.get(derived_status, -1) + return existing_status if existing_rank >= derived_rank else derived_status + + @staticmethod + def _folder_time_iso(folder: Path) -> str: + return datetime.fromtimestamp(folder.stat().st_mtime, tz=timezone.utc).isoformat() + + def _bootstrap_artifacts(self, folder: Path, task_id: str) -> None: + artifacts = [] + if any(folder.glob("*.srt")): + for srt in folder.glob("*.srt"): + artifacts.append(("subtitle_srt", srt)) + for name in ("songs.json", "songs.txt", "bvid.txt"): + path = folder / name + if path.exists(): + artifact_type = { + "songs.json": "songs_json", + "songs.txt": "songs_txt", + "bvid.txt": "publish_bvid", + }[name] + artifacts.append((artifact_type, path)) + existing = {(a.artifact_type, a.path) for a in self.list_artifacts(task_id)} + for artifact_type, path in artifacts: + key = (artifact_type, str(path)) + if key in existing: + continue + self.add_artifact( + Artifact( + id=None, + task_id=task_id, + artifact_type=artifact_type, + path=str(path), + metadata_json=json.dumps({}), + created_at="", + ) + ) diff --git a/src/biliup_next/infra/task_reset.py b/src/biliup_next/infra/task_reset.py new file mode 100644 index 0000000..5fe3ee3 --- /dev/null +++ b/src/biliup_next/infra/task_reset.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from biliup_next.core.models import utc_now_iso +from biliup_next.infra.task_repository import TaskRepository + +STEP_ORDER = [ + "ingest", + "transcribe", + "song_detect", + "split", + "publish", + "comment", + "collection_a", + "collection_b", +] + +STATUS_BEFORE_STEP = { + "transcribe": "created", + "song_detect": "transcribed", + "split": "songs_detected", + "publish": "split_done", + "comment": "published", + "collection_a": "commented", + "collection_b": "commented", +} + + +class TaskResetService: + def __init__(self, repo: TaskRepository): + self.repo = repo + + def reset_to_step(self, task_id: str, step_name: str) -> dict[str, object]: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + if step_name not in STEP_ORDER: + raise RuntimeError(f"unsupported step: {step_name}") + + work_dir = self._resolve_work_dir(task) + self._cleanup_files(work_dir, step_name) + self._cleanup_artifacts(task_id, step_name) + self._reset_steps(task_id, step_name) + target_status = STATUS_BEFORE_STEP.get(step_name, "created") + self.repo.update_task_status(task_id, target_status, utc_now_iso()) + return {"task_id": task_id, "reset_to": step_name, "work_dir": str(work_dir)} + + @staticmethod + def _resolve_work_dir(task) -> Path: # type: ignore[no-untyped-def] + source = Path(task.source_path) + return source.parent if source.is_file() else source + + @staticmethod + def _remove_path(path: Path) -> None: + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + elif path.exists(): + path.unlink() + + def _cleanup_files(self, work_dir: Path, step_name: str) -> None: + cleanup_map = { + "transcribe": [ + work_dir / "transcribe_done.flag", + work_dir / "song_schema.json", + work_dir / "songs.json", + work_dir / "songs.txt", + work_dir / "split_done.flag", + work_dir / "upload_done.flag", + work_dir / "comment_done.flag", + work_dir / "comment_split_done.flag", + work_dir / "comment_full_done.flag", + work_dir / "collection_a_done.flag", + work_dir / "collection_b_done.flag", + work_dir / "bvid.txt", + work_dir / "temp_audio", + work_dir / "split_video", + ], + "song_detect": [ + work_dir / "song_schema.json", + work_dir / "songs.json", + work_dir / "songs.txt", + work_dir / "split_done.flag", + work_dir / "upload_done.flag", + work_dir / "comment_done.flag", + work_dir / "comment_split_done.flag", + work_dir / "comment_full_done.flag", + work_dir / "collection_a_done.flag", + work_dir / "collection_b_done.flag", + work_dir / "bvid.txt", + work_dir / "split_video", + ], + "split": [ + work_dir / "split_done.flag", + work_dir / "upload_done.flag", + work_dir / "comment_done.flag", + work_dir / "comment_split_done.flag", + work_dir / "comment_full_done.flag", + work_dir / "collection_a_done.flag", + work_dir / "collection_b_done.flag", + work_dir / "bvid.txt", + work_dir / "split_video", + ], + "publish": [ + work_dir / "upload_done.flag", + work_dir / "comment_done.flag", + work_dir / "comment_split_done.flag", + work_dir / "comment_full_done.flag", + work_dir / "collection_a_done.flag", + work_dir / "collection_b_done.flag", + work_dir / "bvid.txt", + ], + "comment": [ + work_dir / "comment_done.flag", + work_dir / "comment_split_done.flag", + work_dir / "comment_full_done.flag", + work_dir / "collection_a_done.flag", + work_dir / "collection_b_done.flag", + ], + "collection_a": [ + work_dir / "collection_a_done.flag", + ], + "collection_b": [ + work_dir / "collection_b_done.flag", + ], + } + for path in cleanup_map.get(step_name, []): + self._remove_path(path) + if step_name == "transcribe": + for srt_file in work_dir.glob("*.srt"): + self._remove_path(srt_file) + + def _cleanup_artifacts(self, task_id: str, step_name: str) -> None: + type_map = { + "transcribe": {"subtitle_srt", "songs_json", "songs_txt", "clip_video", "publish_bvid"}, + "song_detect": {"songs_json", "songs_txt", "clip_video", "publish_bvid"}, + "split": {"clip_video", "publish_bvid"}, + "publish": {"publish_bvid"}, + "comment": set(), + "collection_a": set(), + "collection_b": set(), + } + for artifact_type in type_map.get(step_name, set()): + self.repo.delete_artifacts(task_id, artifact_type) + + def _reset_steps(self, task_id: str, step_name: str) -> None: + reset_index = STEP_ORDER.index(step_name) + for index, current_step in enumerate(STEP_ORDER): + if current_step == "ingest": + continue + if index < reset_index: + continue + self.repo.update_step_status( + task_id, + current_step, + "pending", + error_code=None, + error_message=None, + retry_count=0, + started_at=None, + finished_at=None, + ) diff --git a/src/biliup_next/infra/workspace_cleanup.py b/src/biliup_next/infra/workspace_cleanup.py new file mode 100644 index 0000000..32024f1 --- /dev/null +++ b/src/biliup_next/infra/workspace_cleanup.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from biliup_next.infra.task_repository import TaskRepository + + +class WorkspaceCleanupService: + def __init__(self, repo: TaskRepository): + self.repo = repo + + def cleanup_task_outputs(self, task_id: str, settings: dict[str, object]) -> dict[str, object]: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + + session_dir = Path(str(settings["session_dir"])) / task.title + removed: list[str] = [] + skipped: list[str] = [] + + if settings.get("delete_source_video_after_collection_synced", False): + source_path = Path(task.source_path) + if source_path.exists(): + source_path.unlink() + self.repo.delete_artifact_by_path(task_id, str(source_path.resolve())) + removed.append(str(source_path)) + else: + skipped.append(str(source_path)) + + if settings.get("delete_split_videos_after_collection_synced", False): + split_dir = session_dir / "split_video" + if split_dir.exists(): + shutil.rmtree(split_dir, ignore_errors=True) + self.repo.delete_artifacts(task_id, "clip_video") + removed.append(str(split_dir)) + else: + skipped.append(str(split_dir)) + + return {"removed": removed, "skipped": skipped} diff --git a/src/biliup_next/modules/collection/service.py b/src/biliup_next/modules/collection/service.py new file mode 100644 index 0000000..fe2750f --- /dev/null +++ b/src/biliup_next/modules/collection/service.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from biliup_next.core.models import utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.workspace_cleanup import WorkspaceCleanupService +from biliup_next.infra.task_repository import TaskRepository + + +class CollectionService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + self.cleanup = WorkspaceCleanupService(repo) + + def run(self, task_id: str, target: str, settings: dict[str, object]) -> dict[str, object]: + if target not in {"a", "b"}: + raise RuntimeError(f"unsupported collection target: {target}") + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + step_name = f"collection_{target}" + provider = self.registry.get("collection_provider", str(settings.get("provider", "bilibili_collection"))) + started_at = utc_now_iso() + self.repo.update_step_status(task_id, step_name, "running", started_at=started_at) + result = provider.sync(task, target, settings) + finished_at = utc_now_iso() + self.repo.update_step_status(task_id, step_name, "succeeded", finished_at=finished_at) + + steps = {step.step_name: step for step in self.repo.list_steps(task_id)} + if steps.get("collection_a") and steps["collection_a"].status == "succeeded" and steps.get("collection_b") and steps["collection_b"].status == "succeeded": + self.repo.update_task_status(task_id, "collection_synced", finished_at) + cleanup_result = self.cleanup.cleanup_task_outputs(task_id, settings) + return {**result, "cleanup": cleanup_result} + return result diff --git a/src/biliup_next/modules/comment/service.py b/src/biliup_next/modules/comment/service.py new file mode 100644 index 0000000..d360657 --- /dev/null +++ b/src/biliup_next/modules/comment/service.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from biliup_next.core.models import utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.task_repository import TaskRepository + + +class CommentService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + + def run(self, task_id: str, settings: dict[str, object]) -> dict[str, object]: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + provider = self.registry.get("comment_provider", str(settings.get("provider", "bilibili_top_comment"))) + started_at = utc_now_iso() + self.repo.update_step_status(task_id, "comment", "running", started_at=started_at) + result = provider.comment(task, settings) + finished_at = utc_now_iso() + self.repo.update_step_status(task_id, "comment", "succeeded", finished_at=finished_at) + self.repo.update_task_status(task_id, "commented", finished_at) + return result diff --git a/src/biliup_next/modules/ingest/providers/local_file.py b/src/biliup_next/modules/ingest/providers/local_file.py new file mode 100644 index 0000000..8cc915b --- /dev/null +++ b/src/biliup_next/modules/ingest/providers/local_file.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from biliup_next.core.errors import ModuleError +from biliup_next.core.providers import ProviderManifest + + +class LocalFileIngestProvider: + manifest = ProviderManifest( + id="local_file", + name="Local File Ingest", + version="0.1.0", + provider_type="ingest_provider", + entrypoint="biliup_next.modules.ingest.providers.local_file:LocalFileIngestProvider", + capabilities=["ingest"], + enabled_by_default=True, + ) + + def validate_source(self, source_path: Path, settings: dict[str, Any]) -> None: + if not source_path.exists(): + raise ModuleError( + code="SOURCE_NOT_FOUND", + message=f"源文件不存在: {source_path}", + retryable=False, + ) + if not source_path.is_file(): + raise ModuleError( + code="SOURCE_NOT_FILE", + message=f"源路径不是文件: {source_path}", + retryable=False, + ) + suffix = source_path.suffix.lower() + allowed = [str(item).lower() for item in settings.get("allowed_extensions", [])] + if suffix not in allowed: + raise ModuleError( + code="SOURCE_EXTENSION_NOT_ALLOWED", + message=f"文件扩展名不受支持: {suffix}", + retryable=False, + details={"allowed_extensions": allowed}, + ) diff --git a/src/biliup_next/modules/ingest/service.py b/src/biliup_next/modules/ingest/service.py new file mode 100644 index 0000000..1382def --- /dev/null +++ b/src/biliup_next/modules/ingest/service.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import time +from pathlib import Path + +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import Artifact, Task, TaskStep, utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.task_repository import TaskRepository + + +class IngestService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + + def create_task_from_file(self, source_path: Path, settings: dict[str, object]) -> Task: + provider_id = str(settings.get("provider", "local_file")) + provider = self.registry.get("ingest_provider", provider_id) + provider.validate_source(source_path, settings) + + task_id = source_path.stem + if self.repo.get_task(task_id): + raise ModuleError( + code="TASK_ALREADY_EXISTS", + message=f"任务已存在: {task_id}", + retryable=False, + ) + + now = utc_now_iso() + task = Task( + id=task_id, + source_type="local_file", + source_path=str(source_path.resolve()), + title=source_path.stem, + status="created", + created_at=now, + updated_at=now, + ) + self.repo.upsert_task(task) + self.repo.replace_steps( + task_id, + [ + TaskStep(None, task_id, "ingest", "succeeded", None, None, 0, now, now), + TaskStep(None, task_id, "transcribe", "pending", None, None, 0, None, None), + TaskStep(None, task_id, "song_detect", "pending", None, None, 0, None, None), + TaskStep(None, task_id, "split", "pending", None, None, 0, None, None), + TaskStep(None, task_id, "publish", "pending", None, None, 0, None, None), + TaskStep(None, task_id, "comment", "pending", None, None, 0, None, None), + TaskStep(None, task_id, "collection_a", "pending", None, None, 0, None, None), + TaskStep(None, task_id, "collection_b", "pending", None, None, 0, None, None), + ], + ) + self.repo.add_artifact( + Artifact( + id=None, + task_id=task_id, + artifact_type="source_video", + path=str(source_path.resolve()), + metadata_json=json.dumps({"provider": provider_id}), + created_at=now, + ) + ) + return task + + def scan_stage(self, settings: dict[str, object]) -> dict[str, object]: + stage_dir = Path(str(settings["stage_dir"])).resolve() + backup_dir = Path(str(settings["backup_dir"])).resolve() + session_dir = Path(str(settings["session_dir"])).resolve() + ffprobe_bin = str(settings.get("ffprobe_bin", "ffprobe")) + min_duration = int(settings.get("min_duration_seconds", 0)) + stability_wait_seconds = int(settings.get("stability_wait_seconds", 30)) + + stage_dir.mkdir(parents=True, exist_ok=True) + backup_dir.mkdir(parents=True, exist_ok=True) + session_dir.mkdir(parents=True, exist_ok=True) + + accepted: list[dict[str, object]] = [] + rejected: list[dict[str, object]] = [] + skipped: list[dict[str, object]] = [] + + allowed = {str(item).lower() for item in settings.get("allowed_extensions", [])} + for source_path in sorted(p for p in stage_dir.iterdir() if p.is_file()): + if source_path.name.startswith(".") or source_path.name.endswith(".part"): + continue + if source_path.suffix.lower() not in allowed: + continue + if not self._is_stable_enough(source_path, stability_wait_seconds): + skipped.append( + { + "source_path": str(source_path), + "reason": "file_not_stable_yet", + "stability_wait_seconds": stability_wait_seconds, + } + ) + continue + task_id = source_path.stem + if self.repo.get_task(task_id): + target = self._move_to_directory(source_path, backup_dir) + skipped.append( + { + "source_path": str(source_path), + "reason": "task_exists", + "moved_to": str(target), + } + ) + continue + + duration_seconds = self._probe_duration_seconds(source_path, ffprobe_bin) + if duration_seconds < min_duration: + target = self._move_to_directory(source_path, backup_dir) + rejected.append( + { + "source_path": str(source_path), + "reason": "duration_too_short", + "duration_seconds": duration_seconds, + "min_duration_seconds": min_duration, + "moved_to": str(target), + } + ) + continue + + task_dir = session_dir / task_id + task_dir.mkdir(parents=True, exist_ok=True) + target_source = self._move_to_directory(source_path, task_dir) + task = self.create_task_from_file(target_source, settings) + accepted.append( + { + "task_id": task.id, + "title": task.title, + "source_path": str(target_source), + "duration_seconds": duration_seconds, + } + ) + + return {"accepted": accepted, "rejected": rejected, "skipped": skipped} + + @staticmethod + def _is_stable_enough(source_path: Path, stability_wait_seconds: int) -> bool: + if stability_wait_seconds <= 0: + return True + age_seconds = time.time() - source_path.stat().st_mtime + return age_seconds >= stability_wait_seconds + + def _probe_duration_seconds(self, source_path: Path, ffprobe_bin: str) -> float: + cmd = [ + ffprobe_bin, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(source_path), + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError as exc: + raise ModuleError( + code="FFPROBE_NOT_FOUND", + message=f"找不到 ffprobe: {ffprobe_bin}", + retryable=False, + ) from exc + except subprocess.CalledProcessError as exc: + raise ModuleError( + code="FFPROBE_FAILED", + message=f"ffprobe 获取时长失败: {source_path.name}", + retryable=False, + details={"stderr": exc.stderr.strip()}, + ) from exc + try: + return float(result.stdout.strip()) + except ValueError as exc: + raise ModuleError( + code="FFPROBE_INVALID_DURATION", + message=f"ffprobe 返回非法时长: {source_path.name}", + retryable=False, + details={"stdout": result.stdout.strip()}, + ) from exc + + def _move_to_directory(self, source_path: Path, target_dir: Path) -> Path: + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / source_path.name + if target_path.exists(): + target_path = self._unique_target_path(target_dir, source_path.name) + shutil.move(str(source_path), str(target_path)) + return target_path.resolve() + + @staticmethod + def _unique_target_path(target_dir: Path, filename: str) -> Path: + base = Path(filename).stem + suffix = Path(filename).suffix + index = 1 + while True: + candidate = target_dir / f"{base}.{index}{suffix}" + if not candidate.exists(): + return candidate + index += 1 diff --git a/src/biliup_next/modules/publish/service.py b/src/biliup_next/modules/publish/service.py new file mode 100644 index 0000000..56577a6 --- /dev/null +++ b/src/biliup_next/modules/publish/service.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from biliup_next.core.models import Artifact, PublishRecord, utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.task_repository import TaskRepository + + +class PublishService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + + def run(self, task_id: str, settings: dict[str, object]) -> PublishRecord: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + artifacts = self.repo.list_artifacts(task_id) + clip_videos = [a for a in artifacts if a.artifact_type == "clip_video"] + provider = self.registry.get("publish_provider", str(settings.get("provider", "biliup_cli"))) + started_at = utc_now_iso() + self.repo.update_step_status(task_id, "publish", "running", started_at=started_at) + record = provider.publish(task, clip_videos, settings) + self.repo.add_publish_record(record) + if record.bvid: + session_dir = Path(str(settings.get("session_dir", "session"))) / task.title + bvid_path = str((session_dir / "bvid.txt").resolve()) + self.repo.add_artifact( + Artifact( + id=None, + task_id=task_id, + artifact_type="publish_bvid", + path=bvid_path, + metadata_json=json.dumps({}), + created_at=utc_now_iso(), + ) + ) + finished_at = utc_now_iso() + self.repo.update_step_status(task_id, "publish", "succeeded", finished_at=finished_at) + self.repo.update_task_status(task_id, "published", finished_at) + return record diff --git a/src/biliup_next/modules/song_detect/service.py b/src/biliup_next/modules/song_detect/service.py new file mode 100644 index 0000000..e7087f1 --- /dev/null +++ b/src/biliup_next/modules/song_detect/service.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from biliup_next.core.models import Artifact, utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.task_repository import TaskRepository + + +class SongDetectService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + + def run(self, task_id: str, settings: dict[str, object]) -> tuple[Artifact, Artifact]: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + artifacts = self.repo.list_artifacts(task_id) + subtitle_srt = next(a for a in artifacts if a.artifact_type == "subtitle_srt") + provider = self.registry.get("song_detector", str(settings.get("provider", "codex"))) + started_at = utc_now_iso() + self.repo.update_step_status(task_id, "song_detect", "running", started_at=started_at) + songs_json, songs_txt = provider.detect(task, subtitle_srt, settings) + self.repo.add_artifact(songs_json) + self.repo.add_artifact(songs_txt) + finished_at = utc_now_iso() + self.repo.update_step_status(task_id, "song_detect", "succeeded", finished_at=finished_at) + self.repo.update_task_status(task_id, "songs_detected", finished_at) + return songs_json, songs_txt diff --git a/src/biliup_next/modules/split/service.py b/src/biliup_next/modules/split/service.py new file mode 100644 index 0000000..01adace --- /dev/null +++ b/src/biliup_next/modules/split/service.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path + +from biliup_next.core.models import Artifact, utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.storage_guard import ensure_free_space, mb_to_bytes +from biliup_next.infra.task_repository import TaskRepository + + +class SplitService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + + def run(self, task_id: str, settings: dict[str, object]) -> list[Artifact]: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + artifacts = self.repo.list_artifacts(task_id) + songs_json = next(a for a in artifacts if a.artifact_type == "songs_json") + source_video = next(a for a in artifacts if a.artifact_type == "source_video") + source_path = Path(source_video.path) + source_size = source_path.stat().st_size + reserve_bytes = mb_to_bytes(settings.get("min_free_space_mb", 0)) + ensure_free_space( + source_path.parent, + source_size + reserve_bytes, + code="SPLIT_NO_SPACE", + message=f"剩余空间不足,无法开始切歌: {source_path.name}", + retryable=True, + details={"source_size_bytes": source_size, "reserve_bytes": reserve_bytes}, + ) + provider = self.registry.get("split_provider", str(settings.get("provider", "ffmpeg_copy"))) + started_at = utc_now_iso() + self.repo.update_step_status(task_id, "split", "running", started_at=started_at) + clip_artifacts = provider.split(task, songs_json, source_video, settings) + existing = {(a.artifact_type, a.path) for a in artifacts} + for artifact in clip_artifacts: + if (artifact.artifact_type, artifact.path) not in existing: + self.repo.add_artifact(artifact) + finished_at = utc_now_iso() + self.repo.update_step_status(task_id, "split", "succeeded", finished_at=finished_at) + self.repo.update_task_status(task_id, "split_done", finished_at) + return clip_artifacts diff --git a/src/biliup_next/modules/transcribe/service.py b/src/biliup_next/modules/transcribe/service.py new file mode 100644 index 0000000..ff2c21a --- /dev/null +++ b/src/biliup_next/modules/transcribe/service.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from biliup_next.core.models import Artifact, utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.task_repository import TaskRepository + + +class TranscribeService: + def __init__(self, registry: Registry, repo: TaskRepository): + self.registry = registry + self.repo = repo + + def run(self, task_id: str, settings: dict[str, object]) -> Artifact: + task = self.repo.get_task(task_id) + if task is None: + raise RuntimeError(f"task not found: {task_id}") + artifacts = self.repo.list_artifacts(task_id) + source_video = next(a for a in artifacts if a.artifact_type == "source_video") + provider = self.registry.get("transcribe_provider", str(settings.get("provider", "groq"))) + started_at = utc_now_iso() + self.repo.update_step_status(task_id, "transcribe", "running", started_at=started_at) + artifact = provider.transcribe(task, source_video, settings) + self.repo.add_artifact(artifact) + finished_at = utc_now_iso() + self.repo.update_step_status(task_id, "transcribe", "succeeded", finished_at=finished_at) + self.repo.update_task_status(task_id, "transcribed", finished_at) + return artifact diff --git a/src/biliup_next/plugins/manifests/collection_bilibili_collection.json b/src/biliup_next/plugins/manifests/collection_bilibili_collection.json new file mode 100644 index 0000000..ef0e587 --- /dev/null +++ b/src/biliup_next/plugins/manifests/collection_bilibili_collection.json @@ -0,0 +1,9 @@ +{ + "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 +} diff --git a/src/biliup_next/plugins/manifests/comment_bilibili_top_comment.json b/src/biliup_next/plugins/manifests/comment_bilibili_top_comment.json new file mode 100644 index 0000000..f72598a --- /dev/null +++ b/src/biliup_next/plugins/manifests/comment_bilibili_top_comment.json @@ -0,0 +1,9 @@ +{ + "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 +} diff --git a/src/biliup_next/plugins/manifests/ingest_local_file.json b/src/biliup_next/plugins/manifests/ingest_local_file.json new file mode 100644 index 0000000..055fdec --- /dev/null +++ b/src/biliup_next/plugins/manifests/ingest_local_file.json @@ -0,0 +1,9 @@ +{ + "id": "local_file", + "name": "Local File Ingest", + "version": "0.1.0", + "provider_type": "ingest_provider", + "entrypoint": "biliup_next.modules.ingest.providers.local_file:LocalFileIngestProvider", + "capabilities": ["ingest"], + "enabled_by_default": true +} diff --git a/src/biliup_next/plugins/manifests/publish_biliup_cli.json b/src/biliup_next/plugins/manifests/publish_biliup_cli.json new file mode 100644 index 0000000..f8a9dbe --- /dev/null +++ b/src/biliup_next/plugins/manifests/publish_biliup_cli.json @@ -0,0 +1,9 @@ +{ + "id": "biliup_cli", + "name": "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 +} diff --git a/src/biliup_next/plugins/manifests/song_detect_codex.json b/src/biliup_next/plugins/manifests/song_detect_codex.json new file mode 100644 index 0000000..156104b --- /dev/null +++ b/src/biliup_next/plugins/manifests/song_detect_codex.json @@ -0,0 +1,9 @@ +{ + "id": "codex", + "name": "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 +} diff --git a/src/biliup_next/plugins/manifests/split_ffmpeg_copy.json b/src/biliup_next/plugins/manifests/split_ffmpeg_copy.json new file mode 100644 index 0000000..6ca3aec --- /dev/null +++ b/src/biliup_next/plugins/manifests/split_ffmpeg_copy.json @@ -0,0 +1,9 @@ +{ + "id": "ffmpeg_copy", + "name": "FFmpeg Copy 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 +} diff --git a/src/biliup_next/plugins/manifests/transcribe_groq.json b/src/biliup_next/plugins/manifests/transcribe_groq.json new file mode 100644 index 0000000..602784d --- /dev/null +++ b/src/biliup_next/plugins/manifests/transcribe_groq.json @@ -0,0 +1,9 @@ +{ + "id": "groq", + "name": "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 +} diff --git a/systemd/biliup-next-api.service.template b/systemd/biliup-next-api.service.template new file mode 100644 index 0000000..cba60dc --- /dev/null +++ b/systemd/biliup-next-api.service.template @@ -0,0 +1,19 @@ +[Unit] +Description=biliup-next api +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=__USER__ +Group=__GROUP__ +WorkingDirectory=__PROJECT_DIR__ +Environment=BILIUP_NEXT_PYTHON=__PYTHON_BIN__ +Environment=BILIUP_NEXT_API_HOST=0.0.0.0 +Environment=BILIUP_NEXT_API_PORT=8787 +ExecStart=/usr/bin/bash __PROJECT_DIR__/run-api.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/biliup-next-worker.service.template b/systemd/biliup-next-worker.service.template new file mode 100644 index 0000000..3b65722 --- /dev/null +++ b/systemd/biliup-next-worker.service.template @@ -0,0 +1,18 @@ +[Unit] +Description=biliup-next worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=__USER__ +Group=__GROUP__ +WorkingDirectory=__PROJECT_DIR__ +Environment=BILIUP_NEXT_PYTHON=__PYTHON_BIN__ +Environment=BILIUP_NEXT_WORKER_INTERVAL=5 +ExecStart=/usr/bin/bash __PROJECT_DIR__/run-worker.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target