diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..950071c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.venv +.pytest_cache +__pycache__ +*.pyc + +data/ +runtime/cookies.json +runtime/upload_config.json +runtime/biliup +runtime/codex/ +runtime/logs/ + +frontend/node_modules/ +frontend/dist/ + +.env +config/settings.json +config/settings.staged.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdc2135 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Web/API port exposed on the host. +BILIUP_NEXT_PORT=8000 + +# Image used by both api and worker. Override this when using a versioned tag +# or a private registry image, for example 192.168.1.100:25490/biliup-next:20260420. +BILIUP_NEXT_IMAGE=biliup-next:local + +# Worker polling interval in seconds. +WORKER_INTERVAL=5 + +# Container timezone. +TZ=Asia/Shanghai + +# Optional container outbound proxy. In Docker Desktop/WSL, host.docker.internal +# points to the Windows host; set this to your local proxy port. +# These values are also passed as Docker build args for apt/pip/npm. +# HTTP_PROXY=http://host.docker.internal:7897 +# HTTPS_PROXY=http://host.docker.internal:7897 +# ALL_PROXY=http://host.docker.internal:7897 +# NO_PROXY=localhost,127.0.0.1,api,worker +# +# Docker build-time proxy. Separate names avoid being overridden by host +# HTTP_PROXY/HTTPS_PROXY when Compose interpolates build args. +# DOCKER_BUILD_HTTP_PROXY=http://host.docker.internal:7897 +# DOCKER_BUILD_HTTPS_PROXY=http://host.docker.internal:7897 +# DOCKER_BUILD_ALL_PROXY=http://host.docker.internal:7897 +# DOCKER_BUILD_NO_PROXY=localhost,127.0.0.1,api,worker + +# Required for Groq transcription. Prefer this env var over writing the key +# directly into config/settings.json. +GROQ_API_KEY= +# Optional key pool. Use a JSON array; keys here are tried before GROQ_API_KEY. +# GROQ_API_KEYS=["gsk_xxx","gsk_yyy"] + +# Optional for the Codex song detector when you do not mount an existing +# Codex login state into runtime/codex. +OPENAI_API_KEY= + +# Bilibili collection IDs. +# A: live full-video collection +# B: live split/pure-song collection +COLLECTION_SEASON_ID_A=7196643 +COLLECTION_SEASON_ID_B=7196624 + +# Optional explicit config overrides. The generic format is: +# BILIUP_NEXT__GROUP__FIELD=value +# +# BILIUP_NEXT__PUBLISH__RETRY_SCHEDULE_MINUTES=[15,5,5,5,5] +# BILIUP_NEXT__PUBLISH__RATE_LIMIT_RETRY_SCHEDULE_MINUTES=[15,30,60] diff --git a/.gitignore b/.gitignore index 796306c..c0166a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,27 @@ .venv/ +.codex +.codex/ +.env +.tmp-tests/ __pycache__/ *.pyc *.pyo *.pyd - -data/ -config/settings.staged.json - -systemd/rendered/ - + +data/ +config/settings.staged.json + +systemd/rendered/ + runtime/cookies.json runtime/upload_config.json runtime/biliup +runtime/codex/ runtime/logs/ - -frontend/node_modules/ -frontend/dist/ - -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ + +frontend/node_modules/ +frontend/dist/ + +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/DELIVERY.md b/DELIVERY.md index 4e23ea4..3a75e0d 100644 --- a/DELIVERY.md +++ b/DELIVERY.md @@ -1,113 +1,113 @@ -# biliup-next Delivery Checklist - -## Scope - -`biliup-next` 当前已经是一个可独立安装、可运行、可通过控制台运维的本地项目。 - -当前交付范围包括: - -- 独立 Python 包 -- 本地 `.venv` 初始化 -- SQLite 状态存储 -- 隔离 workspace -- `worker` / `api` 运行脚本 -- `systemd` 安装脚本 -- Web 控制台 -- 项目内日志落盘 -- 主链路: - - `stage` - - `ingest` - - `transcribe` - - `song_detect` - - `split` - - `publish` - - `comment` - - `collection` - -## Preflight - -- Python 3.11+ -- `ffmpeg` -- `ffprobe` -- `codex` -- `biliup` -- `biliup-next/runtime/cookies.json` -- `biliup-next/runtime/upload_config.json` -- `biliup-next/runtime/biliup` - -## Install - -```bash -cd /home/theshy/biliup/biliup-next -bash setup.sh -``` - -如需把当前机器上已有运行资产复制到本地: - -```bash -cd /home/theshy/biliup/biliup-next -./.venv/bin/biliup-next sync-legacy-assets -``` - -## Verify - -```bash -cd /home/theshy/biliup/biliup-next -./.venv/bin/biliup-next doctor -./.venv/bin/biliup-next init-workspace -bash smoke-test.sh -``` - -预期: - -- `doctor.ok = true` -- `data/workspace/stage` -- `data/workspace/backup` -- `data/workspace/session` - -## Run - -手动方式: - -```bash -cd /home/theshy/biliup/biliup-next -bash run-worker.sh -bash run-api.sh -``` - -默认会写入: - -- `runtime/logs/worker.log` -- `runtime/logs/api.log` - -默认按大小轮转: - -- 单文件 `20 MiB` -- 保留 `5` 份历史日志 - -systemd 方式: - -```bash -cd /home/theshy/biliup/biliup-next -bash install-systemd.sh -``` - -## Control Plane - -- URL: `http://127.0.0.1:8787/` -- 健康检查:`/health` -- 可选认证:`runtime.control_token` - -## Repository Hygiene - -这些内容不应提交: - -- `.venv/` -- `data/` -- `systemd/rendered/` -- `config/settings.staged.json` - -## Known Limits - -- 当前控制台认证是单 token,本地可用,但不等于完整权限系统 -- `sync-legacy-assets` 仍是一次性导入工具,方便把已有资产复制到 `runtime/` +# biliup-next Delivery Checklist + +## Scope + +`biliup-next` 当前已经是一个可独立安装、可运行、可通过控制台运维的本地项目。 + +当前交付范围包括: + +- 独立 Python 包 +- 本地 `.venv` 初始化 +- SQLite 状态存储 +- 隔离 workspace +- `worker` / `api` 运行脚本 +- `systemd` 安装脚本 +- Web 控制台 +- 项目内日志落盘 +- 主链路: + - `stage` + - `ingest` + - `transcribe` + - `song_detect` + - `split` + - `publish` + - `comment` + - `collection` + +## Preflight + +- Python 3.11+ +- `ffmpeg` +- `ffprobe` +- `codex` +- `biliup` +- `biliup-next/runtime/cookies.json` +- `biliup-next/runtime/upload_config.json` +- `biliup-next/runtime/biliup` + +## Install + +```bash +cd /home/theshy/biliup/biliup-next +bash setup.sh +``` + +如需把当前机器上已有运行资产复制到本地: + +```bash +cd /home/theshy/biliup/biliup-next +./.venv/bin/biliup-next sync-legacy-assets +``` + +## Verify + +```bash +cd /home/theshy/biliup/biliup-next +./.venv/bin/biliup-next doctor +./.venv/bin/biliup-next init-workspace +bash smoke-test.sh +``` + +预期: + +- `doctor.ok = true` +- `data/workspace/stage` +- `data/workspace/backup` +- `data/workspace/session` + +## Run + +手动方式: + +```bash +cd /home/theshy/biliup/biliup-next +bash run-worker.sh +bash run-api.sh +``` + +默认会写入: + +- `runtime/logs/worker.log` +- `runtime/logs/api.log` + +默认按大小轮转: + +- 单文件 `20 MiB` +- 保留 `5` 份历史日志 + +systemd 方式: + +```bash +cd /home/theshy/biliup/biliup-next +bash install-systemd.sh +``` + +## Control Plane + +- URL: `http://127.0.0.1:8787/` +- 健康检查:`/health` +- 可选认证:`runtime.control_token` + +## Repository Hygiene + +这些内容不应提交: + +- `.venv/` +- `data/` +- `systemd/rendered/` +- `config/settings.staged.json` + +## Known Limits + +- 当前控制台认证是单 token,本地可用,但不等于完整权限系统 +- `sync-legacy-assets` 仍是一次性导入工具,方便把已有资产复制到 `runtime/` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c93a04 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +FROM node:24-bookworm-slim AS frontend-builder + +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG ALL_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG all_proxy +ARG no_proxy + +WORKDIR /build/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM python:3.12-slim AS app + +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG ALL_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG all_proxy +ARG no_proxy + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + BILIUP_NEXT_CONTAINER=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml README.md ./ +COPY src ./src +COPY config ./config +COPY runtime/README.md runtime/cookies.example.json runtime/upload_config.example.json ./runtime/ +COPY --from=frontend-builder /build/frontend/dist ./frontend/dist +COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node +COPY --from=frontend-builder /usr/local/lib/node_modules /usr/local/lib/node_modules + +RUN pip install --editable . \ + && pip install yt-dlp \ + && ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ + && npm install -g @openai/codex + +RUN mkdir -p /app/data/workspace/stage /app/data/workspace/session /app/data/workspace/backup /app/runtime/logs /root/.codex + +EXPOSE 8000 + +CMD ["biliup-next", "serve", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 82ce3bf..06638f8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ bash setup.sh - `docs/cold-start-checklist.md` +发布流程、输出文案和评论示例见: + +- `docs/publish-output-examples.md` + 浏览器访问: ```text @@ -192,6 +196,29 @@ cd /home/theshy/biliup/biliup-next - 内容按 `P1/P2/P3` 分组 - 依赖 `full_video_bvid.txt` 或通过标题匹配解析到完整版 BV +评论格式和投稿文案一样,优先从 `runtime/upload_config.json` 读取。可编辑字段: + +```json +"comment_template": { + "split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版:{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享:{previous_pure_video_link} (上一场歌曲纯享版)", + "full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版:{current_pure_video_link} (只听歌曲看这里)\n上次完整版:{previous_full_video_link} (上一场完整录播)", + "split_part_header": "P{part_index}:", + "full_part_header": "P{part_index}:", + "split_song_line": "{song_index}. {title}{artist_suffix}", + "split_text_song_line": "{song_index}. {song_text}", + "full_timeline_line": "{song_index}. {line_text}" +} +``` + +常用变量: + +- 链接:`{current_full_video_link}`、`{current_pure_video_link}`、`{previous_full_video_link}`、`{previous_pure_video_link}` +- 分段与序号:`{part_index}`、`{song_index}` +- 纯享歌单:`{title}`、`{artist}`、`{artist_suffix}`、`{song_text}` +- 完整版时间轴:`{line_text}` + +如果某一行包含空链接变量,例如 `{previous_full_video_link}` 为空,这一整行会自动跳过。 + 清理默认关闭: - `cleanup.delete_source_video_after_collection_synced = false` @@ -201,11 +228,14 @@ cd /home/theshy/biliup/biliup-next ## Full Video BV Input -完整版 `BV` 目前支持 3 种来源: +完整版 `BV` 目前支持 4 种来源: - `stage/*.meta.json` 中的 `full_video_bvid` - 前端 / API 手工绑定 - webhook:`POST /webhooks/full-video-uploaded` +- `biliup list` 标题匹配,包含 `开放浏览` 和 `审核中` 状态 + +只要完整版上传后已经生成 BV,即使仍在审核中,也可以被用于纯享版简介、动态和评论互链。 推荐 webhook 负载: @@ -320,3 +350,14 @@ curl -X POST http://127.0.0.1:8787/tasks \ - `ingest.provider = bilibili_url` - `ingest.yt_dlp_cmd = yt-dlp` + +## Docker Compose Deployment + +如果希望用容器方式一键运行 API 和 worker,请参考 [README_DEPLOY.md](README_DEPLOY.md)。 + +快速入口: + +```bash +./scripts/init-docker-config.sh +docker compose up -d --build +``` diff --git a/README_DEPLOY.md b/README_DEPLOY.md new file mode 100644 index 0000000..f298d5a --- /dev/null +++ b/README_DEPLOY.md @@ -0,0 +1,176 @@ +# Docker Compose Deployment + +This deployment runs the API and worker as two services from the same image. +Runtime state, credentials, staged videos, generated sessions, and the SQLite +database stay on the host through bind mounts. + +## 1. Initialize Local Files + +```bash +chmod +x scripts/init-docker-config.sh +./scripts/init-docker-config.sh +``` + +This creates these files if they do not already exist: + +```text +.env +config/settings.json +runtime/cookies.json +runtime/upload_config.json +data/workspace/ +``` + +## 2. Edit Required Secrets And IDs + +Edit `.env`: + +```env +GROQ_API_KEY=your_groq_key +OPENAI_API_KEY=your_openai_key_if_using_codex +COLLECTION_SEASON_ID_A=7196643 +COLLECTION_SEASON_ID_B=7196624 +``` + +Edit `runtime/cookies.json` and `runtime/upload_config.json` with real Bilibili +credentials and upload metadata. + +`runtime/upload_config.json` also controls pure-video title, description, +dynamic text, and top-comment formatting. Existing deployments mount +`./runtime` from the host, so updating the image does not overwrite this file. +When you want to change output text, edit the host file directly. + +Common output templates: + +```json +{ + "template": { + "title": "【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌", + "description": "{streamer} {date} 歌曲纯享版。\n\n完整歌单与时间轴见置顶评论。\n直播完整版:{current_full_video_link}\n上次直播:{previous_full_video_link}\n\n本视频为歌曲纯享切片,适合只听歌曲。", + "dynamic": "{streamer} {date} 歌曲纯享版已发布。完整歌单见置顶评论。\n直播完整版:{current_full_video_link}\n上次直播:{previous_full_video_link}" + }, + "comment_template": { + "split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版:{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次直播:{previous_full_video_link} (上一场完整录播)", + "full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版:{current_pure_video_link} (只听歌曲看这里)\n上次直播:{previous_full_video_link} (上一场完整录播)", + "split_part_header": "P{part_index}:", + "full_part_header": "P{part_index}:", + "split_song_line": "{song_index}. {title}{artist_suffix}", + "split_text_song_line": "{song_index}. {song_text}", + "full_timeline_line": "{song_index}. {line_text}" + } +} +``` + +Supported comment variables: + +- `{current_full_video_link}` / `{current_pure_video_link}` +- `{previous_full_video_link}` / `{previous_pure_video_link}` +- `{part_index}` / `{song_index}` +- `{title}` / `{artist}` / `{artist_suffix}` / `{song_text}` / `{line_text}` + +If a comment header line contains an empty link variable, that whole line is +omitted. This prevents comments from showing blank `上次直播:` lines when the +previous live video cannot be found. + +Provide the `biliup` binary at: + +```text +runtime/biliup +``` + +It must be executable inside the container: + +```bash +chmod +x runtime/biliup +``` + +The image installs the `codex` CLI for `song_detect.provider=codex`. Provide +Codex auth in one of these ways: + +```text +OPENAI_API_KEY in .env +runtime/codex mounted to /root/.codex +``` + +## 3. Start + +```bash +docker compose up -d --build +``` + +Open: + +```text +http://127.0.0.1:8000 +``` + +Drop videos into: + +```text +data/workspace/stage/ +``` + +## Common Commands + +```bash +docker compose logs -f api +docker compose logs -f worker +docker compose restart worker +docker compose down +``` + +Run one scheduler cycle manually: + +```bash +docker compose run --rm worker biliup-next run-once +``` + +Run doctor: + +```bash +docker compose run --rm api biliup-next doctor +``` + +## Environment Overrides + +`config/settings.json` is still the base configuration. Environment variables +override selected values at runtime. + +The Compose file already forces container-safe paths such as +`/app/data/workspace` and `/app/runtime/cookies.json`, so an existing local +`config/settings.json` with host paths can still be mounted safely. + +Generic format: + +```text +BILIUP_NEXT__GROUP__FIELD=value +``` + +Examples: + +```env +BILIUP_NEXT__PATHS__STAGE_DIR=/app/data/workspace/stage +BILIUP_NEXT__PUBLISH__BILIUP_PATH=/app/runtime/biliup +BILIUP_NEXT__PUBLISH__RETRY_SCHEDULE_MINUTES=[15,5,5,5,5] +``` + +Convenience aliases: + +```env +GROQ_API_KEY=... +COLLECTION_SEASON_ID_A=7196643 +COLLECTION_SEASON_ID_B=7196624 +``` + +## Data Persistence + +These host paths are mounted into the containers: + +```text +./config -> /app/config +./runtime -> /app/runtime +./data/workspace -> /app/data/workspace +``` + +Do not store `cookies.json`, Groq keys, or generated workspace data in the image. +They should stay in the mounted host directories. diff --git a/config/settings.docker.example.json b/config/settings.docker.example.json new file mode 100644 index 0000000..c334ed2 --- /dev/null +++ b/config/settings.docker.example.json @@ -0,0 +1,127 @@ +{ + "runtime": { + "database_path": "/app/data/workspace/biliup_next.db", + "control_token": "", + "log_level": "INFO" + }, + "paths": { + "stage_dir": "/app/data/workspace/stage", + "backup_dir": "/app/data/workspace/backup", + "session_dir": "/app/data/workspace/session", + "cookies_file": "/app/runtime/cookies.json", + "upload_config_file": "/app/runtime/upload_config.json" + }, + "scheduler": { + "candidate_scan_limit": 500, + "max_tasks_per_cycle": 50, + "prioritize_retry_due": true, + "oldest_first": true, + "status_priority": [ + "failed_retryable", + "created", + "transcribed", + "songs_detected", + "split_done", + "published", + "commented", + "collection_synced" + ] + }, + "ingest": { + "provider": "local_file", + "min_duration_seconds": 900, + "ffprobe_bin": "ffprobe", + "yt_dlp_cmd": "yt-dlp", + "yt_dlp_format": "", + "allowed_extensions": [ + ".mp4", + ".flv", + ".mkv", + ".mov" + ], + "stage_min_free_space_mb": 1024, + "stability_wait_seconds": 30, + "session_gap_minutes": 60, + "meta_sidecar_enabled": true, + "meta_sidecar_suffix": ".meta.json" + }, + "transcribe": { + "provider": "groq", + "groq_api_key": "", + "groq_api_keys": [], + "ffmpeg_bin": "ffmpeg", + "max_file_size_mb": 12, + "request_timeout_seconds": 180, + "request_max_retries": 1, + "request_retry_backoff_seconds": 30, + "serialize_groq_requests": true, + "retry_count": 3, + "retry_backoff_seconds": 300, + "retry_schedule_minutes": [ + 5, + 10, + 15 + ] + }, + "song_detect": { + "provider": "codex", + "codex_cmd": "codex", + "qwen_cmd": "qwen", + "poll_interval_seconds": 2, + "retry_count": 3, + "retry_backoff_seconds": 300, + "retry_schedule_minutes": [ + 5, + 10, + 15 + ] + }, + "split": { + "provider": "ffmpeg_copy", + "ffmpeg_bin": "ffmpeg", + "poll_interval_seconds": 2, + "min_free_space_mb": 2048 + }, + "publish": { + "provider": "biliup_cli", + "biliup_path": "/app/runtime/biliup", + "cookie_file": "/app/runtime/cookies.json", + "retry_count": 5, + "retry_schedule_minutes": [ + 15, + 5, + 5, + 5, + 5 + ], + "retry_backoff_seconds": 300, + "command_timeout_seconds": 1800, + "rate_limit_retry_schedule_minutes": [ + 15, + 30, + 60 + ] + }, + "comment": { + "provider": "bilibili_top_comment", + "enabled": true, + "max_retries": 5, + "base_delay_seconds": 180, + "poll_interval_seconds": 10, + "post_split_comment": true, + "post_full_video_timeline_comment": true + }, + "collection": { + "provider": "bilibili_collection", + "enabled": true, + "season_id_a": 7196643, + "season_id_b": 7196624, + "allow_fuzzy_full_video_match": false, + "append_collection_a_new_to_end": true, + "append_collection_b_new_to_end": true + }, + "cleanup": { + "delete_source_video_after_collection_synced": false, + "delete_split_videos_after_collection_synced": false + } +} diff --git a/config/settings.json b/config/settings.json index 7b5147b..f700310 100644 --- a/config/settings.json +++ b/config/settings.json @@ -1,15 +1,15 @@ { "runtime": { - "database_path": "data/workspace/biliup_next.db", + "database_path": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/biliup_next.db", "control_token": "", "log_level": "INFO" }, "paths": { - "stage_dir": "data/workspace/stage", - "backup_dir": "data/workspace/backup", - "session_dir": "data/workspace/session", - "cookies_file": "runtime/cookies.json", - "upload_config_file": "runtime/upload_config.json" + "stage_dir": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/stage", + "backup_dir": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/backup", + "session_dir": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/data/workspace/session", + "cookies_file": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/cookies.json", + "upload_config_file": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/upload_config.json" }, "scheduler": { "candidate_scan_limit": 500, @@ -31,7 +31,7 @@ "provider": "local_file", "min_duration_seconds": 900, "ffprobe_bin": "ffprobe", - "yt_dlp_cmd": "yt-dlp", + "yt_dlp_cmd": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/.venv/bin/yt-dlp", "yt_dlp_format": "", "allowed_extensions": [ ".mp4", @@ -47,15 +47,34 @@ }, "transcribe": { "provider": "groq", - "groq_api_key": "", + "groq_api_key": "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", + "groq_api_keys": [], "ffmpeg_bin": "ffmpeg", - "max_file_size_mb": 23 + "max_file_size_mb": 12, + "request_timeout_seconds": 180, + "request_max_retries": 1, + "request_retry_backoff_seconds": 30, + "serialize_groq_requests": true, + "retry_count": 3, + "retry_backoff_seconds": 300, + "retry_schedule_minutes": [ + 5, + 10, + 15 + ] }, "song_detect": { - "provider": "qwen_cli", + "provider": "codex", "codex_cmd": "codex", "qwen_cmd": "qwen", - "poll_interval_seconds": 2 + "poll_interval_seconds": 2, + "retry_count": 3, + "retry_backoff_seconds": 300, + "retry_schedule_minutes": [ + 5, + 10, + 15 + ] }, "split": { "provider": "ffmpeg_copy", @@ -65,8 +84,8 @@ }, "publish": { "provider": "biliup_cli", - "biliup_path": "runtime/biliup", - "cookie_file": "runtime/cookies.json", + "biliup_path": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/biliup", + "cookie_file": "/mnt/f/Codecases/2026-04-14_biliup-next/biliup-next/runtime/cookies.json", "retry_count": 5, "retry_schedule_minutes": [ 15, @@ -78,9 +97,9 @@ "retry_backoff_seconds": 300, "command_timeout_seconds": 1800, "rate_limit_retry_schedule_minutes": [ + 15, 30, - 60, - 120 + 60 ] }, "comment": { @@ -95,8 +114,8 @@ "collection": { "provider": "bilibili_collection", "enabled": true, - "season_id_a": 0, - "season_id_b": 0, + "season_id_a": 7196643, + "season_id_b": 7196624, "allow_fuzzy_full_video_match": false, "append_collection_a_new_to_end": true, "append_collection_b_new_to_end": true diff --git a/config/settings.schema.json b/config/settings.schema.json index 7937713..b76f0e9 100644 --- a/config/settings.schema.json +++ b/config/settings.schema.json @@ -229,6 +229,16 @@ "description": "用于调用 Groq 转录 API。", "sensitive": true }, + "groq_api_keys": { + "type": "array", + "default": [], + "title": "Groq API Keys", + "ui_order": 12, + "ui_widget": "secret_list", + "items": { "type": "string" }, + "description": "可选 Groq API Key 池。遇到单个 key 限流时会自动切换下一个 key;为空时使用 groq_api_key。", + "sensitive": true + }, "ffmpeg_bin": { "type": "string", "default": "ffmpeg", @@ -238,10 +248,66 @@ }, "max_file_size_mb": { "type": "integer", - "default": 23, + "default": 12, "title": "Max File Size MB", "ui_order": 40, - "minimum": 1 + "minimum": 1, + "description": "Groq 音频分片目标上限。实际切分会额外保留安全余量,避免贴近上传限制。" + }, + "request_timeout_seconds": { + "type": "integer", + "default": 180, + "title": "Request Timeout Seconds", + "ui_order": 50, + "minimum": 1, + "description": "单个 Groq 转录请求的超时时间。" + }, + "request_max_retries": { + "type": "integer", + "default": 1, + "title": "Request Max Retries", + "ui_order": 60, + "minimum": 0, + "description": "单个音频分片在超时、限流或连接错误时的请求级重试次数。" + }, + "request_retry_backoff_seconds": { + "type": "integer", + "default": 30, + "title": "Request Retry Backoff Seconds", + "ui_order": 70, + "minimum": 0, + "description": "Groq 请求级重试之间的等待时间。" + }, + "serialize_groq_requests": { + "type": "boolean", + "default": true, + "title": "Serialize Groq Requests", + "ui_order": 75, + "description": "是否串行化 Groq 分片上传请求,避免多个 worker 或多个任务同时上传导致超时。" + }, + "retry_count": { + "type": "integer", + "default": 3, + "title": "Task Retry Count", + "ui_order": 80, + "minimum": 0, + "description": "transcribe 步骤允许的任务级失败重试次数。" + }, + "retry_backoff_seconds": { + "type": "integer", + "default": 300, + "title": "Task Retry Backoff Seconds", + "ui_order": 90, + "minimum": 0, + "description": "未配置 retry_schedule_minutes 时,transcribe 任务级重试的等待时间。" + }, + "retry_schedule_minutes": { + "type": "array", + "default": [5, 10, 15], + "title": "Task Retry Schedule Minutes", + "ui_order": 100, + "items": { "type": "integer", "minimum": 0 }, + "description": "transcribe 任务级失败后的自动重试等待时间。" } }, "song_detect": { @@ -275,6 +341,30 @@ "title": "Poll Interval Seconds", "ui_order": 30, "minimum": 1 + }, + "retry_count": { + "type": "integer", + "default": 3, + "title": "Task Retry Count", + "ui_order": 40, + "minimum": 0, + "description": "song_detect 步骤允许的任务级失败重试次数。认证失败会直接进入人工失败,不会重试。" + }, + "retry_backoff_seconds": { + "type": "integer", + "default": 300, + "title": "Task Retry Backoff Seconds", + "ui_order": 50, + "minimum": 0, + "description": "未配置 retry_schedule_minutes 时,song_detect 任务级重试的等待时间。" + }, + "retry_schedule_minutes": { + "type": "array", + "default": [5, 10, 15], + "title": "Task Retry Schedule Minutes", + "ui_order": 60, + "items": { "type": "integer", "minimum": 0 }, + "description": "song_detect 任务级失败后的自动重试等待时间。" } }, "split": { @@ -375,9 +465,9 @@ "rate_limit_retry_schedule_minutes": { "type": "array", "default": [ + 15, 30, - 60, - 120 + 60 ], "title": "Rate Limit Retry Schedule Minutes", "ui_order": 70, diff --git a/config/settings.standalone.example.json b/config/settings.standalone.example.json index c02c24e..cea63ae 100644 --- a/config/settings.standalone.example.json +++ b/config/settings.standalone.example.json @@ -1,69 +1,70 @@ -{ - "runtime": { - "database_path": "data/workspace/biliup_next.db", - "control_token": "", - "log_level": "INFO" - }, - "paths": { - "stage_dir": "data/workspace/stage", - "backup_dir": "data/workspace/backup", - "session_dir": "data/workspace/session", - "cookies_file": "runtime/cookies.json", - "upload_config_file": "runtime/upload_config.json" - }, - "ingest": { - "provider": "local_file", - "min_duration_seconds": 900, - "ffprobe_bin": "ffprobe", - "yt_dlp_cmd": "yt-dlp", - "yt_dlp_format": "", - "allowed_extensions": [".mp4", ".flv", ".mkv", ".mov"], - "stage_min_free_space_mb": 2048, - "stability_wait_seconds": 30, - "session_gap_minutes": 60, - "meta_sidecar_enabled": true, - "meta_sidecar_suffix": ".meta.json" - }, - "transcribe": { +{ + "runtime": { + "database_path": "data/workspace/biliup_next.db", + "control_token": "", + "log_level": "INFO" + }, + "paths": { + "stage_dir": "data/workspace/stage", + "backup_dir": "data/workspace/backup", + "session_dir": "data/workspace/session", + "cookies_file": "runtime/cookies.json", + "upload_config_file": "runtime/upload_config.json" + }, + "ingest": { + "provider": "local_file", + "min_duration_seconds": 900, + "ffprobe_bin": "ffprobe", + "yt_dlp_cmd": "yt-dlp", + "yt_dlp_format": "", + "allowed_extensions": [".mp4", ".flv", ".mkv", ".mov"], + "stage_min_free_space_mb": 2048, + "stability_wait_seconds": 30, + "session_gap_minutes": 60, + "meta_sidecar_enabled": true, + "meta_sidecar_suffix": ".meta.json" + }, + "transcribe": { "provider": "groq", "groq_api_key": "", + "groq_api_keys": [], "ffmpeg_bin": "ffmpeg", - "max_file_size_mb": 23 - }, - "song_detect": { - "provider": "codex", - "codex_cmd": "codex", - "qwen_cmd": "qwen", - "poll_interval_seconds": 2 - }, - "split": { - "provider": "ffmpeg_copy", - "ffmpeg_bin": "ffmpeg", - "poll_interval_seconds": 2 - }, - "publish": { - "provider": "biliup_cli", - "biliup_path": "runtime/biliup", - "cookie_file": "runtime/cookies.json", - "retry_count": 5, - "retry_backoff_seconds": 300, - "command_timeout_seconds": 1800, - "rate_limit_retry_schedule_minutes": [30, 60, 120] - }, - "comment": { - "provider": "bilibili_top_comment", - "enabled": true, - "max_retries": 5, - "base_delay_seconds": 180, - "poll_interval_seconds": 10 - }, - "collection": { - "provider": "bilibili_collection", - "enabled": true, - "season_id_a": 7196643, - "season_id_b": 7196624, - "allow_fuzzy_full_video_match": false, - "append_collection_a_new_to_end": true, - "append_collection_b_new_to_end": true - } -} + "max_file_size_mb": 23 + }, + "song_detect": { + "provider": "codex", + "codex_cmd": "codex", + "qwen_cmd": "qwen", + "poll_interval_seconds": 2 + }, + "split": { + "provider": "ffmpeg_copy", + "ffmpeg_bin": "ffmpeg", + "poll_interval_seconds": 2 + }, + "publish": { + "provider": "biliup_cli", + "biliup_path": "runtime/biliup", + "cookie_file": "runtime/cookies.json", + "retry_count": 5, + "retry_backoff_seconds": 300, + "command_timeout_seconds": 1800, + "rate_limit_retry_schedule_minutes": [30, 60, 120] + }, + "comment": { + "provider": "bilibili_top_comment", + "enabled": true, + "max_retries": 5, + "base_delay_seconds": 180, + "poll_interval_seconds": 10 + }, + "collection": { + "provider": "bilibili_collection", + "enabled": true, + "season_id_a": 7196643, + "season_id_b": 7196624, + "allow_fuzzy_full_video_match": false, + "append_collection_a_new_to_end": true, + "append_collection_b_new_to_end": true + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..821b942 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +services: + api: + build: + context: . + args: + HTTP_PROXY: ${DOCKER_BUILD_HTTP_PROXY:-} + HTTPS_PROXY: ${DOCKER_BUILD_HTTPS_PROXY:-} + ALL_PROXY: ${DOCKER_BUILD_ALL_PROXY:-} + NO_PROXY: ${DOCKER_BUILD_NO_PROXY:-} + http_proxy: ${DOCKER_BUILD_HTTP_PROXY:-} + https_proxy: ${DOCKER_BUILD_HTTPS_PROXY:-} + all_proxy: ${DOCKER_BUILD_ALL_PROXY:-} + no_proxy: ${DOCKER_BUILD_NO_PROXY:-} + image: ${BILIUP_NEXT_IMAGE:-biliup-next:local} + command: ["biliup-next", "serve", "--host", "0.0.0.0", "--port", "8000"] + env_file: + - path: .env + required: false + environment: + TZ: ${TZ:-Asia/Shanghai} + BILIUP_NEXT__RUNTIME__DATABASE_PATH: /app/data/workspace/biliup_next.db + BILIUP_NEXT__PATHS__STAGE_DIR: /app/data/workspace/stage + BILIUP_NEXT__PATHS__BACKUP_DIR: /app/data/workspace/backup + BILIUP_NEXT__PATHS__SESSION_DIR: /app/data/workspace/session + BILIUP_NEXT__PATHS__COOKIES_FILE: /app/runtime/cookies.json + BILIUP_NEXT__PATHS__UPLOAD_CONFIG_FILE: /app/runtime/upload_config.json + BILIUP_NEXT__INGEST__YT_DLP_CMD: yt-dlp + BILIUP_NEXT__PUBLISH__BILIUP_PATH: /app/runtime/biliup + BILIUP_NEXT__PUBLISH__COOKIE_FILE: /app/runtime/cookies.json + ports: + - "${BILIUP_NEXT_PORT:-8000}:8000" + volumes: + - ./config:/app/config + - ./runtime:/app/runtime + - ./data/workspace:/app/data/workspace + - ./runtime/codex:/root/.codex + restart: unless-stopped + + worker: + image: ${BILIUP_NEXT_IMAGE:-biliup-next:local} + build: + context: . + args: + HTTP_PROXY: ${DOCKER_BUILD_HTTP_PROXY:-} + HTTPS_PROXY: ${DOCKER_BUILD_HTTPS_PROXY:-} + ALL_PROXY: ${DOCKER_BUILD_ALL_PROXY:-} + NO_PROXY: ${DOCKER_BUILD_NO_PROXY:-} + http_proxy: ${DOCKER_BUILD_HTTP_PROXY:-} + https_proxy: ${DOCKER_BUILD_HTTPS_PROXY:-} + all_proxy: ${DOCKER_BUILD_ALL_PROXY:-} + no_proxy: ${DOCKER_BUILD_NO_PROXY:-} + command: ["sh", "-c", "biliup-next worker --interval ${WORKER_INTERVAL:-5}"] + env_file: + - path: .env + required: false + environment: + TZ: ${TZ:-Asia/Shanghai} + BILIUP_NEXT__RUNTIME__DATABASE_PATH: /app/data/workspace/biliup_next.db + BILIUP_NEXT__PATHS__STAGE_DIR: /app/data/workspace/stage + BILIUP_NEXT__PATHS__BACKUP_DIR: /app/data/workspace/backup + BILIUP_NEXT__PATHS__SESSION_DIR: /app/data/workspace/session + BILIUP_NEXT__PATHS__COOKIES_FILE: /app/runtime/cookies.json + BILIUP_NEXT__PATHS__UPLOAD_CONFIG_FILE: /app/runtime/upload_config.json + BILIUP_NEXT__INGEST__YT_DLP_CMD: yt-dlp + BILIUP_NEXT__PUBLISH__BILIUP_PATH: /app/runtime/biliup + BILIUP_NEXT__PUBLISH__COOKIE_FILE: /app/runtime/cookies.json + volumes: + - ./config:/app/config + - ./runtime:/app/runtime + - ./data/workspace:/app/data/workspace + - ./runtime/codex:/root/.codex + restart: unless-stopped + depends_on: + - api diff --git a/docs/adr/0001-modular-monolith.md b/docs/adr/0001-modular-monolith.md index 39f33dd..8aeb0ff 100644 --- a/docs/adr/0001-modular-monolith.md +++ b/docs/adr/0001-modular-monolith.md @@ -1,64 +1,64 @@ -# ADR 0001: Use A Modular Monolith As The Target Architecture - -## Status - -Accepted - -## Context - -当前项目由多个 Python 脚本、目录扫描逻辑、flag 文件和外部命令拼接而成。 - -主要问题: - -- 状态不统一 -- 配置不统一 -- 重复逻辑多 -- 扩展新功能需要继续增加脚本 -- 运维和业务边界不清晰 - -重构目标要求: - -- 可扩展 -- 可配置 -- 可观测 -- 易部署 -- 易文档化 - -## Decision - -新系统采用模块化单体架构,而不是: - -- 继续维护脚本集合 -- 直接拆成微服务 - -## Rationale - -选择模块化单体的原因: - -- 当前系统规模和团队协作模式不需要微服务 -- 单机部署和本地运维是核心需求 -- 统一数据库、配置和日志对当前问题最直接有效 -- 模块化单体足以提供清晰边界和未来插件扩展能力 - -## Consequences - -正面影响: - -- 部署简单 -- 重构成本可控 -- 便于引入统一状态机和管理 API -- 后续可以逐步插件化 - -负面影响: - -- 需要严格维持模块边界,避免重新长成“大脚本” -- 单进程内错误隔离不如微服务天然 - -## Follow-Up Decisions - -后续还需要补充的 ADR: - -- 是否使用 SQLite 作为主状态存储 -- 是否引入事件总线 -- 插件机制如何注册 -- 管理台采用什么技术栈 +# ADR 0001: Use A Modular Monolith As The Target Architecture + +## Status + +Accepted + +## Context + +当前项目由多个 Python 脚本、目录扫描逻辑、flag 文件和外部命令拼接而成。 + +主要问题: + +- 状态不统一 +- 配置不统一 +- 重复逻辑多 +- 扩展新功能需要继续增加脚本 +- 运维和业务边界不清晰 + +重构目标要求: + +- 可扩展 +- 可配置 +- 可观测 +- 易部署 +- 易文档化 + +## Decision + +新系统采用模块化单体架构,而不是: + +- 继续维护脚本集合 +- 直接拆成微服务 + +## Rationale + +选择模块化单体的原因: + +- 当前系统规模和团队协作模式不需要微服务 +- 单机部署和本地运维是核心需求 +- 统一数据库、配置和日志对当前问题最直接有效 +- 模块化单体足以提供清晰边界和未来插件扩展能力 + +## Consequences + +正面影响: + +- 部署简单 +- 重构成本可控 +- 便于引入统一状态机和管理 API +- 后续可以逐步插件化 + +负面影响: + +- 需要严格维持模块边界,避免重新长成“大脚本” +- 单进程内错误隔离不如微服务天然 + +## Follow-Up Decisions + +后续还需要补充的 ADR: + +- 是否使用 SQLite 作为主状态存储 +- 是否引入事件总线 +- 插件机制如何注册 +- 管理台采用什么技术栈 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 36fdece..0ab1388 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,251 +1,251 @@ -openapi: 3.1.0 -info: - title: biliup-next Control API - version: 0.1.0 - summary: 本地 worker、任务和控制台 API -servers: - - url: http://127.0.0.1:8787 -paths: - /: - get: - summary: 控制台首页 - responses: - "200": - description: HTML dashboard - /health: - get: - summary: 健康检查 - responses: - "200": - description: OK - /settings: - get: - summary: 获取当前设置 - responses: - "200": - description: 当前配置,敏感字段已掩码 - put: - summary: 更新设置 - responses: - "200": - description: 保存成功 - /settings/schema: - get: - summary: 获取 settings schema - responses: - "200": - description: schema-first UI 元数据 - /doctor: - get: - summary: 运行时依赖检查 - responses: - "200": - description: doctor result - /modules: - get: - summary: 查询已注册模块与 manifest - responses: - "200": - description: module list - /runtime/services: - get: - summary: 查询 systemd 服务状态 - responses: - "200": - description: service list - /runtime/services/{serviceId}/{action}: - post: - summary: 执行 service start/stop/restart - parameters: - - in: path - name: serviceId - required: true - schema: - type: string - - in: path - name: action - required: true - schema: - type: string - enum: [start, stop, restart] - responses: - "202": - description: action accepted - /logs: - get: - summary: 查询日志列表或日志内容 - parameters: - - in: query - name: name - schema: - type: string - - in: query - name: lines - schema: - type: integer - - in: query - name: contains - schema: - type: string - responses: - "200": - description: log list or log content - /history: - get: - summary: 查询全局动作流 - parameters: - - in: query - name: task_id - schema: - type: string - - in: query - name: action_name - schema: - type: string - - in: query - name: status - schema: - type: string - - in: query - name: limit - schema: - type: integer - responses: - "200": - description: action records - /tasks: - get: - summary: 查询任务列表 - parameters: - - in: query - name: limit - schema: - type: integer - responses: - "200": - description: task list - post: - summary: 从 source_path 手动创建任务 - responses: - "201": - description: task created - /webhooks/full-video-uploaded: - post: - summary: 接收原视频上传成功后的完整版 BV webhook - responses: - "202": - description: accepted - /tasks/{taskId}: - get: - summary: 查询任务详情 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "200": - description: task detail - /tasks/{taskId}/steps: - get: - summary: 查询任务步骤 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "200": - description: task steps - /tasks/{taskId}/artifacts: - get: - summary: 查询任务产物 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "200": - description: task artifacts - /tasks/{taskId}/history: - get: - summary: 查询单任务动作历史 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "200": - description: task action history - /tasks/{taskId}/timeline: - get: - summary: 查询单任务时间线 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "200": - description: task timeline - /tasks/{taskId}/actions/run: - post: - summary: 推进单个任务 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "202": - description: accepted - /tasks/{taskId}/actions/retry-step: - post: - summary: 从指定 step 重试 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "202": - description: accepted - /tasks/{taskId}/actions/reset-to-step: - post: - summary: 重置到指定 step 并重跑 - parameters: - - in: path - name: taskId - required: true - schema: - type: string - responses: - "202": - description: accepted - /worker/run-once: - post: - summary: 执行一轮 worker - responses: - "202": - description: accepted - /stage/import: - post: - summary: 从本机已有绝对路径复制到隔离 stage - responses: - "201": - description: imported - /stage/upload: - post: - summary: 上传文件到隔离 stage - responses: - "201": - description: uploaded +openapi: 3.1.0 +info: + title: biliup-next Control API + version: 0.1.0 + summary: 本地 worker、任务和控制台 API +servers: + - url: http://127.0.0.1:8787 +paths: + /: + get: + summary: 控制台首页 + responses: + "200": + description: HTML dashboard + /health: + get: + summary: 健康检查 + responses: + "200": + description: OK + /settings: + get: + summary: 获取当前设置 + responses: + "200": + description: 当前配置,敏感字段已掩码 + put: + summary: 更新设置 + responses: + "200": + description: 保存成功 + /settings/schema: + get: + summary: 获取 settings schema + responses: + "200": + description: schema-first UI 元数据 + /doctor: + get: + summary: 运行时依赖检查 + responses: + "200": + description: doctor result + /modules: + get: + summary: 查询已注册模块与 manifest + responses: + "200": + description: module list + /runtime/services: + get: + summary: 查询 systemd 服务状态 + responses: + "200": + description: service list + /runtime/services/{serviceId}/{action}: + post: + summary: 执行 service start/stop/restart + parameters: + - in: path + name: serviceId + required: true + schema: + type: string + - in: path + name: action + required: true + schema: + type: string + enum: [start, stop, restart] + responses: + "202": + description: action accepted + /logs: + get: + summary: 查询日志列表或日志内容 + parameters: + - in: query + name: name + schema: + type: string + - in: query + name: lines + schema: + type: integer + - in: query + name: contains + schema: + type: string + responses: + "200": + description: log list or log content + /history: + get: + summary: 查询全局动作流 + parameters: + - in: query + name: task_id + schema: + type: string + - in: query + name: action_name + schema: + type: string + - in: query + name: status + schema: + type: string + - in: query + name: limit + schema: + type: integer + responses: + "200": + description: action records + /tasks: + get: + summary: 查询任务列表 + parameters: + - in: query + name: limit + schema: + type: integer + responses: + "200": + description: task list + post: + summary: 从 source_path 手动创建任务 + responses: + "201": + description: task created + /webhooks/full-video-uploaded: + post: + summary: 接收原视频上传成功后的完整版 BV webhook + responses: + "202": + description: accepted + /tasks/{taskId}: + get: + summary: 查询任务详情 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "200": + description: task detail + /tasks/{taskId}/steps: + get: + summary: 查询任务步骤 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "200": + description: task steps + /tasks/{taskId}/artifacts: + get: + summary: 查询任务产物 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "200": + description: task artifacts + /tasks/{taskId}/history: + get: + summary: 查询单任务动作历史 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "200": + description: task action history + /tasks/{taskId}/timeline: + get: + summary: 查询单任务时间线 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "200": + description: task timeline + /tasks/{taskId}/actions/run: + post: + summary: 推进单个任务 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "202": + description: accepted + /tasks/{taskId}/actions/retry-step: + post: + summary: 从指定 step 重试 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "202": + description: accepted + /tasks/{taskId}/actions/reset-to-step: + post: + summary: 重置到指定 step 并重跑 + parameters: + - in: path + name: taskId + required: true + schema: + type: string + responses: + "202": + description: accepted + /worker/run-once: + post: + summary: 执行一轮 worker + responses: + "202": + description: accepted + /stage/import: + post: + summary: 从本机已有绝对路径复制到隔离 stage + responses: + "201": + description: imported + /stage/upload: + post: + summary: 上传文件到隔离 stage + responses: + "201": + description: uploaded diff --git a/docs/architecture.md b/docs/architecture.md index 6cb1c15..ef59f9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,203 +1,203 @@ -# Architecture - -## Architecture Style - -采用模块化单体架构。 - -原因: - -- 当前规模不需要微服务 -- 需要统一配置、状态和任务模型 -- 需要较低部署复杂度 -- 需要明确模块边界和未来插件扩展能力 - -## High-Level Layers - -### 1. Core - -核心领域层,不依赖具体外部服务。 - -- 领域模型 -- 状态机 -- 任务编排接口 -- 事件定义 -- 配置模型 - -### 2. Modules - -业务模块层,每个模块只关心自己的一步能力。 - -- `ingest` -- `transcribe` -- `song_detect` -- `split` -- `publish` -- `comment` -- `collection` - -### 3. Infra - -基础设施层,对外部依赖做适配。 - -- 文件系统 -- SQLite 存储 -- Groq adapter -- Codex adapter -- FFmpeg adapter -- Bili API adapter -- biliup adapter -- 日志与审计 - -### 4. App - -应用层,对外暴露统一运行入口。 - -- API Server -- Worker -- Scheduler -- CLI -- Admin Web - -## Proposed Directory Layout - -```text -biliup-next/ - src/ - app/ - api/ - worker/ - scheduler/ - cli/ - core/ - models/ - services/ - events/ - state_machine/ - config/ - modules/ - ingest/ - transcribe/ - song_detect/ - split/ - publish/ - comment/ - collection/ - infra/ - db/ - fs/ - adapters/ - groq/ - codex/ - ffmpeg/ - bili/ - biliup/ - logging/ - plugins/ - docs/ - tests/ -``` - -## Runtime Components - -### API Server - -负责: - -- 配置管理 -- 任务查询 -- 手动操作 -- 日志聚合查询 -- 模块与插件可见性展示 - -### Worker - -负责: - -- 消费任务 -- 推进状态机 -- 执行模块步骤 - -### Scheduler - -负责: - -- 定时扫描待补偿任务 -- 定时同步外部状态 -- 触发重试 - -## Control Plane - -新系统应明确区分控制面和数据面。 - -### Control Plane - -负责: - -- 配置管理 -- 模块/插件注册 -- 任务可视化 -- 手动操作入口 -- 日志与诊断 - -### Data Plane - -负责: - -- 实际执行转录 -- 实际执行识歌 -- 实际执行切歌 -- 实际执行上传 -- 实际执行评论和合集归档 - -## Registry - -系统内部建立统一 registry,用于注册和查找模块能力。 - -例如: - -- 当前转录 provider -- 当前识歌 provider -- 当前上传 provider -- 当前合集策略 - -核心模块只依赖抽象接口和 registry,不直接依赖具体实现。 - -## Task Lifecycle - -```text -created - -> running - -> transcribed - -> running - -> songs_detected - -> running - -> split_done - -> running - -> published - -> running - -> commented - -> running - -> collection_synced -``` - -失败状态不结束任务,而是转入: - -- `failed_retryable` -- `failed_manual` - -## Data Ownership - -- SQLite:任务、步骤、产物索引、配置、审计记录 -- 文件系统:视频、字幕、切片、AI 输出、日志 -- 外部平台:B 站稿件、评论、合集 - -## Key Design Rules - -- 所有状态变更必须落库 -- 模块间只通过领域对象和事件通信 -- 外部依赖不可直接在业务模块中调用 shell 或 HTTP -- 配置统一由 `core.config` 读取 -- 管理端展示的数据优先来自数据库,不直接从日志推断 -- 工作区 flag 只表达交付副作用和产物标记,不作为 task 主状态事实源 -- 配置系统必须 schema-first -- 插件系统必须 manifest-first +# Architecture + +## Architecture Style + +采用模块化单体架构。 + +原因: + +- 当前规模不需要微服务 +- 需要统一配置、状态和任务模型 +- 需要较低部署复杂度 +- 需要明确模块边界和未来插件扩展能力 + +## High-Level Layers + +### 1. Core + +核心领域层,不依赖具体外部服务。 + +- 领域模型 +- 状态机 +- 任务编排接口 +- 事件定义 +- 配置模型 + +### 2. Modules + +业务模块层,每个模块只关心自己的一步能力。 + +- `ingest` +- `transcribe` +- `song_detect` +- `split` +- `publish` +- `comment` +- `collection` + +### 3. Infra + +基础设施层,对外部依赖做适配。 + +- 文件系统 +- SQLite 存储 +- Groq adapter +- Codex adapter +- FFmpeg adapter +- Bili API adapter +- biliup adapter +- 日志与审计 + +### 4. App + +应用层,对外暴露统一运行入口。 + +- API Server +- Worker +- Scheduler +- CLI +- Admin Web + +## Proposed Directory Layout + +```text +biliup-next/ + src/ + app/ + api/ + worker/ + scheduler/ + cli/ + core/ + models/ + services/ + events/ + state_machine/ + config/ + modules/ + ingest/ + transcribe/ + song_detect/ + split/ + publish/ + comment/ + collection/ + infra/ + db/ + fs/ + adapters/ + groq/ + codex/ + ffmpeg/ + bili/ + biliup/ + logging/ + plugins/ + docs/ + tests/ +``` + +## Runtime Components + +### API Server + +负责: + +- 配置管理 +- 任务查询 +- 手动操作 +- 日志聚合查询 +- 模块与插件可见性展示 + +### Worker + +负责: + +- 消费任务 +- 推进状态机 +- 执行模块步骤 + +### Scheduler + +负责: + +- 定时扫描待补偿任务 +- 定时同步外部状态 +- 触发重试 + +## Control Plane + +新系统应明确区分控制面和数据面。 + +### Control Plane + +负责: + +- 配置管理 +- 模块/插件注册 +- 任务可视化 +- 手动操作入口 +- 日志与诊断 + +### Data Plane + +负责: + +- 实际执行转录 +- 实际执行识歌 +- 实际执行切歌 +- 实际执行上传 +- 实际执行评论和合集归档 + +## Registry + +系统内部建立统一 registry,用于注册和查找模块能力。 + +例如: + +- 当前转录 provider +- 当前识歌 provider +- 当前上传 provider +- 当前合集策略 + +核心模块只依赖抽象接口和 registry,不直接依赖具体实现。 + +## Task Lifecycle + +```text +created + -> running + -> transcribed + -> running + -> songs_detected + -> running + -> split_done + -> running + -> published + -> running + -> commented + -> running + -> collection_synced +``` + +失败状态不结束任务,而是转入: + +- `failed_retryable` +- `failed_manual` + +## Data Ownership + +- SQLite:任务、步骤、产物索引、配置、审计记录 +- 文件系统:视频、字幕、切片、AI 输出、日志 +- 外部平台:B 站稿件、评论、合集 + +## Key Design Rules + +- 所有状态变更必须落库 +- 模块间只通过领域对象和事件通信 +- 外部依赖不可直接在业务模块中调用 shell 或 HTTP +- 配置统一由 `core.config` 读取 +- 管理端展示的数据优先来自数据库,不直接从日志推断 +- 工作区 flag 只表达交付副作用和产物标记,不作为 task 主状态事实源 +- 配置系统必须 schema-first +- 插件系统必须 manifest-first diff --git a/docs/cold-start-checklist.md b/docs/cold-start-checklist.md index a278c68..c80a964 100644 --- a/docs/cold-start-checklist.md +++ b/docs/cold-start-checklist.md @@ -1,82 +1,82 @@ -# biliup-next Cold Start Checklist - -目标:在一台没有旧环境残留的新机器上,把 `biliup-next` 启动到“可配置、可 doctor、可进入控制面”的状态。 - -## 1. 基础环境 - -- 安装 `python3` -- 安装 `ffmpeg` 和 `ffprobe` -- 如需完整歌曲识别,安装 `codex` -- 如需完整上传链路,准备 `biliup` 可执行文件 - -## 2. 获取项目 - -```bash -git clone biliup -cd biliup/biliup-next -``` - -## 3. 一键初始化 - -```bash -bash setup.sh -``` - -初始化完成后,项目会自动生成: - -- `config/settings.json` -- `config/settings.staged.json` -- `runtime/cookies.json` -- `runtime/upload_config.json` -- `data/workspace/*` - -注意: - -- 这些文件默认都是模板或占位内容 -- 此时项目应当已经能执行 `doctor`,但不代表上传链路已经可用 - -## 4. 填写真实运行资产 - -- 编辑 `runtime/cookies.json` -- 编辑 `runtime/upload_config.json` -- 把 `biliup` 放到 `runtime/biliup`,或在 `settings.json` 里改成系统路径 -- 填写 `transcribe.groq_api_key` -- 按机器实际情况调整 `song_detect.provider` -- 如果用 `codex`,调整 `song_detect.codex_cmd` -- 如果用 `qwen_cli`,调整 `song_detect.qwen_cmd` -- 按需要填写 `collection.season_id_a` / `collection.season_id_b` - -## 5. 验收 - -```bash -./.venv/bin/biliup-next doctor -./.venv/bin/biliup-next init-workspace -./.venv/bin/biliup-next serve --host 127.0.0.1 --port 8787 -bash cold-start-smoke.sh -``` - -浏览器打开: - -```text -http://127.0.0.1:8787/ -``` - -验收通过标准: - -- `doctor` 输出可读,缺失项只剩你尚未填写的外部依赖 -- 控制面可以打开 -- `Settings` 页可正常保存 -- `stage` 目录可导入或上传文件 -- `cold-start-smoke.sh` 能完整通过 - -## 6. 完整链路前检查 - -在开始真实处理前,确认以下项目已经真实可用: - -- `runtime/cookies.json` -- `runtime/upload_config.json` -- `publish.biliup_path` -- `song_detect.provider` -- `song_detect.codex_cmd` 或 `song_detect.qwen_cmd` -- `transcribe.groq_api_key` -- `collection.season_id_a` / `collection.season_id_b` +# biliup-next Cold Start Checklist + +目标:在一台没有旧环境残留的新机器上,把 `biliup-next` 启动到“可配置、可 doctor、可进入控制面”的状态。 + +## 1. 基础环境 + +- 安装 `python3` +- 安装 `ffmpeg` 和 `ffprobe` +- 如需完整歌曲识别,安装 `codex` +- 如需完整上传链路,准备 `biliup` 可执行文件 + +## 2. 获取项目 + +```bash +git clone biliup +cd biliup/biliup-next +``` + +## 3. 一键初始化 + +```bash +bash setup.sh +``` + +初始化完成后,项目会自动生成: + +- `config/settings.json` +- `config/settings.staged.json` +- `runtime/cookies.json` +- `runtime/upload_config.json` +- `data/workspace/*` + +注意: + +- 这些文件默认都是模板或占位内容 +- 此时项目应当已经能执行 `doctor`,但不代表上传链路已经可用 + +## 4. 填写真实运行资产 + +- 编辑 `runtime/cookies.json` +- 编辑 `runtime/upload_config.json` +- 把 `biliup` 放到 `runtime/biliup`,或在 `settings.json` 里改成系统路径 +- 填写 `transcribe.groq_api_key` +- 按机器实际情况调整 `song_detect.provider` +- 如果用 `codex`,调整 `song_detect.codex_cmd` +- 如果用 `qwen_cli`,调整 `song_detect.qwen_cmd` +- 按需要填写 `collection.season_id_a` / `collection.season_id_b` + +## 5. 验收 + +```bash +./.venv/bin/biliup-next doctor +./.venv/bin/biliup-next init-workspace +./.venv/bin/biliup-next serve --host 127.0.0.1 --port 8787 +bash cold-start-smoke.sh +``` + +浏览器打开: + +```text +http://127.0.0.1:8787/ +``` + +验收通过标准: + +- `doctor` 输出可读,缺失项只剩你尚未填写的外部依赖 +- 控制面可以打开 +- `Settings` 页可正常保存 +- `stage` 目录可导入或上传文件 +- `cold-start-smoke.sh` 能完整通过 + +## 6. 完整链路前检查 + +在开始真实处理前,确认以下项目已经真实可用: + +- `runtime/cookies.json` +- `runtime/upload_config.json` +- `publish.biliup_path` +- `song_detect.provider` +- `song_detect.codex_cmd` 或 `song_detect.qwen_cmd` +- `transcribe.groq_api_key` +- `collection.season_id_a` / `collection.season_id_b` diff --git a/docs/config-system.md b/docs/config-system.md index 996da1f..b20fd52 100644 --- a/docs/config-system.md +++ b/docs/config-system.md @@ -155,6 +155,60 @@ User edits config - `base_delay_seconds` - `poll_interval_seconds` +## Upload And Comment Templates + +`paths.upload_config_file` 指向 `runtime/upload_config.json`。这个文件不只控制 `biliup upload` 的标题、简介、动态和标签,也控制 B 站置顶评论格式。 + +投稿字段在 `template` 中: + +```json +{ + "template": { + "title": "【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌", + "description": "{streamer} {date} 歌曲纯享版。\n\n完整歌单与时间轴见置顶评论。\n直播完整版:{current_full_video_link}\n上次直播:{previous_full_video_link}", + "tag": "可爱,王海颖,唱歌,音乐", + "dynamic": "{streamer} {date} 歌曲纯享版已发布。\n直播完整版:{current_full_video_link}" + } +} +``` + +评论字段在 `comment_template` 中: + +```json +{ + "comment_template": { + "split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版:{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享:{previous_pure_video_link} (上一场歌曲纯享版)", + "full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版:{current_pure_video_link} (只听歌曲看这里)\n上次完整版:{previous_full_video_link} (上一场完整录播)", + "split_part_header": "P{part_index}:", + "full_part_header": "P{part_index}:", + "split_song_line": "{song_index}. {title}{artist_suffix}", + "split_text_song_line": "{song_index}. {song_text}", + "full_timeline_line": "{song_index}. {line_text}" + } +} +``` + +可用变量: + +- `streamer`:主播名。 +- `date`:从文件名解析出来的日期和时间。 +- `song_count`:识别到的歌曲数量。 +- `songs_list`:`songs.txt` 原始歌单内容。 +- `daily_quote` / `quote_author`:随机引用文本。 +- `current_full_video_bvid` / `current_full_video_link`:本场直播完整版 BV 和链接。 +- `current_pure_video_bvid` / `current_pure_video_link`:本场歌曲纯享版 BV 和链接。 +- `previous_full_video_bvid` / `previous_full_video_link`:上一场直播完整版 BV 和链接。 +- `previous_pure_video_bvid` / `previous_pure_video_link`:上一场歌曲纯享版 BV 和链接。 +- `part_index`:评论中的 `P1/P2/P3` 分段序号。 +- `song_index`:全局歌曲序号。 +- `title` / `artist` / `artist_suffix`:从 `songs.json` 生成纯享歌单时使用。 +- `song_text`:从 `songs.txt` 兜底生成纯享歌单时使用,通常不含时间戳。 +- `line_text`:完整版时间轴的原始行,通常包含时间戳。 + +评论头部模板有一条额外规则:如果某一行包含空链接变量,例如 `{previous_full_video_link}` 为空,这一整行会自动跳过,避免发出空链接提示。 + +Docker 部署时 `./runtime` 是宿主机挂载目录。镜像更新不会覆盖已有 `runtime/upload_config.json`,因此调整文案或评论格式时应修改宿主机上的这个文件,然后重启容器。 + ### collection - `enabled` diff --git a/docs/control-plane-guide.md b/docs/control-plane-guide.md index 7e1a66d..a6fb23c 100644 --- a/docs/control-plane-guide.md +++ b/docs/control-plane-guide.md @@ -1,491 +1,491 @@ -# Control Plane Guide - -本文档面向 `biliup-next` 控制台的日常使用。 - -默认地址: - -```text -http://127.0.0.1:8787/ -``` - -如果当前机器已经开放公网访问,也可以使用服务器 IP + `8787` 端口访问。 - -## 页面分区 - -控制台主要分成两列: - -- 左侧:全局状态、服务、动作流、导入入口、任务列表、Settings -- 右侧:当前任务详情、步骤、产物、历史、时间线、模块、日志 - -建议的使用顺序是: - -1. 先看 `Runtime` -2. 再看 `Tasks` -3. 选中一个任务后,看右侧详情 -4. 如果任务异常,再看 `Logs` 和 `History` - -## Runtime - -这里可以看 3 个汇总指标: - -- `Health` -- `Doctor` -- `Tasks` - -含义: - -- `Health = OK` - - API 服务本身还活着 -- `Doctor = OK` - - 关键路径、二进制和依赖文件都存在 -- `Tasks` - - 当前数据库里的任务数 - -如果 `Doctor` 不是 `OK`,优先不要继续点任务操作,先修运行环境。 - -## Services - -这里可以查看并控制: - -- `biliup-next-worker.service` -- `biliup-next-api.service` -- 如果还保留旧服务,也可能看到 `biliup-python.service` - -可执行操作: - -- `start` -- `restart` -- `stop` - -建议: - -- 页面打不开时,先看 `biliup-next-api.service` -- 任务不推进时,先看 `biliup-next-worker.service` -- 不要随便再启动旧 `biliup-python.service`,除非你明确知道自己要同时跑旧链路 - -## Recent Actions - -这里显示最近的控制面动作流,例如: - -- `worker_run_once` -- `task_run` -- `retry_step` -- `reset_to_step` -- `stage_import` -- `stage_upload` -- `service_action` - -可过滤: - -- 仅当前任务 -- `status` -- `action_name` - -用途: - -- 判断最近有没有人工操作过任务 -- 判断任务是不是被重试过 -- 判断服务是不是被重启过 - -## Import To Stage - -这里有两种入口。 - -### 1. 复制本机已有文件到隔离 stage - -输入服务器上的绝对路径,例如: - -```text -/home/theshy/video/test.mp4 -``` - -点击: - -```text -复制到隔离 Stage -``` - -适合: - -- 服务器本地已有文件 -- 想快速把已有文件丢进新系统测试 - -### 2. 浏览器直接上传文件 - -选择本地文件后点击: - -```text -上传到隔离 Stage -``` - -适合: - -- 本地电脑上的测试视频 -- 不想先手动传到服务器 - -上传成功后,`worker` 会自动扫描 `stage` 并开始建任务。 - -## Tasks - -这里列出任务列表。 - -每个任务会显示: - -- 标题 -- 当前状态 -- 任务 ID - -常见状态: - -- `created` -- `transcribed` -- `songs_detected` -- `split_done` -- `published` -- `commented` -- `collection_synced` -- `failed_retryable` -- `failed_manual` - -建议: - -- 先选中你关心的任务 -- 再看右侧 `Task Detail / Steps / Logs` - -## Task Detail - -显示当前选中任务的核心信息: - -- `Task ID` -- `Status` -- `Title` -- `Source` -- `Created` -- `Updated` - -上方有 3 个操作按钮: - -- `执行当前任务` -- `重试选中 Step` -- `重置到选中 Step` - -### 执行当前任务 - -作用: - -- 手动推进当前任务一次 - -适合: - -- 你刚改完配置 -- 你不想等下一轮 worker 轮询 - -### 重试选中 Step - -作用: - -- 从当前选中的 step 重新尝试 - -适合: - -- 某一步是临时失败 -- 不需要删除后续产物 - -### 重置到选中 Step - -作用: - -- 清理该 step 之后的产物和标记 -- 把任务回拨到该 step -- 然后重新执行 - -适合: - -- 后续结果已经不可信 -- 需要从某一步重新跑整段链路 - -注意: - -- 这是破坏性动作 -- 页面会要求确认 - -## Steps - -这里显示任务步骤列表,例如: - -- `ingest` -- `transcribe` -- `song_detect` -- `split` -- `publish` -- `comment` -- `collection_a` -- `collection_b` - -点击某个 step 之后: - -- 这个 step 会成为“当前选中 step” -- 然后你就可以点: - - `重试选中 Step` - - `重置到选中 Step` - -排查原则: - -- `transcribe` 失败:先看 `Groq API Key`、`ffmpeg` -- `song_detect` 失败:先看 `song_detect.provider`,再看 `codex_cmd` 或 `qwen_cmd` -- `publish` 失败:先看 `cookies.json`、`biliup` -- `collection_*` 失败:再看任务历史和日志 - -评论规则补充: - -- `comment` - - 纯享版视频下默认发 session 级聚合“编号歌单” - - 内容按 `P1/P2/P3` 分组 - - 同一 session 只由 anchor task 发一次 - - 完整版主视频下默认才发 session 级“带时间轴评论” - - 内容按 `P1/P2/P3` 分组 - - 同一 session 只由 anchor task 发一次 - - 如果当前任务找不到 `full_video_bvid.txt`,也没能从最近发布列表解析出完整版 BV,主视频评论会跳过 - -session 规则补充: - -- 同主播、文件名时间 `3` 小时内的任务会自动归到同一 session -- session 的 `session_key` 使用最早片段标题 -- 同一 session 内: - - 只有 anchor task 真正执行纯享版上传 - - 纯享版上传会聚合整组 `split_video` - - 整组 task 共用同一个 `bvid.txt` - - split/full 评论都只发一次 - -## Artifacts - -这里显示任务当前已经产出的文件,例如: - -- `source_video` -- `subtitle_srt` -- `songs_json` -- `songs_txt` -- `clip_video` -- `publish_bvid` - -用途: - -- 判断任务跑到了哪一步 -- 判断关键输出是否已经落盘 - -如果开启了 cleanup 配置,任务在 `collection_synced` 后: - -- `source_video` 对应的原始视频可能会被删除 -- `clip_video` 对应的 `split_video/` 目录也可能被清理 - -这是正常收尾行为,不代表任务失败。 - -## History - -这里是单任务动作历史。 - -和 `Recent Actions` 的区别: - -- `Recent Actions` 看全局 -- `History` 只看当前任务 - -可以看到: - -- 动作名 -- 状态 -- 摘要 -- 时间 -- 结构化 `details_json` - -用途: - -- 判断某次 `retry` 或 `reset` 的结果 -- 判断 worker 最近对这个任务做了什么 - -## Timeline - -这里把任务事件串成一条时间线: - -- `Task Created` -- step started / finished -- artifact created -- action records - -适合: - -- 回放任务完整过程 -- 查清楚任务到底卡在什么时候 - -## Modules - -这里显示当前注册的模块 / provider。 - -用途: - -- 确认当前用的是哪套 provider -- 确认模块有没有注册成功 - -如果将来切 provider,这里会很有用。 - -## Doctor Checks - -这里比顶部的 `Doctor = OK/FAIL` 更详细。 - -会列出: - -- workspace 目录 -- `cookies_file` -- `upload_config_file` -- `ffprobe` -- `ffmpeg` -- `codex_cmd` 或 `qwen_cmd` -- `biliup_path` - -如果某个依赖显示 `(external)`,表示它还在用系统或父项目路径,不是 `biliup-next` 自己目录内的副本。 - -## Logs - -这里可以看日志文件。 - -支持: - -- 切换日志文件 -- 刷新日志 -- 按当前任务标题过滤 - -使用建议: - -- 任务异常时,先选中任务 -- 再勾选“按当前任务标题过滤” -- 然后查看相关日志 - -这样比直接翻整份日志快很多。 - -## Settings - -Settings 分成两层: - -- 上半部分:schema 驱动表单 -- 下半部分:`Advanced JSON Editor` - -### 表单区 - -这里适合日常参数调整,例如: - -- `min_duration_seconds` -- `groq_api_key` -- `codex_cmd` -- `qwen_cmd` -- `retry_count` -- `season_id_a` -- `season_id_b` -- `post_split_comment` -- `post_full_video_timeline_comment` -- `delete_source_video_after_collection_synced` -- `delete_split_videos_after_collection_synced` - -支持: - -- 搜索配置项 -- 高频参数优先展示 -- 低频参数收纳到 `Advanced Settings` - -### Advanced JSON Editor - -适合: - -- 批量调整 -- 一次改多个字段 -- 需要直接编辑原始 JSON - -页面上有两个同步按钮: - -- `表单同步到 JSON` -- `JSON 重绘表单` - -### 敏感字段规则 - -敏感字段会显示为: - -```text -__BILIUP_NEXT_SECRET__ -``` - -规则: - -- 保留占位符:不改原值 -- 改成空字符串:清空原值 -- 改成新的字符串:更新为新值 - -## 推荐操作流 - -### 新上传一个测试视频 - -1. 打开控制台 -2. 在 `Import To Stage` 上传视频 -3. 看 `Tasks` 是否出现新任务 -4. 选中该任务 -5. 观察 `Steps / Artifacts / Timeline` -6. 如需加速,点击 `执行当前任务` - -### 某个任务失败 - -1. 选中任务 -2. 看 `Task Detail` 当前状态 -3. 看 `Steps` 哪一步失败 -4. 看 `Logs` -5. 若只是临时失败: - - 选中 step - - 点 `重试选中 Step` -6. 若后续产物也需要重建: - - 选中 step - - 点 `重置到选中 Step` - -### 修改合集或上传参数 - -1. 打开 `Settings` -2. 搜索目标参数,例如 `season` / `retry` -3. 修改表单 -4. 点击 `保存 Settings` -5. 对目标任务执行: - - `执行当前任务` - - 或 `重置到相关 step` - -### 控制评论与清理行为 - -1. 打开 `Settings` -2. 搜索: - - `post_split_comment` - - `post_full_video_timeline_comment` - - `delete_source_video_after_collection_synced` - - `delete_split_videos_after_collection_synced` -3. 修改后保存 -4. 新任务会按新规则执行 - -建议: - -- 如果你希望纯享版评论以 session 级聚合歌单展示,保持 `post_split_comment = true` -- 如果你不希望尝试给完整版主视频发时间轴评论,可以关闭 `post_full_video_timeline_comment` -- 如果磁盘紧张,再开启 cleanup;默认建议先关闭,等确认流程稳定后再开 - -### 服务异常 - -1. 看 `Services` -2. 优先 `restart biliup-next-worker.service` -3. 如果页面自身异常,再 `restart biliup-next-api.service` -4. 重启后看: - - `Health` - - `Doctor` - - `Recent Actions` - -## 安全建议 - -当前控制台已经对公网开放时,建议立刻设置: - -- `runtime.control_token` - -设置后: - -- 除 `/` 和 `/health` 外,其余 API 都要求 `X-Biliup-Token` - -如果你通过公网访问控制台,不建议长期保持空 token。 +# Control Plane Guide + +本文档面向 `biliup-next` 控制台的日常使用。 + +默认地址: + +```text +http://127.0.0.1:8787/ +``` + +如果当前机器已经开放公网访问,也可以使用服务器 IP + `8787` 端口访问。 + +## 页面分区 + +控制台主要分成两列: + +- 左侧:全局状态、服务、动作流、导入入口、任务列表、Settings +- 右侧:当前任务详情、步骤、产物、历史、时间线、模块、日志 + +建议的使用顺序是: + +1. 先看 `Runtime` +2. 再看 `Tasks` +3. 选中一个任务后,看右侧详情 +4. 如果任务异常,再看 `Logs` 和 `History` + +## Runtime + +这里可以看 3 个汇总指标: + +- `Health` +- `Doctor` +- `Tasks` + +含义: + +- `Health = OK` + - API 服务本身还活着 +- `Doctor = OK` + - 关键路径、二进制和依赖文件都存在 +- `Tasks` + - 当前数据库里的任务数 + +如果 `Doctor` 不是 `OK`,优先不要继续点任务操作,先修运行环境。 + +## Services + +这里可以查看并控制: + +- `biliup-next-worker.service` +- `biliup-next-api.service` +- 如果还保留旧服务,也可能看到 `biliup-python.service` + +可执行操作: + +- `start` +- `restart` +- `stop` + +建议: + +- 页面打不开时,先看 `biliup-next-api.service` +- 任务不推进时,先看 `biliup-next-worker.service` +- 不要随便再启动旧 `biliup-python.service`,除非你明确知道自己要同时跑旧链路 + +## Recent Actions + +这里显示最近的控制面动作流,例如: + +- `worker_run_once` +- `task_run` +- `retry_step` +- `reset_to_step` +- `stage_import` +- `stage_upload` +- `service_action` + +可过滤: + +- 仅当前任务 +- `status` +- `action_name` + +用途: + +- 判断最近有没有人工操作过任务 +- 判断任务是不是被重试过 +- 判断服务是不是被重启过 + +## Import To Stage + +这里有两种入口。 + +### 1. 复制本机已有文件到隔离 stage + +输入服务器上的绝对路径,例如: + +```text +/home/theshy/video/test.mp4 +``` + +点击: + +```text +复制到隔离 Stage +``` + +适合: + +- 服务器本地已有文件 +- 想快速把已有文件丢进新系统测试 + +### 2. 浏览器直接上传文件 + +选择本地文件后点击: + +```text +上传到隔离 Stage +``` + +适合: + +- 本地电脑上的测试视频 +- 不想先手动传到服务器 + +上传成功后,`worker` 会自动扫描 `stage` 并开始建任务。 + +## Tasks + +这里列出任务列表。 + +每个任务会显示: + +- 标题 +- 当前状态 +- 任务 ID + +常见状态: + +- `created` +- `transcribed` +- `songs_detected` +- `split_done` +- `published` +- `commented` +- `collection_synced` +- `failed_retryable` +- `failed_manual` + +建议: + +- 先选中你关心的任务 +- 再看右侧 `Task Detail / Steps / Logs` + +## Task Detail + +显示当前选中任务的核心信息: + +- `Task ID` +- `Status` +- `Title` +- `Source` +- `Created` +- `Updated` + +上方有 3 个操作按钮: + +- `执行当前任务` +- `重试选中 Step` +- `重置到选中 Step` + +### 执行当前任务 + +作用: + +- 手动推进当前任务一次 + +适合: + +- 你刚改完配置 +- 你不想等下一轮 worker 轮询 + +### 重试选中 Step + +作用: + +- 从当前选中的 step 重新尝试 + +适合: + +- 某一步是临时失败 +- 不需要删除后续产物 + +### 重置到选中 Step + +作用: + +- 清理该 step 之后的产物和标记 +- 把任务回拨到该 step +- 然后重新执行 + +适合: + +- 后续结果已经不可信 +- 需要从某一步重新跑整段链路 + +注意: + +- 这是破坏性动作 +- 页面会要求确认 + +## Steps + +这里显示任务步骤列表,例如: + +- `ingest` +- `transcribe` +- `song_detect` +- `split` +- `publish` +- `comment` +- `collection_a` +- `collection_b` + +点击某个 step 之后: + +- 这个 step 会成为“当前选中 step” +- 然后你就可以点: + - `重试选中 Step` + - `重置到选中 Step` + +排查原则: + +- `transcribe` 失败:先看 `Groq API Key`、`ffmpeg` +- `song_detect` 失败:先看 `song_detect.provider`,再看 `codex_cmd` 或 `qwen_cmd` +- `publish` 失败:先看 `cookies.json`、`biliup` +- `collection_*` 失败:再看任务历史和日志 + +评论规则补充: + +- `comment` + - 纯享版视频下默认发 session 级聚合“编号歌单” + - 内容按 `P1/P2/P3` 分组 + - 同一 session 只由 anchor task 发一次 + - 完整版主视频下默认才发 session 级“带时间轴评论” + - 内容按 `P1/P2/P3` 分组 + - 同一 session 只由 anchor task 发一次 + - 如果当前任务找不到 `full_video_bvid.txt`,也没能从最近发布列表解析出完整版 BV,主视频评论会跳过 + +session 规则补充: + +- 同主播、文件名时间 `3` 小时内的任务会自动归到同一 session +- session 的 `session_key` 使用最早片段标题 +- 同一 session 内: + - 只有 anchor task 真正执行纯享版上传 + - 纯享版上传会聚合整组 `split_video` + - 整组 task 共用同一个 `bvid.txt` + - split/full 评论都只发一次 + +## Artifacts + +这里显示任务当前已经产出的文件,例如: + +- `source_video` +- `subtitle_srt` +- `songs_json` +- `songs_txt` +- `clip_video` +- `publish_bvid` + +用途: + +- 判断任务跑到了哪一步 +- 判断关键输出是否已经落盘 + +如果开启了 cleanup 配置,任务在 `collection_synced` 后: + +- `source_video` 对应的原始视频可能会被删除 +- `clip_video` 对应的 `split_video/` 目录也可能被清理 + +这是正常收尾行为,不代表任务失败。 + +## History + +这里是单任务动作历史。 + +和 `Recent Actions` 的区别: + +- `Recent Actions` 看全局 +- `History` 只看当前任务 + +可以看到: + +- 动作名 +- 状态 +- 摘要 +- 时间 +- 结构化 `details_json` + +用途: + +- 判断某次 `retry` 或 `reset` 的结果 +- 判断 worker 最近对这个任务做了什么 + +## Timeline + +这里把任务事件串成一条时间线: + +- `Task Created` +- step started / finished +- artifact created +- action records + +适合: + +- 回放任务完整过程 +- 查清楚任务到底卡在什么时候 + +## Modules + +这里显示当前注册的模块 / provider。 + +用途: + +- 确认当前用的是哪套 provider +- 确认模块有没有注册成功 + +如果将来切 provider,这里会很有用。 + +## Doctor Checks + +这里比顶部的 `Doctor = OK/FAIL` 更详细。 + +会列出: + +- workspace 目录 +- `cookies_file` +- `upload_config_file` +- `ffprobe` +- `ffmpeg` +- `codex_cmd` 或 `qwen_cmd` +- `biliup_path` + +如果某个依赖显示 `(external)`,表示它还在用系统或父项目路径,不是 `biliup-next` 自己目录内的副本。 + +## Logs + +这里可以看日志文件。 + +支持: + +- 切换日志文件 +- 刷新日志 +- 按当前任务标题过滤 + +使用建议: + +- 任务异常时,先选中任务 +- 再勾选“按当前任务标题过滤” +- 然后查看相关日志 + +这样比直接翻整份日志快很多。 + +## Settings + +Settings 分成两层: + +- 上半部分:schema 驱动表单 +- 下半部分:`Advanced JSON Editor` + +### 表单区 + +这里适合日常参数调整,例如: + +- `min_duration_seconds` +- `groq_api_key` +- `codex_cmd` +- `qwen_cmd` +- `retry_count` +- `season_id_a` +- `season_id_b` +- `post_split_comment` +- `post_full_video_timeline_comment` +- `delete_source_video_after_collection_synced` +- `delete_split_videos_after_collection_synced` + +支持: + +- 搜索配置项 +- 高频参数优先展示 +- 低频参数收纳到 `Advanced Settings` + +### Advanced JSON Editor + +适合: + +- 批量调整 +- 一次改多个字段 +- 需要直接编辑原始 JSON + +页面上有两个同步按钮: + +- `表单同步到 JSON` +- `JSON 重绘表单` + +### 敏感字段规则 + +敏感字段会显示为: + +```text +__BILIUP_NEXT_SECRET__ +``` + +规则: + +- 保留占位符:不改原值 +- 改成空字符串:清空原值 +- 改成新的字符串:更新为新值 + +## 推荐操作流 + +### 新上传一个测试视频 + +1. 打开控制台 +2. 在 `Import To Stage` 上传视频 +3. 看 `Tasks` 是否出现新任务 +4. 选中该任务 +5. 观察 `Steps / Artifacts / Timeline` +6. 如需加速,点击 `执行当前任务` + +### 某个任务失败 + +1. 选中任务 +2. 看 `Task Detail` 当前状态 +3. 看 `Steps` 哪一步失败 +4. 看 `Logs` +5. 若只是临时失败: + - 选中 step + - 点 `重试选中 Step` +6. 若后续产物也需要重建: + - 选中 step + - 点 `重置到选中 Step` + +### 修改合集或上传参数 + +1. 打开 `Settings` +2. 搜索目标参数,例如 `season` / `retry` +3. 修改表单 +4. 点击 `保存 Settings` +5. 对目标任务执行: + - `执行当前任务` + - 或 `重置到相关 step` + +### 控制评论与清理行为 + +1. 打开 `Settings` +2. 搜索: + - `post_split_comment` + - `post_full_video_timeline_comment` + - `delete_source_video_after_collection_synced` + - `delete_split_videos_after_collection_synced` +3. 修改后保存 +4. 新任务会按新规则执行 + +建议: + +- 如果你希望纯享版评论以 session 级聚合歌单展示,保持 `post_split_comment = true` +- 如果你不希望尝试给完整版主视频发时间轴评论,可以关闭 `post_full_video_timeline_comment` +- 如果磁盘紧张,再开启 cleanup;默认建议先关闭,等确认流程稳定后再开 + +### 服务异常 + +1. 看 `Services` +2. 优先 `restart biliup-next-worker.service` +3. 如果页面自身异常,再 `restart biliup-next-api.service` +4. 重启后看: + - `Health` + - `Doctor` + - `Recent Actions` + +## 安全建议 + +当前控制台已经对公网开放时,建议立刻设置: + +- `runtime.control_token` + +设置后: + +- 除 `/` 和 `/health` 外,其余 API 都要求 `X-Biliup-Token` + +如果你通过公网访问控制台,不建议长期保持空 token。 diff --git a/docs/design-principles.md b/docs/design-principles.md index 3a84de0..ce6ff47 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -1,243 +1,243 @@ -# Design Principles - -## Positioning - -`biliup-next` 以 OpenClaw 的设计哲学为指引,但不复制它的产品形态。 - -本项目的核心目标不是做聊天代理系统,而是构建一个面向本地视频流水线的控制面驱动系统。 - -因此我们借鉴的是方法论: - -- 单体优先 -- 控制面优先 -- 配置与扩展元数据优先 -- 严格校验 -- 本地优先 -- 可读、可审计、可替换 - -## Principle 1: Modular Monolith First - -系统优先采用模块化单体架构,而不是微服务。 - -原因: - -- 当前问题主要来自边界混乱,而不是部署扩展性不足 -- 单机部署和 systemd 管理仍然是核心场景 -- 统一配置、任务状态和日志比进程拆分更重要 - -约束: - -- 所有模块运行在同一系统边界内 -- 模块之间通过抽象接口和统一模型交互 -- 不得通过随意脚本调用形成隐式耦合 - -## Principle 2: Control Plane First - -功能模块不是系统中心,控制面才是系统中心。 - -控制面负责: - -- 配置管理 -- 任务状态管理 -- 模块与插件注册 -- 手动操作入口 -- 日志与诊断 - -数据面负责: - -- 执行转录 -- 执行识歌 -- 执行切歌 -- 执行上传 -- 执行评论和合集归档 - -任何新增功能,都必须先回答: - -- 它如何进入控制面 -- 它的状态如何呈现 -- 它的配置如何管理 -- 它失败后如何恢复 - -## Principle 3: Schema-First Configuration - -配置必须先有 schema,再有实现和 UI。 - -要求: - -- 所有配置项先定义在 schema -- 所有配置项有默认值、校验规则和说明 -- UI 基于 schema 生成 -- CLI 和 API 使用同一套字段定义 - -禁止: - -- 在模块里私自增加隐藏配置常量 -- UI 和代码维护不同字段名 -- 配置错误仍然带病启动 - -## Principle 4: Manifest-First Extensibility - -扩展能力先注册元数据,再执行运行时代码。 - -manifest 负责描述: - -- 插件是谁 -- 提供什么能力 -- 需要什么配置 -- 入口在哪 -- 是否可启用 - -这样控制面可以在不执行插件代码时完成: - -- 能力发现 -- 配置渲染 -- 可用性检查 -- 兼容性检查 - -## Principle 5: Registry Over Direct Coupling - -模块和插件必须通过统一 registry 接入。 - -例如: - -- transcriber registry -- song detector registry -- publisher registry -- collection strategy registry - -核心模块只依赖接口,不依赖具体 provider。 - -这意味着: - -- 更换 Groq 为其他转录器不影响任务引擎 -- 更换 Codex 为其他识歌器不影响控制面 -- 更换 biliup 为其他上传方式不影响领域模型 - -## Principle 6: Local-First And Human-Readable - -系统优先本地运行,本地保存,本地可读。 - -要求: - -- 主状态存储可本地访问 -- 日志保存在本地 -- 配置保存在本地 -- 关键元数据可被开发者直接理解 - -这不意味着只靠文件系统。 - -建议做法: - -- SQLite 保存结构化状态 -- 文件系统保存产物 -- JSON / YAML 保存配置 -- 文本日志保存审计和错误 - -## Principle 7: Strict Validation - -系统不能接受“差不多能跑”的配置和模块状态。 - -启动或配置变更前,应验证: - -- schema 是否合法 -- 可执行依赖是否存在 -- 必要凭证是否存在 -- provider 是否可初始化 -- 插件 manifest 是否正确 - -失败时: - -- 保留旧运行态 -- 返回明确错误 -- 不进入半失效状态 - -## Principle 8: Single Source Of Truth - -任务状态必须有统一来源。 - -不得同时依赖: - -- flag 文件推断状态 -- 日志推断状态 -- 目录结构推断状态 - -正确做法: - -- 数据库记录任务状态 -- 文件系统存放任务产物 -- 日志记录过程和诊断 - -三者职责分离,不互相替代。 - -补充: - -- 工作区 flag 可以保留,用于表示某些外部动作已经发生,例如评论、合集、上传等副作用完成。 -- 但这些 flag 不应被提升为 task 主状态本身。 - -## Principle 9: Replaceability With Stable Core - -可替换的是 provider,不可随意漂移的是核心模型。 - -稳定核心包括: - -- Task -- TaskStep -- Artifact -- PublishRecord -- CollectionBinding -- Settings - -这些模型一旦定义,应保持长期稳定,避免每新增一个模块就改核心语义。 - -## Principle 10: Observability Is A First-Class Feature - -可观测性不是补丁,而是正式能力。 - -管理台必须能够回答: - -- 当前有哪些任务 -- 每个任务在哪一步 -- 最近一次失败是什么 -- 当前启用的 provider 是谁 -- 当前配置是否有效 -- 哪个模块健康异常 - -如果系统不能快速回答这些问题,说明设计不完整。 - -## Principle 11: Backward-Compatible Migration - -重构必须与旧系统并行推进。 - -要求: - -- 原项目继续运行 -- 新项目只在 `./biliup-next` 演进 -- 先搭文档和骨架 -- 再逐步迁移模块 -- 最后切换生产入口 - -禁止: - -- 直接在旧系统里边修边重构 -- 在未定义状态模型前大规模搬代码 - -## Principle 12: Documentation Before Expansion - -任何新的模块、插件、控制面能力,在动手实现前都要先回答: - -- 它属于哪个层 -- 它的输入输出是什么 -- 它如何配置 -- 它如何注册 -- 它如何被 UI 展示 -- 它如何失败和恢复 - -如果这些问题没有写清楚,就不应进入实现阶段。 - -## Summary - -`biliup-next` 的核心方向不是“把旧脚本改漂亮”,而是: - -- 建立一个本地优先、控制面驱动、模块边界清晰的系统 -- 让配置、模块、状态、操作和诊断都回到统一模型之下 -- 让未来扩展建立在 schema、manifest、registry 和稳定领域模型之上 +# Design Principles + +## Positioning + +`biliup-next` 以 OpenClaw 的设计哲学为指引,但不复制它的产品形态。 + +本项目的核心目标不是做聊天代理系统,而是构建一个面向本地视频流水线的控制面驱动系统。 + +因此我们借鉴的是方法论: + +- 单体优先 +- 控制面优先 +- 配置与扩展元数据优先 +- 严格校验 +- 本地优先 +- 可读、可审计、可替换 + +## Principle 1: Modular Monolith First + +系统优先采用模块化单体架构,而不是微服务。 + +原因: + +- 当前问题主要来自边界混乱,而不是部署扩展性不足 +- 单机部署和 systemd 管理仍然是核心场景 +- 统一配置、任务状态和日志比进程拆分更重要 + +约束: + +- 所有模块运行在同一系统边界内 +- 模块之间通过抽象接口和统一模型交互 +- 不得通过随意脚本调用形成隐式耦合 + +## Principle 2: Control Plane First + +功能模块不是系统中心,控制面才是系统中心。 + +控制面负责: + +- 配置管理 +- 任务状态管理 +- 模块与插件注册 +- 手动操作入口 +- 日志与诊断 + +数据面负责: + +- 执行转录 +- 执行识歌 +- 执行切歌 +- 执行上传 +- 执行评论和合集归档 + +任何新增功能,都必须先回答: + +- 它如何进入控制面 +- 它的状态如何呈现 +- 它的配置如何管理 +- 它失败后如何恢复 + +## Principle 3: Schema-First Configuration + +配置必须先有 schema,再有实现和 UI。 + +要求: + +- 所有配置项先定义在 schema +- 所有配置项有默认值、校验规则和说明 +- UI 基于 schema 生成 +- CLI 和 API 使用同一套字段定义 + +禁止: + +- 在模块里私自增加隐藏配置常量 +- UI 和代码维护不同字段名 +- 配置错误仍然带病启动 + +## Principle 4: Manifest-First Extensibility + +扩展能力先注册元数据,再执行运行时代码。 + +manifest 负责描述: + +- 插件是谁 +- 提供什么能力 +- 需要什么配置 +- 入口在哪 +- 是否可启用 + +这样控制面可以在不执行插件代码时完成: + +- 能力发现 +- 配置渲染 +- 可用性检查 +- 兼容性检查 + +## Principle 5: Registry Over Direct Coupling + +模块和插件必须通过统一 registry 接入。 + +例如: + +- transcriber registry +- song detector registry +- publisher registry +- collection strategy registry + +核心模块只依赖接口,不依赖具体 provider。 + +这意味着: + +- 更换 Groq 为其他转录器不影响任务引擎 +- 更换 Codex 为其他识歌器不影响控制面 +- 更换 biliup 为其他上传方式不影响领域模型 + +## Principle 6: Local-First And Human-Readable + +系统优先本地运行,本地保存,本地可读。 + +要求: + +- 主状态存储可本地访问 +- 日志保存在本地 +- 配置保存在本地 +- 关键元数据可被开发者直接理解 + +这不意味着只靠文件系统。 + +建议做法: + +- SQLite 保存结构化状态 +- 文件系统保存产物 +- JSON / YAML 保存配置 +- 文本日志保存审计和错误 + +## Principle 7: Strict Validation + +系统不能接受“差不多能跑”的配置和模块状态。 + +启动或配置变更前,应验证: + +- schema 是否合法 +- 可执行依赖是否存在 +- 必要凭证是否存在 +- provider 是否可初始化 +- 插件 manifest 是否正确 + +失败时: + +- 保留旧运行态 +- 返回明确错误 +- 不进入半失效状态 + +## Principle 8: Single Source Of Truth + +任务状态必须有统一来源。 + +不得同时依赖: + +- flag 文件推断状态 +- 日志推断状态 +- 目录结构推断状态 + +正确做法: + +- 数据库记录任务状态 +- 文件系统存放任务产物 +- 日志记录过程和诊断 + +三者职责分离,不互相替代。 + +补充: + +- 工作区 flag 可以保留,用于表示某些外部动作已经发生,例如评论、合集、上传等副作用完成。 +- 但这些 flag 不应被提升为 task 主状态本身。 + +## Principle 9: Replaceability With Stable Core + +可替换的是 provider,不可随意漂移的是核心模型。 + +稳定核心包括: + +- Task +- TaskStep +- Artifact +- PublishRecord +- CollectionBinding +- Settings + +这些模型一旦定义,应保持长期稳定,避免每新增一个模块就改核心语义。 + +## Principle 10: Observability Is A First-Class Feature + +可观测性不是补丁,而是正式能力。 + +管理台必须能够回答: + +- 当前有哪些任务 +- 每个任务在哪一步 +- 最近一次失败是什么 +- 当前启用的 provider 是谁 +- 当前配置是否有效 +- 哪个模块健康异常 + +如果系统不能快速回答这些问题,说明设计不完整。 + +## Principle 11: Backward-Compatible Migration + +重构必须与旧系统并行推进。 + +要求: + +- 原项目继续运行 +- 新项目只在 `./biliup-next` 演进 +- 先搭文档和骨架 +- 再逐步迁移模块 +- 最后切换生产入口 + +禁止: + +- 直接在旧系统里边修边重构 +- 在未定义状态模型前大规模搬代码 + +## Principle 12: Documentation Before Expansion + +任何新的模块、插件、控制面能力,在动手实现前都要先回答: + +- 它属于哪个层 +- 它的输入输出是什么 +- 它如何配置 +- 它如何注册 +- 它如何被 UI 展示 +- 它如何失败和恢复 + +如果这些问题没有写清楚,就不应进入实现阶段。 + +## Summary + +`biliup-next` 的核心方向不是“把旧脚本改漂亮”,而是: + +- 建立一个本地优先、控制面驱动、模块边界清晰的系统 +- 让配置、模块、状态、操作和诊断都回到统一模型之下 +- 让未来扩展建立在 schema、manifest、registry 和稳定领域模型之上 diff --git a/docs/domain-model.md b/docs/domain-model.md index ba6e2d9..8d8f1d9 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -75,7 +75,7 @@ "platform": "bilibili", "aid": 123456, "bvid": "BV1xxxx", - "title": "【王海颖 (歌曲纯享版)】_03月29日 22时02分 共18首歌", + "title": "【王海颖 (歌曲纯享版)】 03月29日 22时02分 共18首歌", "published_at": "2026-03-30T07:56:13+08:00" } ``` diff --git a/docs/frontend-implementation-checklist.md b/docs/frontend-implementation-checklist.md index 6a49ec9..f6181b5 100644 --- a/docs/frontend-implementation-checklist.md +++ b/docs/frontend-implementation-checklist.md @@ -1,335 +1,335 @@ -# Frontend Implementation Checklist - -## Goal - -把当前 `biliup-next` 已有的后端能力,整理成前端可直接开发的任务清单。 - -这份清单面向前端开发,不讨论后端架构,只回答 3 个问题: - -1. 先做哪些页面最值钱 -2. 每个页面要拆哪些组件 -3. 每个组件依赖哪些接口和字段 - -## Priority - -建议按这个顺序推进: - -1. 任务列表页状态升级 -2. 任务详情页 -3. 手工绑定完整版 BV -4. Session 合并 / 重绑 -5. 设置页常用配置强化 - -## Milestone 1: 任务列表页状态升级 - -目标: - -- 用户一眼看懂任务是在运行、等待、失败还是完成 -- 不需要理解内部状态机字段 - -### 页面任务 - -- 把当前任务列表中的内部状态替换成用户态状态 -- 在任务列表中增加“当前步骤”列 -- 在任务列表中增加“下次重试时间”列 -- 在任务列表中增加“分P BV / 完整版 BV”列 -- 在任务列表中增加“评论 / 合集 / 清理”状态列 - -### 组件任务 - -- `TaskStatusBadge` - - 输入:`task.status`, `task.retry_state`, `steps` - - 输出:`已接收 / 上传中 / 等待B站可见 / 需人工处理 / 已完成` -- `TaskStepBadge` - - 输入:`steps` - - 输出当前步骤文案 -- `TaskDeliverySummary` - - 输入:`delivery_state`, `session_context` - - 输出: - - 分P BV - - 完整版 BV - - 评论状态 - - 合集状态 - - 清理状态 - -### 接口依赖 - -- `GET /tasks` - -### 建议后端字段 - -- 现有可直接使用: - - `status` - - `retry_state` - - `delivery_state` - - `session_context` -- 建议前端先本地派生: - - `display_status` - - `current_step` - -## Milestone 2: 任务详情页 - -目标: - -- 用户不看日志也能知道这个任务发生了什么 -- 用户能在单任务页完成最常见修复动作 - -### 页面任务 - -- 新建任务详情页 Hero 区 -- 新建步骤时间线 -- 新建交付结果卡片 -- 新建 Session 信息卡片 -- 新建产物列表卡片 -- 新建历史动作卡片 -- 新建错误说明卡片 - -### 组件任务 - -- `TaskHero` - - 标题 - - 用户态状态 - - 当前步骤 - - 下次重试时间 -- `TaskTimeline` - - ingest -> collection_b 全步骤 -- `TaskDeliveryPanel` - - 分P `BV` - - 完整版 `BV` - - 分P链接 - - 完整版链接 - - 合集状态 -- `TaskSessionPanel` - - `session_key` - - `streamer` - - `room_id` - - `segment_started_at` - - `segment_duration_seconds` - - `context_source` -- `TaskArtifactsPanel` - - source_video - - subtitle_srt - - songs.json - - songs.txt - - clip_video -- `TaskActionsPanel` - - 运行 - - 重试 - - 重置 - - 绑定完整版 BV - -### 接口依赖 - -- `GET /tasks/` -- `GET /tasks//steps` -- `GET /tasks//artifacts` -- `GET /tasks//history` -- `GET /tasks//timeline` -- `GET /tasks//context` - -### 操作接口依赖 - -- `POST /tasks//actions/run` -- `POST /tasks//actions/retry-step` -- `POST /tasks//actions/reset-to-step` - -## Milestone 3: 手工绑定完整版 BV - -目标: - -- 用户在前端直接补 `full_video_bvid` -- 不需要再手工写 `full_video_bvid.txt` - -### 页面任务 - -- 在任务详情页增加“绑定完整版 BV”表单 -- 显示当前已绑定 BV -- 显示绑定来源: - - fallback - - task_context - - meta_sidecar - - webhook - -### 组件任务 - -- `BindFullVideoForm` - - 输入框:`BV...` - - 提交按钮 - - 成功反馈 - - 错误反馈 - -### 接口依赖 - -- `POST /tasks//bind-full-video` - -### 交互要求 - -- 提交前本地校验 `BV[0-9A-Za-z]+` -- 成功后刷新: - - `GET /tasks/` - - `GET /tasks//context` - -## Milestone 4: Session 合并 / 重绑 - -目标: - -- 用户能处理“同一场多个断流片段” -- 用户能统一给整个 session 重绑完整版 BV - -### 页面任务 - -- 在任务详情页显示当前任务所属 session -- 增加“查看同 session 任务”入口 -- 增加“合并到现有 session”弹窗 -- 增加“整个 session 重绑完整版 BV”表单 - -### 组件任务 - -- `SessionSummaryCard` - - `session_key` - - task count - - 当前 `full_video_bvid` -- `SessionTaskList` - - 列出该 session 下所有任务 -- `MergeSessionDialog` - - 输入目标 `session_key` - - 选择任务 -- `RebindSessionForm` - - 输入新的完整版 `BV` - -### 接口依赖 - -- `GET /sessions/` -- `POST /sessions//merge` -- `POST /sessions//rebind` - -### 交互要求 - -- 合并成功后刷新: - - 当前任务详情 - - session 详情 - - 任务列表 -- 如果目标 session 已有 `full_video_bvid` - - 前端提示“合并后会继承该完整版 BV” - -## Milestone 5: 设置页常用配置强化 - -目标: - -- 用户无需直接改 JSON 就能调优常用行为 - -### 页面任务 - -- 在设置页高亮常用 ingest/session 配置 -- 在设置页高亮 comment 重试配置 -- 在设置页高亮 cleanup 配置 - -### 应优先暴露的配置 - -- `ingest.session_gap_minutes` -- `ingest.meta_sidecar_enabled` -- `ingest.meta_sidecar_suffix` -- `comment.max_retries` -- `comment.base_delay_seconds` -- `cleanup.delete_source_video_after_collection_synced` -- `cleanup.delete_split_videos_after_collection_synced` - -### 接口依赖 - -- `GET /settings` -- `GET /settings/schema` -- `PUT /settings` - -## Common UX Rules - -### 状态文案 - -- `failed_retryable` 不显示“失败” -- 优先显示: - - `等待自动重试` - - `等待B站可见` - - `正在处理中` - - `需人工处理` - -### 错误提示 - -错误提示统一分成 2 行: - -- 原因 -- 建议动作 - -例如: - -- 原因:视频刚上传,B站暂未可见 -- 建议:系统会自动重试,无需人工处理 - -### 操作反馈 - -所有写操作都要有: - -- loading 态 -- 成功 toast -- 错误 toast - -### 刷新策略 - -这些动作成功后必须自动刷新详情数据: - -- `retry-step` -- `reset-to-step` -- `bind-full-video` -- `session merge` -- `session rebind` - -## Suggested Frontend Types - -建议前端统一定义这些类型: - -```ts -type TaskDisplayStatus = - | "accepted" - | "processing" - | "waiting_retry" - | "waiting_visibility" - | "manual_action" - | "done"; - -type TaskSessionContext = { - task_id: string; - session_key: string | null; - streamer: string | null; - room_id: string | null; - source_title: string | null; - segment_started_at: string | null; - segment_duration_seconds: number | null; - full_video_bvid: string | null; - split_bvid: string | null; - context_source: string; - video_links: { - split_video_url: string | null; - full_video_url: string | null; - }; -}; -``` - -## Suggested Build Order Inside Frontend Repo - -建议按这个顺序拆 PR: - -1. 状态映射工具函数 -2. 任务列表页文案升级 -3. 任务详情页 Session/Delivery 面板 -4. 绑定完整版 BV 表单 -5. Session 合并 / 重绑弹窗 -6. 设置页常用配置高亮 - -## Definition Of Done - -这一轮前端完成的标准建议是: - -- 用户可以在任务列表页看懂所有任务当前状态 -- 用户可以在任务详情页看到分P/完整版 BV 和链接 -- 用户可以手工绑定完整版 BV -- 用户可以把多个任务合并为同一个 session -- 用户可以给整个 session 重绑完整版 BV -- 用户不需要 ssh 登录机器改 txt 文件 +# Frontend Implementation Checklist + +## Goal + +把当前 `biliup-next` 已有的后端能力,整理成前端可直接开发的任务清单。 + +这份清单面向前端开发,不讨论后端架构,只回答 3 个问题: + +1. 先做哪些页面最值钱 +2. 每个页面要拆哪些组件 +3. 每个组件依赖哪些接口和字段 + +## Priority + +建议按这个顺序推进: + +1. 任务列表页状态升级 +2. 任务详情页 +3. 手工绑定完整版 BV +4. Session 合并 / 重绑 +5. 设置页常用配置强化 + +## Milestone 1: 任务列表页状态升级 + +目标: + +- 用户一眼看懂任务是在运行、等待、失败还是完成 +- 不需要理解内部状态机字段 + +### 页面任务 + +- 把当前任务列表中的内部状态替换成用户态状态 +- 在任务列表中增加“当前步骤”列 +- 在任务列表中增加“下次重试时间”列 +- 在任务列表中增加“分P BV / 完整版 BV”列 +- 在任务列表中增加“评论 / 合集 / 清理”状态列 + +### 组件任务 + +- `TaskStatusBadge` + - 输入:`task.status`, `task.retry_state`, `steps` + - 输出:`已接收 / 上传中 / 等待B站可见 / 需人工处理 / 已完成` +- `TaskStepBadge` + - 输入:`steps` + - 输出当前步骤文案 +- `TaskDeliverySummary` + - 输入:`delivery_state`, `session_context` + - 输出: + - 分P BV + - 完整版 BV + - 评论状态 + - 合集状态 + - 清理状态 + +### 接口依赖 + +- `GET /tasks` + +### 建议后端字段 + +- 现有可直接使用: + - `status` + - `retry_state` + - `delivery_state` + - `session_context` +- 建议前端先本地派生: + - `display_status` + - `current_step` + +## Milestone 2: 任务详情页 + +目标: + +- 用户不看日志也能知道这个任务发生了什么 +- 用户能在单任务页完成最常见修复动作 + +### 页面任务 + +- 新建任务详情页 Hero 区 +- 新建步骤时间线 +- 新建交付结果卡片 +- 新建 Session 信息卡片 +- 新建产物列表卡片 +- 新建历史动作卡片 +- 新建错误说明卡片 + +### 组件任务 + +- `TaskHero` + - 标题 + - 用户态状态 + - 当前步骤 + - 下次重试时间 +- `TaskTimeline` + - ingest -> collection_b 全步骤 +- `TaskDeliveryPanel` + - 分P `BV` + - 完整版 `BV` + - 分P链接 + - 完整版链接 + - 合集状态 +- `TaskSessionPanel` + - `session_key` + - `streamer` + - `room_id` + - `segment_started_at` + - `segment_duration_seconds` + - `context_source` +- `TaskArtifactsPanel` + - source_video + - subtitle_srt + - songs.json + - songs.txt + - clip_video +- `TaskActionsPanel` + - 运行 + - 重试 + - 重置 + - 绑定完整版 BV + +### 接口依赖 + +- `GET /tasks/` +- `GET /tasks//steps` +- `GET /tasks//artifacts` +- `GET /tasks//history` +- `GET /tasks//timeline` +- `GET /tasks//context` + +### 操作接口依赖 + +- `POST /tasks//actions/run` +- `POST /tasks//actions/retry-step` +- `POST /tasks//actions/reset-to-step` + +## Milestone 3: 手工绑定完整版 BV + +目标: + +- 用户在前端直接补 `full_video_bvid` +- 不需要再手工写 `full_video_bvid.txt` + +### 页面任务 + +- 在任务详情页增加“绑定完整版 BV”表单 +- 显示当前已绑定 BV +- 显示绑定来源: + - fallback + - task_context + - meta_sidecar + - webhook + +### 组件任务 + +- `BindFullVideoForm` + - 输入框:`BV...` + - 提交按钮 + - 成功反馈 + - 错误反馈 + +### 接口依赖 + +- `POST /tasks//bind-full-video` + +### 交互要求 + +- 提交前本地校验 `BV[0-9A-Za-z]+` +- 成功后刷新: + - `GET /tasks/` + - `GET /tasks//context` + +## Milestone 4: Session 合并 / 重绑 + +目标: + +- 用户能处理“同一场多个断流片段” +- 用户能统一给整个 session 重绑完整版 BV + +### 页面任务 + +- 在任务详情页显示当前任务所属 session +- 增加“查看同 session 任务”入口 +- 增加“合并到现有 session”弹窗 +- 增加“整个 session 重绑完整版 BV”表单 + +### 组件任务 + +- `SessionSummaryCard` + - `session_key` + - task count + - 当前 `full_video_bvid` +- `SessionTaskList` + - 列出该 session 下所有任务 +- `MergeSessionDialog` + - 输入目标 `session_key` + - 选择任务 +- `RebindSessionForm` + - 输入新的完整版 `BV` + +### 接口依赖 + +- `GET /sessions/` +- `POST /sessions//merge` +- `POST /sessions//rebind` + +### 交互要求 + +- 合并成功后刷新: + - 当前任务详情 + - session 详情 + - 任务列表 +- 如果目标 session 已有 `full_video_bvid` + - 前端提示“合并后会继承该完整版 BV” + +## Milestone 5: 设置页常用配置强化 + +目标: + +- 用户无需直接改 JSON 就能调优常用行为 + +### 页面任务 + +- 在设置页高亮常用 ingest/session 配置 +- 在设置页高亮 comment 重试配置 +- 在设置页高亮 cleanup 配置 + +### 应优先暴露的配置 + +- `ingest.session_gap_minutes` +- `ingest.meta_sidecar_enabled` +- `ingest.meta_sidecar_suffix` +- `comment.max_retries` +- `comment.base_delay_seconds` +- `cleanup.delete_source_video_after_collection_synced` +- `cleanup.delete_split_videos_after_collection_synced` + +### 接口依赖 + +- `GET /settings` +- `GET /settings/schema` +- `PUT /settings` + +## Common UX Rules + +### 状态文案 + +- `failed_retryable` 不显示“失败” +- 优先显示: + - `等待自动重试` + - `等待B站可见` + - `正在处理中` + - `需人工处理` + +### 错误提示 + +错误提示统一分成 2 行: + +- 原因 +- 建议动作 + +例如: + +- 原因:视频刚上传,B站暂未可见 +- 建议:系统会自动重试,无需人工处理 + +### 操作反馈 + +所有写操作都要有: + +- loading 态 +- 成功 toast +- 错误 toast + +### 刷新策略 + +这些动作成功后必须自动刷新详情数据: + +- `retry-step` +- `reset-to-step` +- `bind-full-video` +- `session merge` +- `session rebind` + +## Suggested Frontend Types + +建议前端统一定义这些类型: + +```ts +type TaskDisplayStatus = + | "accepted" + | "processing" + | "waiting_retry" + | "waiting_visibility" + | "manual_action" + | "done"; + +type TaskSessionContext = { + task_id: string; + session_key: string | null; + streamer: string | null; + room_id: string | null; + source_title: string | null; + segment_started_at: string | null; + segment_duration_seconds: number | null; + full_video_bvid: string | null; + split_bvid: string | null; + context_source: string; + video_links: { + split_video_url: string | null; + full_video_url: string | null; + }; +}; +``` + +## Suggested Build Order Inside Frontend Repo + +建议按这个顺序拆 PR: + +1. 状态映射工具函数 +2. 任务列表页文案升级 +3. 任务详情页 Session/Delivery 面板 +4. 绑定完整版 BV 表单 +5. Session 合并 / 重绑弹窗 +6. 设置页常用配置高亮 + +## Definition Of Done + +这一轮前端完成的标准建议是: + +- 用户可以在任务列表页看懂所有任务当前状态 +- 用户可以在任务详情页看到分P/完整版 BV 和链接 +- 用户可以手工绑定完整版 BV +- 用户可以把多个任务合并为同一个 session +- 用户可以给整个 session 重绑完整版 BV +- 用户不需要 ssh 登录机器改 txt 文件 diff --git a/docs/frontend-product-integration.md b/docs/frontend-product-integration.md index 436810c..4a1250e 100644 --- a/docs/frontend-product-integration.md +++ b/docs/frontend-product-integration.md @@ -1,383 +1,383 @@ -# Frontend Product Integration - -## Goal - -从用户视角,把当前 `biliup-next` 的任务状态机包装成可操作、可理解的控制面。 - -这份文档面向前端与后端联调,目标不是描述内部实现,而是明确: - -- 前端应该有哪些页面 -- 每个页面需要哪些字段 -- 当前后端已经提供了哪些接口 -- 哪些字段/接口还需要补 - -## User Goals - -用户最关心的不是数据库状态,而是这 6 件事: - -1. 视频有没有被接收 -2. 现在卡在哪一步 -3. 这是自动等待还是需要人工处理 -4. 上传后的分P BV 和完整版 BV 是什么 -5. 评论和合集有没有完成 -6. 失败后应该点哪里恢复 - -因此,前端不应该直接暴露 `created/transcribed/failed_retryable` 这类内部状态,而应该提供一层用户可理解的派生展示。 - -## Information Architecture - -建议前端固定成 4 个一级页面: - -1. 总览页 -2. 任务列表页 -3. 任务详情页 -4. 设置页 - -可选扩展页: - -5. 日志页 -6. Webhook / Sidecar 调试页 - -## Page Spec - -### 1. 总览页 - -目标:让用户在 10 秒内知道系统是否正常、当前队列是否卡住。 - -核心模块: - -- 任务摘要卡片 - - 运行中 - - 等待自动重试 - - 需人工处理 - - 已完成 -- 最近 10 个任务 - - 标题 - - 用户态状态 - - 当前步骤 - - 下次重试时间 -- 运行时摘要 - - API 服务状态 - - Worker 服务状态 - - stage 目录文件数 - - 最近一次调度结果 -- 风险提示 - - cookies 缺失 - - 磁盘空间不足 - - Groq/Codex/Biliup 不可用 - -现有接口可复用: - -- `GET /health` -- `GET /doctor` -- `GET /tasks?limit=100` -- `GET /runtime/services` -- `GET /scheduler` - -### 2. 任务列表页 - -目标:批量查看任务,快速定位失败或等待中的任务。 - -表格建议字段: - -- 任务标题 -- 用户态状态 -- 当前步骤 -- 完成进度 -- 下次重试时间 -- 分P BV -- 完整版 BV -- 评论状态 -- 合集状态 -- 清理状态 -- 最近更新时间 - -筛选项建议: - -- 全部 -- 运行中 -- 等待自动重试 -- 需人工处理 -- 已完成 -- 仅显示未完成评论 -- 仅显示未完成合集 -- 仅显示未清理文件 - -现有接口可复用: - -- `GET /tasks` - -建议新增的派生字段: - -- `display_status` -- `current_step` -- `progress_percent` -- `split_bvid` -- `full_video_bvid` -- `session_key` -- `session_binding_state` - -### 3. 任务详情页 - -目标:让用户不看日志也能处理单个任务。 - -建议布局: - -- Hero 区 - - 标题 - - 用户态状态 - - 当前步骤 - - 下次重试时间 - - 主要操作按钮 -- 步骤时间线 - - ingest - - transcribe - - song_detect - - split - - publish - - comment - - collection_a - - collection_b -- 交付结果区 - - 分P BV - - 完整版 BV - - 分P 链接 - - 完整版链接 - - 合集 A / B 链接 -- Session 信息区 - - session_key - - streamer - - room_id - - segment_started_at - - segment_duration_seconds - - 是否由 sidecar 提供 - - 是否由时间连续性自动归并 -- 文件与产物区 - - source_video - - subtitle_srt - - songs.json - - songs.txt - - clip_video -- 历史动作区 - - run - - retry-step - - reset-to-step -- 错误与建议区 - - 错误码 - - 错误摘要 - - 系统建议动作 - -现有接口可复用: - -- `GET /tasks/` -- `GET /tasks//steps` -- `GET /tasks//artifacts` -- `GET /tasks//history` -- `GET /tasks//timeline` -- `POST /tasks//actions/run` -- `POST /tasks//actions/retry-step` -- `POST /tasks//actions/reset-to-step` - -建议新增接口: - -- `GET /tasks//context` - -### 4. 设置页 - -目标:把常用配置变成可理解、可搜索、可修改的产品设置,而不是裸 JSON。 - -优先展示的用户级配置: - -- `ingest.session_gap_minutes` -- `ingest.meta_sidecar_enabled` -- `ingest.meta_sidecar_suffix` -- `comment.max_retries` -- `comment.base_delay_seconds` -- `cleanup.delete_source_video_after_collection_synced` -- `cleanup.delete_split_videos_after_collection_synced` -- `collection.season_id_a` -- `collection.season_id_b` - -现有接口可复用: - -- `GET /settings` -- `GET /settings/schema` -- `PUT /settings` - -## User-Facing Status Mapping - -前端必须提供一层用户态状态,不要直接显示内部状态。 - -建议映射: - -- `created` -> `已接收` -- `transcribed` -> `已转录` -- `songs_detected` -> `已识歌` -- `split_done` -> `已切片` -- `published` -> `已上传` -- `commented` -> `评论完成` -- `collection_synced` -> `已完成` -- `failed_retryable` + `step=comment` -> `等待B站可见` -- `failed_retryable` 其他 -> `等待自动重试` -- `failed_manual` -> `需人工处理` -- 任一步 `running` -> `<步骤名>处理中` - -建议步骤名展示: - -- `ingest` -> `接收视频` -- `transcribe` -> `转录字幕` -- `song_detect` -> `识别歌曲` -- `split` -> `切分分P` -- `publish` -> `上传分P` -- `comment` -> `发布评论` -- `collection_a` -> `加入完整版合集` -- `collection_b` -> `加入分P合集` - -## API Integration - -### Existing APIs That Frontend Should Reuse - -- `GET /tasks` -- `GET /tasks/` -- `GET /tasks//steps` -- `GET /tasks//artifacts` -- `GET /tasks//history` -- `GET /tasks//timeline` -- `POST /tasks//actions/run` -- `POST /tasks//actions/retry-step` -- `POST /tasks//actions/reset-to-step` -- `GET /settings` -- `GET /settings/schema` -- `PUT /settings` -- `GET /runtime/services` -- `POST /runtime/services//` -- `POST /worker/run-once` - -### Recommended New APIs - -#### `GET /tasks//context` - -用途:给任务详情页和 session 归并 UI 提供上下文。 - -返回建议: - -```json -{ - "task_id": "xxx", - "session_key": "王海颖:20260402T2203", - "streamer": "王海颖", - "room_id": "581192190066", - "source_title": "王海颖唱歌录播 04月02日 22时03分", - "segment_started_at": "2026-04-02T22:03:00+08:00", - "segment_duration_seconds": 4076.443, - "full_video_bvid": "BV1uH9wBsELC", - "binding_source": "meta_sidecar" -} -``` - -#### `POST /tasks//bind-full-video` - -用途:用户在前端手工补绑完整版 BV。 - -请求: - -```json -{ - "full_video_bvid": "BV1uH9wBsELC" -} -``` - -#### `POST /sessions//merge` - -用途:把多个任务手工归并到同一个 session。 - -请求: - -```json -{ - "task_ids": ["why-2205", "why-2306"] -} -``` - -#### `POST /sessions//rebind` - -用途:修改 session 级完整版 BV。 - -请求: - -```json -{ - "full_video_bvid": "BV1uH9wBsELC" -} -``` - -## Derived Fields For UI - -后端最好直接给前端这些派生字段,减少前端自行拼状态: - -- `display_status` -- `display_step` -- `progress_percent` -- `split_bvid` -- `full_video_bvid` -- `video_links` -- `delivery_state` -- `retry_state` -- `session_context` -- `actions_available` - -其中 `actions_available` 建议返回: - -```json -{ - "run": true, - "retry_step": true, - "reset_to_step": true, - "bind_full_video": true, - "merge_session": true -} -``` - -## Delivery State Contract - -任务列表和详情页都依赖统一的交付状态模型。 - -建议结构: - -```json -{ - "split_bvid": "BV1GoDPBtEUg", - "full_video_bvid": "BV1uH9wBsELC", - "split_video_url": "https://www.bilibili.com/video/BV1GoDPBtEUg", - "full_video_url": "https://www.bilibili.com/video/BV1uH9wBsELC", - "comment_split_done": false, - "comment_full_done": false, - "collection_a_done": false, - "collection_b_done": false, - "source_video_present": true, - "split_videos_present": true -} -``` - -## Suggested Frontend Build Order - -按实际价值排序: - -1. 任务列表页状态文案升级 -2. 任务详情页增加交付结果和重试说明 -3. 详情页增加 session/context 区块 -4. 设置页增加 session 归并相关配置 -5. 增加“手工绑定完整版 BV”操作 -6. 增加“合并 session”操作 - -## MVP Scope - -如果只做一轮最小交付,建议先完成: - -- 用户态状态映射 -- 单任务详情页 -- `GET /tasks//context` -- 手工绑定 `full_video_bvid` -- 前端重试/重置按钮统一化 - -这样即使 webhook 和自动 session 归并后面再完善,用户也已经能在前端完整处理问题。 +# Frontend Product Integration + +## Goal + +从用户视角,把当前 `biliup-next` 的任务状态机包装成可操作、可理解的控制面。 + +这份文档面向前端与后端联调,目标不是描述内部实现,而是明确: + +- 前端应该有哪些页面 +- 每个页面需要哪些字段 +- 当前后端已经提供了哪些接口 +- 哪些字段/接口还需要补 + +## User Goals + +用户最关心的不是数据库状态,而是这 6 件事: + +1. 视频有没有被接收 +2. 现在卡在哪一步 +3. 这是自动等待还是需要人工处理 +4. 上传后的分P BV 和完整版 BV 是什么 +5. 评论和合集有没有完成 +6. 失败后应该点哪里恢复 + +因此,前端不应该直接暴露 `created/transcribed/failed_retryable` 这类内部状态,而应该提供一层用户可理解的派生展示。 + +## Information Architecture + +建议前端固定成 4 个一级页面: + +1. 总览页 +2. 任务列表页 +3. 任务详情页 +4. 设置页 + +可选扩展页: + +5. 日志页 +6. Webhook / Sidecar 调试页 + +## Page Spec + +### 1. 总览页 + +目标:让用户在 10 秒内知道系统是否正常、当前队列是否卡住。 + +核心模块: + +- 任务摘要卡片 + - 运行中 + - 等待自动重试 + - 需人工处理 + - 已完成 +- 最近 10 个任务 + - 标题 + - 用户态状态 + - 当前步骤 + - 下次重试时间 +- 运行时摘要 + - API 服务状态 + - Worker 服务状态 + - stage 目录文件数 + - 最近一次调度结果 +- 风险提示 + - cookies 缺失 + - 磁盘空间不足 + - Groq/Codex/Biliup 不可用 + +现有接口可复用: + +- `GET /health` +- `GET /doctor` +- `GET /tasks?limit=100` +- `GET /runtime/services` +- `GET /scheduler` + +### 2. 任务列表页 + +目标:批量查看任务,快速定位失败或等待中的任务。 + +表格建议字段: + +- 任务标题 +- 用户态状态 +- 当前步骤 +- 完成进度 +- 下次重试时间 +- 分P BV +- 完整版 BV +- 评论状态 +- 合集状态 +- 清理状态 +- 最近更新时间 + +筛选项建议: + +- 全部 +- 运行中 +- 等待自动重试 +- 需人工处理 +- 已完成 +- 仅显示未完成评论 +- 仅显示未完成合集 +- 仅显示未清理文件 + +现有接口可复用: + +- `GET /tasks` + +建议新增的派生字段: + +- `display_status` +- `current_step` +- `progress_percent` +- `split_bvid` +- `full_video_bvid` +- `session_key` +- `session_binding_state` + +### 3. 任务详情页 + +目标:让用户不看日志也能处理单个任务。 + +建议布局: + +- Hero 区 + - 标题 + - 用户态状态 + - 当前步骤 + - 下次重试时间 + - 主要操作按钮 +- 步骤时间线 + - ingest + - transcribe + - song_detect + - split + - publish + - comment + - collection_a + - collection_b +- 交付结果区 + - 分P BV + - 完整版 BV + - 分P 链接 + - 完整版链接 + - 合集 A / B 链接 +- Session 信息区 + - session_key + - streamer + - room_id + - segment_started_at + - segment_duration_seconds + - 是否由 sidecar 提供 + - 是否由时间连续性自动归并 +- 文件与产物区 + - source_video + - subtitle_srt + - songs.json + - songs.txt + - clip_video +- 历史动作区 + - run + - retry-step + - reset-to-step +- 错误与建议区 + - 错误码 + - 错误摘要 + - 系统建议动作 + +现有接口可复用: + +- `GET /tasks/` +- `GET /tasks//steps` +- `GET /tasks//artifacts` +- `GET /tasks//history` +- `GET /tasks//timeline` +- `POST /tasks//actions/run` +- `POST /tasks//actions/retry-step` +- `POST /tasks//actions/reset-to-step` + +建议新增接口: + +- `GET /tasks//context` + +### 4. 设置页 + +目标:把常用配置变成可理解、可搜索、可修改的产品设置,而不是裸 JSON。 + +优先展示的用户级配置: + +- `ingest.session_gap_minutes` +- `ingest.meta_sidecar_enabled` +- `ingest.meta_sidecar_suffix` +- `comment.max_retries` +- `comment.base_delay_seconds` +- `cleanup.delete_source_video_after_collection_synced` +- `cleanup.delete_split_videos_after_collection_synced` +- `collection.season_id_a` +- `collection.season_id_b` + +现有接口可复用: + +- `GET /settings` +- `GET /settings/schema` +- `PUT /settings` + +## User-Facing Status Mapping + +前端必须提供一层用户态状态,不要直接显示内部状态。 + +建议映射: + +- `created` -> `已接收` +- `transcribed` -> `已转录` +- `songs_detected` -> `已识歌` +- `split_done` -> `已切片` +- `published` -> `已上传` +- `commented` -> `评论完成` +- `collection_synced` -> `已完成` +- `failed_retryable` + `step=comment` -> `等待B站可见` +- `failed_retryable` 其他 -> `等待自动重试` +- `failed_manual` -> `需人工处理` +- 任一步 `running` -> `<步骤名>处理中` + +建议步骤名展示: + +- `ingest` -> `接收视频` +- `transcribe` -> `转录字幕` +- `song_detect` -> `识别歌曲` +- `split` -> `切分分P` +- `publish` -> `上传分P` +- `comment` -> `发布评论` +- `collection_a` -> `加入完整版合集` +- `collection_b` -> `加入分P合集` + +## API Integration + +### Existing APIs That Frontend Should Reuse + +- `GET /tasks` +- `GET /tasks/` +- `GET /tasks//steps` +- `GET /tasks//artifacts` +- `GET /tasks//history` +- `GET /tasks//timeline` +- `POST /tasks//actions/run` +- `POST /tasks//actions/retry-step` +- `POST /tasks//actions/reset-to-step` +- `GET /settings` +- `GET /settings/schema` +- `PUT /settings` +- `GET /runtime/services` +- `POST /runtime/services//` +- `POST /worker/run-once` + +### Recommended New APIs + +#### `GET /tasks//context` + +用途:给任务详情页和 session 归并 UI 提供上下文。 + +返回建议: + +```json +{ + "task_id": "xxx", + "session_key": "王海颖:20260402T2203", + "streamer": "王海颖", + "room_id": "581192190066", + "source_title": "王海颖唱歌录播 04月02日 22时03分", + "segment_started_at": "2026-04-02T22:03:00+08:00", + "segment_duration_seconds": 4076.443, + "full_video_bvid": "BV1uH9wBsELC", + "binding_source": "meta_sidecar" +} +``` + +#### `POST /tasks//bind-full-video` + +用途:用户在前端手工补绑完整版 BV。 + +请求: + +```json +{ + "full_video_bvid": "BV1uH9wBsELC" +} +``` + +#### `POST /sessions//merge` + +用途:把多个任务手工归并到同一个 session。 + +请求: + +```json +{ + "task_ids": ["why-2205", "why-2306"] +} +``` + +#### `POST /sessions//rebind` + +用途:修改 session 级完整版 BV。 + +请求: + +```json +{ + "full_video_bvid": "BV1uH9wBsELC" +} +``` + +## Derived Fields For UI + +后端最好直接给前端这些派生字段,减少前端自行拼状态: + +- `display_status` +- `display_step` +- `progress_percent` +- `split_bvid` +- `full_video_bvid` +- `video_links` +- `delivery_state` +- `retry_state` +- `session_context` +- `actions_available` + +其中 `actions_available` 建议返回: + +```json +{ + "run": true, + "retry_step": true, + "reset_to_step": true, + "bind_full_video": true, + "merge_session": true +} +``` + +## Delivery State Contract + +任务列表和详情页都依赖统一的交付状态模型。 + +建议结构: + +```json +{ + "split_bvid": "BV1GoDPBtEUg", + "full_video_bvid": "BV1uH9wBsELC", + "split_video_url": "https://www.bilibili.com/video/BV1GoDPBtEUg", + "full_video_url": "https://www.bilibili.com/video/BV1uH9wBsELC", + "comment_split_done": false, + "comment_full_done": false, + "collection_a_done": false, + "collection_b_done": false, + "source_video_present": true, + "split_videos_present": true +} +``` + +## Suggested Frontend Build Order + +按实际价值排序: + +1. 任务列表页状态文案升级 +2. 任务详情页增加交付结果和重试说明 +3. 详情页增加 session/context 区块 +4. 设置页增加 session 归并相关配置 +5. 增加“手工绑定完整版 BV”操作 +6. 增加“合并 session”操作 + +## MVP Scope + +如果只做一轮最小交付,建议先完成: + +- 用户态状态映射 +- 单任务详情页 +- `GET /tasks//context` +- 手工绑定 `full_video_bvid` +- 前端重试/重置按钮统一化 + +这样即使 webhook 和自动 session 归并后面再完善,用户也已经能在前端完整处理问题。 diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 172c015..5792522 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -1,192 +1,192 @@ -# Migration Plan - -## Goal - -在不破坏原项目运行的前提下,逐步将能力迁移到 `biliup-next`。 - -## Migration Principles - -- 原项目继续作为生产系统运行 -- 新项目只在 `./biliup-next` 中演进 -- 先文档、后骨架、再迁移功能 -- 先控制面,后数据面 -- 先兼容旧目录结构,再逐步替换旧入口 - -## Phase 0: Documentation Baseline - -目标: - -- 明确设计原则 -- 明确架构分层 -- 明确领域模型 -- 明确配置系统和插件系统 - -产物: - -- `vision.md` -- `architecture.md` -- `domain-model.md` -- `design-principles.md` -- `config-system.md` -- `plugin-system.md` -- `state-machine.md` -- `module-contracts.md` -- `migration-plan.md` - -## Phase 1: Project Skeleton - -目标: - -- 建立 `biliup-next/src` 目录结构 -- 建立基础 Python 包 -- 建立最小配置系统 -- 建立 SQLite 存储层 -- 建立任务模型和状态模型 - -不做: - -- 不迁移业务逻辑 -- 不接管生产入口 - -## Phase 2: Control Plane MVP - -目标: - -- 提供最小 API -- 提供任务列表和配置读取能力 -- 提供最小 CLI -- 提供 health / logs / settings / tasks 查询能力 - -产物: - -- API server -- config service -- task repository -- runtime doctor - -## Phase 3: Data Plane Adapters - -目标: - -- 把旧系统依赖的外部能力封装成 adapter - -优先顺序: - -1. `ffmpeg` adapter -2. `Groq` adapter -3. `Codex` adapter -4. `biliup` adapter -5. `Bili API` adapter - -理由: - -- 先封装外部依赖,后迁移业务模块,能减少后续反复返工 - -## Phase 4: Module Migration - -按顺序迁移业务模块。 - -### 4.1 Ingest - -- 替代 `monitor.py` 的任务创建部分 - -### 4.2 Transcribe - -- 替代 `video2srt.py` - -### 4.3 Song Detect - -- 替代 `monitorSrt.py` - -### 4.4 Split - -- 替代 `monitorSongs.py` - -### 4.5 Publish - -- 替代 `upload.py` - -### 4.6 Comment - -- 替代 `session_top_comment.py` - -### 4.7 Collection - -- 替代 `add_to_collection.py` - -## Phase 5: Parallel Verification - -目标: - -- 新旧系统并行验证 -- 新系统只处理测试任务 -- 对比产物、日志、状态和 B 站结果 - -重点检查: - -- 字幕结果 -- 歌曲识别结果 -- 切片结果 -- 上传结果 -- 评论和合集结果 - -## Phase 6: Admin UI - -目标: - -- 构建本地控制台 - -第一版包含: - -- 配置页 -- 任务页 -- 模块页 -- 日志页 -- 手动操作页 - -## Phase 7: Cutover - -目标: - -- 新系统逐步接管生产入口 - -顺序建议: - -1. 只接管任务可视化 -2. 接管配置管理 -3. 接管测试任务处理 -4. 接管单模块生产流量 -5. 最终接管全部生产流量 - -## Risks - -### Risk 1: 旧系统与新系统语义不一致 - -缓解: - -- 先定义领域模型和状态机 -- 迁移前写适配层,不直接照抄旧脚本行为 - -### Risk 2: 边迁移边污染旧项目 - -缓解: - -- 所有新内容只放在 `./biliup-next` -- 不改原项目运行入口 - -### Risk 3: UI 先行导致底层不稳 - -缓解: - -- 先做控制面模型和 API -- 最后做 UI - -## Definition Of Done - -迁移完成的标准不是“代码搬完”,而是: - -- 新系统可以独立运行 -- 配置统一管理 -- 状态统一落库 -- UI 可以完整观察任务 -- 旧脚本不再是主入口 +# Migration Plan + +## Goal + +在不破坏原项目运行的前提下,逐步将能力迁移到 `biliup-next`。 + +## Migration Principles + +- 原项目继续作为生产系统运行 +- 新项目只在 `./biliup-next` 中演进 +- 先文档、后骨架、再迁移功能 +- 先控制面,后数据面 +- 先兼容旧目录结构,再逐步替换旧入口 + +## Phase 0: Documentation Baseline + +目标: + +- 明确设计原则 +- 明确架构分层 +- 明确领域模型 +- 明确配置系统和插件系统 + +产物: + +- `vision.md` +- `architecture.md` +- `domain-model.md` +- `design-principles.md` +- `config-system.md` +- `plugin-system.md` +- `state-machine.md` +- `module-contracts.md` +- `migration-plan.md` + +## Phase 1: Project Skeleton + +目标: + +- 建立 `biliup-next/src` 目录结构 +- 建立基础 Python 包 +- 建立最小配置系统 +- 建立 SQLite 存储层 +- 建立任务模型和状态模型 + +不做: + +- 不迁移业务逻辑 +- 不接管生产入口 + +## Phase 2: Control Plane MVP + +目标: + +- 提供最小 API +- 提供任务列表和配置读取能力 +- 提供最小 CLI +- 提供 health / logs / settings / tasks 查询能力 + +产物: + +- API server +- config service +- task repository +- runtime doctor + +## Phase 3: Data Plane Adapters + +目标: + +- 把旧系统依赖的外部能力封装成 adapter + +优先顺序: + +1. `ffmpeg` adapter +2. `Groq` adapter +3. `Codex` adapter +4. `biliup` adapter +5. `Bili API` adapter + +理由: + +- 先封装外部依赖,后迁移业务模块,能减少后续反复返工 + +## Phase 4: Module Migration + +按顺序迁移业务模块。 + +### 4.1 Ingest + +- 替代 `monitor.py` 的任务创建部分 + +### 4.2 Transcribe + +- 替代 `video2srt.py` + +### 4.3 Song Detect + +- 替代 `monitorSrt.py` + +### 4.4 Split + +- 替代 `monitorSongs.py` + +### 4.5 Publish + +- 替代 `upload.py` + +### 4.6 Comment + +- 替代 `session_top_comment.py` + +### 4.7 Collection + +- 替代 `add_to_collection.py` + +## Phase 5: Parallel Verification + +目标: + +- 新旧系统并行验证 +- 新系统只处理测试任务 +- 对比产物、日志、状态和 B 站结果 + +重点检查: + +- 字幕结果 +- 歌曲识别结果 +- 切片结果 +- 上传结果 +- 评论和合集结果 + +## Phase 6: Admin UI + +目标: + +- 构建本地控制台 + +第一版包含: + +- 配置页 +- 任务页 +- 模块页 +- 日志页 +- 手动操作页 + +## Phase 7: Cutover + +目标: + +- 新系统逐步接管生产入口 + +顺序建议: + +1. 只接管任务可视化 +2. 接管配置管理 +3. 接管测试任务处理 +4. 接管单模块生产流量 +5. 最终接管全部生产流量 + +## Risks + +### Risk 1: 旧系统与新系统语义不一致 + +缓解: + +- 先定义领域模型和状态机 +- 迁移前写适配层,不直接照抄旧脚本行为 + +### Risk 2: 边迁移边污染旧项目 + +缓解: + +- 所有新内容只放在 `./biliup-next` +- 不改原项目运行入口 + +### Risk 3: UI 先行导致底层不稳 + +缓解: + +- 先做控制面模型和 API +- 最后做 UI + +## Definition Of Done + +迁移完成的标准不是“代码搬完”,而是: + +- 新系统可以独立运行 +- 配置统一管理 +- 状态统一落库 +- UI 可以完整观察任务 +- 旧脚本不再是主入口 diff --git a/docs/module-contracts.md b/docs/module-contracts.md index 6462580..15e37ee 100644 --- a/docs/module-contracts.md +++ b/docs/module-contracts.md @@ -1,229 +1,229 @@ -# Module Contracts - -## Goal - -定义各模块的职责边界、输入输出和契约,避免旧系统中“脚本互相读目录、互相猜状态”的耦合方式。 - -## Contract Principles - -- 每个模块只处理一类能力 -- 模块只接收明确输入,不扫描全世界 -- 模块输出必须结构化 -- 模块不直接操控其他模块的内部实现 -- 模块不直接依赖具体 provider - -## Shared Concepts - -所有模块统一围绕这些对象协作: - -- `Task` -- `TaskStep` -- `Artifact` -- `Settings` -- `ProviderRef` - -## Ingest Module - -### Responsibility - -- 接收文件输入 -- 校验最小处理条件 -- 创建任务 - -### Input - -- 本地文件路径 -- 当前配置 - -### Output - -- `Task` -- `source_video` artifact - -### Must Not Do - -- 不直接转录 -- 不写上传状态 - -## Transcribe Module - -### Responsibility - -- 调用转录 provider -- 生成字幕产物 - -### Input - -- `Task` -- `source_video` artifact -- `transcribe` settings - -### Output - -- `subtitle_srt` artifact - -### Must Not Do - -- 不识别歌曲 -- 不决定切歌策略 - -## Song Detect Module - -### Responsibility - -- 根据字幕识别歌曲 -- 生成歌曲结构化结果 - -### Input - -- `Task` -- `subtitle_srt` artifact -- `song_detect` settings - -### Output - -- `songs_json` artifact -- `songs_txt` artifact - -### Must Not Do - -- 不切歌 -- 不上传 - -## Split Module - -### Responsibility - -- 根据歌曲列表切割纯享版片段 - -### Input - -- `Task` -- `songs_json` artifact -- `source_video` artifact -- `split` settings - -### Output - -- 多个 `clip_video` artifact - -## Publish Module - -### Responsibility - -- 上传纯享版视频 -- 记录发布结果 - -### Input - -- `Task` -- `clip_video[]` -- `publish` settings - -### Output - -- `PublishRecord` -- `publish_bvid` artifact - -### Must Not Do - -- 不负责评论文案生成 -- 不负责合集匹配策略 - -## Comment Module - -### Responsibility - -- 发布并置顶评论 - -### Input - -- `Task` -- `PublishRecord` -- `songs_txt` artifact -- `comment` settings - -### Output - -- `comment_record` artifact - -## Collection Module - -### Responsibility - -- 根据策略同步合集 A / B - -### Input - -- `Task` -- `PublishRecord` 或外部 `full_video_bvid` -- `collection` settings -- `collection strategy` - -### Output - -- `CollectionBinding` - -### Internal Sub-Strategies - -- `full_video_collection_strategy` -- `song_collection_strategy` - -## Provider Contracts - -### TranscribeProvider - -```text -transcribe(task, source_video, settings) -> subtitle_srt -``` - -### SongDetector - -```text -detect(task, subtitle_srt, settings) -> songs_json, songs_txt -``` - -### PublishProvider - -```text -publish(task, clip_videos, settings) -> PublishRecord -``` - -### CommentStrategy - -```text -sync_comment(task, publish_record, songs_txt, settings) -> comment_record -``` - -### CollectionStrategy - -```text -sync_collection(task, context, settings) -> CollectionBinding[] -``` - -## Orchestration Rules - -模块本身不负责全局编排。 - -全局编排由任务引擎或 worker 负责: - -- 判断下一步该跑什么 -- 决定是否重试 -- 写入状态 -- 调度具体模块 - -## Error Contract - -所有模块失败时应返回统一错误结构: - -- `code` -- `message` -- `retryable` -- `details` - -不得只返回原始字符串日志作为唯一错误结果。 - -## Non-Goals - -- 模块之间不共享私有目录扫描逻辑 -- 模块契约不直接暴露 shell 命令细节 +# Module Contracts + +## Goal + +定义各模块的职责边界、输入输出和契约,避免旧系统中“脚本互相读目录、互相猜状态”的耦合方式。 + +## Contract Principles + +- 每个模块只处理一类能力 +- 模块只接收明确输入,不扫描全世界 +- 模块输出必须结构化 +- 模块不直接操控其他模块的内部实现 +- 模块不直接依赖具体 provider + +## Shared Concepts + +所有模块统一围绕这些对象协作: + +- `Task` +- `TaskStep` +- `Artifact` +- `Settings` +- `ProviderRef` + +## Ingest Module + +### Responsibility + +- 接收文件输入 +- 校验最小处理条件 +- 创建任务 + +### Input + +- 本地文件路径 +- 当前配置 + +### Output + +- `Task` +- `source_video` artifact + +### Must Not Do + +- 不直接转录 +- 不写上传状态 + +## Transcribe Module + +### Responsibility + +- 调用转录 provider +- 生成字幕产物 + +### Input + +- `Task` +- `source_video` artifact +- `transcribe` settings + +### Output + +- `subtitle_srt` artifact + +### Must Not Do + +- 不识别歌曲 +- 不决定切歌策略 + +## Song Detect Module + +### Responsibility + +- 根据字幕识别歌曲 +- 生成歌曲结构化结果 + +### Input + +- `Task` +- `subtitle_srt` artifact +- `song_detect` settings + +### Output + +- `songs_json` artifact +- `songs_txt` artifact + +### Must Not Do + +- 不切歌 +- 不上传 + +## Split Module + +### Responsibility + +- 根据歌曲列表切割纯享版片段 + +### Input + +- `Task` +- `songs_json` artifact +- `source_video` artifact +- `split` settings + +### Output + +- 多个 `clip_video` artifact + +## Publish Module + +### Responsibility + +- 上传纯享版视频 +- 记录发布结果 + +### Input + +- `Task` +- `clip_video[]` +- `publish` settings + +### Output + +- `PublishRecord` +- `publish_bvid` artifact + +### Must Not Do + +- 不负责评论文案生成 +- 不负责合集匹配策略 + +## Comment Module + +### Responsibility + +- 发布并置顶评论 + +### Input + +- `Task` +- `PublishRecord` +- `songs_txt` artifact +- `comment` settings + +### Output + +- `comment_record` artifact + +## Collection Module + +### Responsibility + +- 根据策略同步合集 A / B + +### Input + +- `Task` +- `PublishRecord` 或外部 `full_video_bvid` +- `collection` settings +- `collection strategy` + +### Output + +- `CollectionBinding` + +### Internal Sub-Strategies + +- `full_video_collection_strategy` +- `song_collection_strategy` + +## Provider Contracts + +### TranscribeProvider + +```text +transcribe(task, source_video, settings) -> subtitle_srt +``` + +### SongDetector + +```text +detect(task, subtitle_srt, settings) -> songs_json, songs_txt +``` + +### PublishProvider + +```text +publish(task, clip_videos, settings) -> PublishRecord +``` + +### CommentStrategy + +```text +sync_comment(task, publish_record, songs_txt, settings) -> comment_record +``` + +### CollectionStrategy + +```text +sync_collection(task, context, settings) -> CollectionBinding[] +``` + +## Orchestration Rules + +模块本身不负责全局编排。 + +全局编排由任务引擎或 worker 负责: + +- 判断下一步该跑什么 +- 决定是否重试 +- 写入状态 +- 调度具体模块 + +## Error Contract + +所有模块失败时应返回统一错误结构: + +- `code` +- `message` +- `retryable` +- `details` + +不得只返回原始字符串日志作为唯一错误结果。 + +## Non-Goals + +- 模块之间不共享私有目录扫描逻辑 +- 模块契约不直接暴露 shell 命令细节 diff --git a/docs/plugin-system.md b/docs/plugin-system.md index 4f2c859..fadc015 100644 --- a/docs/plugin-system.md +++ b/docs/plugin-system.md @@ -1,156 +1,156 @@ -# Plugin System - -## Goal - -插件系统的目标不是“让任何东西都能热插拔”,而是为未来的能力替换和扩展提供稳定边界。 - -优先支持: - -- 转录提供者替换 -- 歌曲识别提供者替换 -- 上传器替换 -- 评论策略替换 -- 合集策略替换 -- 输入源扩展 - -## Design Principles - -借鉴 OpenClaw 的思路,采用 `manifest-first` + `registry` 设计。 - -原则: - -- 插件先注册元信息,再执行运行时代码 -- 控制面优先读取 manifest 和 schema -- 核心系统只依赖抽象接口和 registry -- 插件配置必须可校验 - -## Plugin Composition - -每个插件由两部分组成: - -### 1. Manifest - -描述插件的元信息和配置能力。 - -例如: - -```json -{ - "id": "codex-song-detector", - "name": "Codex Song Detector", - "version": "0.1.0", - "type": "song_detector", - "entrypoint": "plugins.codex_song_detector.runtime:register", - "configSchema": "plugins/codex_song_detector/config.schema.json", - "capabilities": ["song_detect"], - "enabledByDefault": true -} -``` - -### 2. Runtime - -真正实现业务逻辑的代码。 - -## Registry - -系统启动时统一构建 registry。 - -registry 负责: - -- 注册插件能力 -- 按类型查找实现 -- 根据配置激活当前 provider - -### Registry Types - -- `ingest_provider` -- `transcribe_provider` -- `song_detector` -- `split_provider` -- `publish_provider` -- `comment_strategy` -- `collection_strategy` - -## Plugin Loading Flow - -```text -Discover manifests - -> Validate manifests - -> Register capabilities in registry - -> Load plugin config schema - -> Validate plugin config - -> Activate runtime implementation -``` - -## Why Manifest-First - -这样设计有 4 个直接好处: - -- 管理台可以在不执行插件代码时展示插件信息 -- UI 可以根据 schema 渲染配置表单 -- 系统可以提前发现缺失字段或不兼容版本 -- 插件运行失败不会影响元数据层的可见性 - -## Suggested Plugin Boundaries - -### Transcribe Provider - -示例: - -- `groq` -- `openai` -- `local_whisper` - -### Song Detector - -示例: - -- `codex` -- `rule_engine` -- `custom_llm` - -### Publish Provider - -示例: - -- `biliup_cli` -- `bilibili_api` - -### Collection Strategy - -示例: - -- `default_song_collection` -- `title_match_full_video` -- `manual_binding` - -## Control Plane Integration - -插件系统必须服务于控制面。 - -因此管理台至少需要知道: - -- 当前有哪些插件 -- 每个插件类型是什么 -- 当前启用的是哪一个 -- 配置是否有效 -- 最近一次健康检查结果 - -## Restrictions - -为了避免再次走向“任意脚本散落”,插件系统需要约束: - -- 插件不得直接修改核心数据库结构 -- 插件不得绕过统一配置系统 -- 插件不得私自写独立日志目录作为唯一状态来源 -- 插件不得直接互相调用具体实现 - -## Initial Strategy - -第一阶段不追求真正的第三方插件生态。 - -先实现“内置插件化”: - -- 核心仓库内提供多个 provider -- 统一用 manifest + registry 管理 -- 等边界稳定后,再考虑开放外部插件目录 +# Plugin System + +## Goal + +插件系统的目标不是“让任何东西都能热插拔”,而是为未来的能力替换和扩展提供稳定边界。 + +优先支持: + +- 转录提供者替换 +- 歌曲识别提供者替换 +- 上传器替换 +- 评论策略替换 +- 合集策略替换 +- 输入源扩展 + +## Design Principles + +借鉴 OpenClaw 的思路,采用 `manifest-first` + `registry` 设计。 + +原则: + +- 插件先注册元信息,再执行运行时代码 +- 控制面优先读取 manifest 和 schema +- 核心系统只依赖抽象接口和 registry +- 插件配置必须可校验 + +## Plugin Composition + +每个插件由两部分组成: + +### 1. Manifest + +描述插件的元信息和配置能力。 + +例如: + +```json +{ + "id": "codex-song-detector", + "name": "Codex Song Detector", + "version": "0.1.0", + "type": "song_detector", + "entrypoint": "plugins.codex_song_detector.runtime:register", + "configSchema": "plugins/codex_song_detector/config.schema.json", + "capabilities": ["song_detect"], + "enabledByDefault": true +} +``` + +### 2. Runtime + +真正实现业务逻辑的代码。 + +## Registry + +系统启动时统一构建 registry。 + +registry 负责: + +- 注册插件能力 +- 按类型查找实现 +- 根据配置激活当前 provider + +### Registry Types + +- `ingest_provider` +- `transcribe_provider` +- `song_detector` +- `split_provider` +- `publish_provider` +- `comment_strategy` +- `collection_strategy` + +## Plugin Loading Flow + +```text +Discover manifests + -> Validate manifests + -> Register capabilities in registry + -> Load plugin config schema + -> Validate plugin config + -> Activate runtime implementation +``` + +## Why Manifest-First + +这样设计有 4 个直接好处: + +- 管理台可以在不执行插件代码时展示插件信息 +- UI 可以根据 schema 渲染配置表单 +- 系统可以提前发现缺失字段或不兼容版本 +- 插件运行失败不会影响元数据层的可见性 + +## Suggested Plugin Boundaries + +### Transcribe Provider + +示例: + +- `groq` +- `openai` +- `local_whisper` + +### Song Detector + +示例: + +- `codex` +- `rule_engine` +- `custom_llm` + +### Publish Provider + +示例: + +- `biliup_cli` +- `bilibili_api` + +### Collection Strategy + +示例: + +- `default_song_collection` +- `title_match_full_video` +- `manual_binding` + +## Control Plane Integration + +插件系统必须服务于控制面。 + +因此管理台至少需要知道: + +- 当前有哪些插件 +- 每个插件类型是什么 +- 当前启用的是哪一个 +- 配置是否有效 +- 最近一次健康检查结果 + +## Restrictions + +为了避免再次走向“任意脚本散落”,插件系统需要约束: + +- 插件不得直接修改核心数据库结构 +- 插件不得绕过统一配置系统 +- 插件不得私自写独立日志目录作为唯一状态来源 +- 插件不得直接互相调用具体实现 + +## Initial Strategy + +第一阶段不追求真正的第三方插件生态。 + +先实现“内置插件化”: + +- 核心仓库内提供多个 provider +- 统一用 manifest + registry 管理 +- 等边界稳定后,再考虑开放外部插件目录 diff --git a/docs/professionalization-roadmap-2026-04-06.md b/docs/professionalization-roadmap-2026-04-06.md index f2716fe..8d5aa94 100644 --- a/docs/professionalization-roadmap-2026-04-06.md +++ b/docs/professionalization-roadmap-2026-04-06.md @@ -1,178 +1,178 @@ -# biliup-next Professionalization Roadmap - 2026-04-06 - -## 目标 - -把 `biliup-next` 从“方向正确的重构工程”推进到“边界清晰、契约稳定、可持续演进的专业级本地控制面系统”。 - -本路线图以当前仓库中已经明确吸收的 OpenClaw 设计哲学为参照: - -- modular monolith -- control-plane first -- schema-first -- manifest-first -- registry over direct coupling -- single source of truth - -重点不是重复这些口号,而是把它们继续落实到真实代码和工程制度中。 - -## 维度一:平台边界 - -### 当前差距 - -- provider 内仍大量直接调用 `subprocess` 和 `requests` -- adapter / provider / module service 的边界还不够硬 -- 外部依赖的超时、重试、错误翻译和观测没有统一制度 - -### 目标状态 - -- 外部命令和外部 HTTP 都通过稳定 adapter 层进入系统 -- provider 只消费标准化 adapter 能力和统一错误语义 -- 超时、重试、限流、日志和诊断在 adapter 层具备统一约束 - -### 改进事项 - -- 为 `ffmpeg`、`codex`、`biliup`、Bili API、Groq 定义统一 adapter 接口 -- 将 provider 中的直接 `subprocess.run()` 和 `requests` 逐步下沉到 adapter -- 统一 adapter 错误模型,减少 provider 自己拼接临时错误码 -- 为 adapter 增加可观测上下文,例如 command name、target、duration、attempt - -### 完成标志 - -- 业务模块不再直接拼 shell/http 调用 -- adapter 成为唯一外部依赖入口 - -## 维度二:领域模型 - -### 当前差距 - -- 核心规则分散在 `task_engine`、`task_policies`、`task_actions`、provider 和部分工作区文件 -- 文档已有 domain model,但还没有形成更稳定的应用服务/领域服务边界 -- `task`、`session`、`full_video_bvid` 这类跨模块关系仍有隐式规则 - -### 目标状态 - -- task lifecycle、retry policy、session binding、delivery side effects 都有清晰归属 -- 领域规则主要存在于少数稳定模块,而不是散落在控制器和 provider 中 -- “谁负责写什么状态”有明确制度 - -### 改进事项 - -- 明确 `Task`、`TaskContext`、`SessionBinding` 的边界和 ownership -- 把 `full_video_bvid`、session 归并、评论/合集副作用收敛成独立领域服务 -- 评估是否引入显式 domain event 或最小事件记录层 -- 为状态迁移建立更显式的 transition table 或 policy object - -### 完成标志 - -- 关键规则不再分散在多个入口函数中重复实现 -- task/session/delivery 的事实源和写入职责稳定 - -## 维度三:接口契约 - -### 当前差距 - -- API handler 仍承担较多 payload 组装和视图拼接工作 -- OpenAPI 与真实控制面细节还不够同步 -- 内部领域模型与外部 API 视图没有充分分层 - -### 目标状态 - -- API 对外暴露稳定 DTO,而不是直接拼内部模型 -- handler 更薄,组装逻辑集中在 service / presenter / serializer 层 -- 契约变更可追踪、可校验 - -### 改进事项 - -- 为 task detail、task list、session detail、timeline 建立稳定 serializer -- 清理 API handler 中的重复组装逻辑 -- 更新 `docs/api/openapi.yaml`,让其覆盖真实控制面接口 -- 明确哪些字段属于内部实现细节,不直接暴露给前端 - -### 完成标志 - -- handler 只做路由、鉴权、输入解析和响应返回 -- API 文档与真实返回结构保持同步 - -## 维度四:测试体系 - -### 当前差距 - -- 已有最小回归测试,但仍偏重纯逻辑 -- repository、API、provider 契约、端到端场景覆盖不足 - -### 目标状态 - -- 核心编排、存储、API、adapter 都有分层测试 -- 关键重构不需要依赖手工回归 - -### 改进事项 - -- 新增 repository 的 SQLite 集成测试 -- 为 API handler 增加最小接口行为测试 -- 为 adapter/provider 增加契约测试和失败场景测试 -- 保留现有纯逻辑 unittest,继续增加 smoke 回归脚本 - -### 完成标志 - -- 至少形成: - - 逻辑单元测试 - - SQLite 集成测试 - - API 行为测试 - - smoke / regression 流程 - -## 维度五:运维成熟度 - -### 当前差距 - -- 已有 doctor、logs、systemd 控制和 workspace 隔离 -- 但健康度、指标、审计、恢复机制还不够体系化 - -### 目标状态 - -- 控制面不仅能“看到状态”,还能帮助判断风险和恢复问题 -- 运行问题可以靠结构化信号而不是人工翻日志定位 - -### 改进事项 - -- 区分 health / readiness / degraded -- 规范结构化日志字段 -- 为 task/step 增加最小指标视图 -- 完善审计事件分类 -- 明确数据库/配置变更/运行资产的迁移与回滚流程 - -### 完成标志 - -- 常见运行问题可以靠控制面和标准日志定位 -- 关键操作具备审计和回滚说明 - -## 推荐优先顺序 - -1. 平台边界 -2. 领域模型 -3. 接口契约 -4. 测试体系 -5. 运维成熟度 - -## 下一批优先项 - -### Priority A - -- 为 `biliup`、Bili API 和 `codex` 建立统一 adapter 边界 -- 把 `task_actions` 中与 session/delivery 相关的规则继续抽成稳定服务 -- 为 task list / task detail / session detail 提供 serializer 层 - -### Priority B - -- 新增 repository SQLite 集成测试 -- 新增 API 行为测试 -- 更新 OpenAPI 契约 - -### Priority C - -- 设计 health/readiness/degraded 模型 -- 规范日志和审计字段 - -## 备注 - -- 这份路线图描述的是“距离专业化还有哪些结构性工作”,不是说当前系统不可用。 -- 当前项目已经具备正确方向;接下来的重点是把设计哲学继续固化为代码边界、测试制度和运维约束。 +# biliup-next Professionalization Roadmap - 2026-04-06 + +## 目标 + +把 `biliup-next` 从“方向正确的重构工程”推进到“边界清晰、契约稳定、可持续演进的专业级本地控制面系统”。 + +本路线图以当前仓库中已经明确吸收的 OpenClaw 设计哲学为参照: + +- modular monolith +- control-plane first +- schema-first +- manifest-first +- registry over direct coupling +- single source of truth + +重点不是重复这些口号,而是把它们继续落实到真实代码和工程制度中。 + +## 维度一:平台边界 + +### 当前差距 + +- provider 内仍大量直接调用 `subprocess` 和 `requests` +- adapter / provider / module service 的边界还不够硬 +- 外部依赖的超时、重试、错误翻译和观测没有统一制度 + +### 目标状态 + +- 外部命令和外部 HTTP 都通过稳定 adapter 层进入系统 +- provider 只消费标准化 adapter 能力和统一错误语义 +- 超时、重试、限流、日志和诊断在 adapter 层具备统一约束 + +### 改进事项 + +- 为 `ffmpeg`、`codex`、`biliup`、Bili API、Groq 定义统一 adapter 接口 +- 将 provider 中的直接 `subprocess.run()` 和 `requests` 逐步下沉到 adapter +- 统一 adapter 错误模型,减少 provider 自己拼接临时错误码 +- 为 adapter 增加可观测上下文,例如 command name、target、duration、attempt + +### 完成标志 + +- 业务模块不再直接拼 shell/http 调用 +- adapter 成为唯一外部依赖入口 + +## 维度二:领域模型 + +### 当前差距 + +- 核心规则分散在 `task_engine`、`task_policies`、`task_actions`、provider 和部分工作区文件 +- 文档已有 domain model,但还没有形成更稳定的应用服务/领域服务边界 +- `task`、`session`、`full_video_bvid` 这类跨模块关系仍有隐式规则 + +### 目标状态 + +- task lifecycle、retry policy、session binding、delivery side effects 都有清晰归属 +- 领域规则主要存在于少数稳定模块,而不是散落在控制器和 provider 中 +- “谁负责写什么状态”有明确制度 + +### 改进事项 + +- 明确 `Task`、`TaskContext`、`SessionBinding` 的边界和 ownership +- 把 `full_video_bvid`、session 归并、评论/合集副作用收敛成独立领域服务 +- 评估是否引入显式 domain event 或最小事件记录层 +- 为状态迁移建立更显式的 transition table 或 policy object + +### 完成标志 + +- 关键规则不再分散在多个入口函数中重复实现 +- task/session/delivery 的事实源和写入职责稳定 + +## 维度三:接口契约 + +### 当前差距 + +- API handler 仍承担较多 payload 组装和视图拼接工作 +- OpenAPI 与真实控制面细节还不够同步 +- 内部领域模型与外部 API 视图没有充分分层 + +### 目标状态 + +- API 对外暴露稳定 DTO,而不是直接拼内部模型 +- handler 更薄,组装逻辑集中在 service / presenter / serializer 层 +- 契约变更可追踪、可校验 + +### 改进事项 + +- 为 task detail、task list、session detail、timeline 建立稳定 serializer +- 清理 API handler 中的重复组装逻辑 +- 更新 `docs/api/openapi.yaml`,让其覆盖真实控制面接口 +- 明确哪些字段属于内部实现细节,不直接暴露给前端 + +### 完成标志 + +- handler 只做路由、鉴权、输入解析和响应返回 +- API 文档与真实返回结构保持同步 + +## 维度四:测试体系 + +### 当前差距 + +- 已有最小回归测试,但仍偏重纯逻辑 +- repository、API、provider 契约、端到端场景覆盖不足 + +### 目标状态 + +- 核心编排、存储、API、adapter 都有分层测试 +- 关键重构不需要依赖手工回归 + +### 改进事项 + +- 新增 repository 的 SQLite 集成测试 +- 为 API handler 增加最小接口行为测试 +- 为 adapter/provider 增加契约测试和失败场景测试 +- 保留现有纯逻辑 unittest,继续增加 smoke 回归脚本 + +### 完成标志 + +- 至少形成: + - 逻辑单元测试 + - SQLite 集成测试 + - API 行为测试 + - smoke / regression 流程 + +## 维度五:运维成熟度 + +### 当前差距 + +- 已有 doctor、logs、systemd 控制和 workspace 隔离 +- 但健康度、指标、审计、恢复机制还不够体系化 + +### 目标状态 + +- 控制面不仅能“看到状态”,还能帮助判断风险和恢复问题 +- 运行问题可以靠结构化信号而不是人工翻日志定位 + +### 改进事项 + +- 区分 health / readiness / degraded +- 规范结构化日志字段 +- 为 task/step 增加最小指标视图 +- 完善审计事件分类 +- 明确数据库/配置变更/运行资产的迁移与回滚流程 + +### 完成标志 + +- 常见运行问题可以靠控制面和标准日志定位 +- 关键操作具备审计和回滚说明 + +## 推荐优先顺序 + +1. 平台边界 +2. 领域模型 +3. 接口契约 +4. 测试体系 +5. 运维成熟度 + +## 下一批优先项 + +### Priority A + +- 为 `biliup`、Bili API 和 `codex` 建立统一 adapter 边界 +- 把 `task_actions` 中与 session/delivery 相关的规则继续抽成稳定服务 +- 为 task list / task detail / session detail 提供 serializer 层 + +### Priority B + +- 新增 repository SQLite 集成测试 +- 新增 API 行为测试 +- 更新 OpenAPI 契约 + +### Priority C + +- 设计 health/readiness/degraded 模型 +- 规范日志和审计字段 + +## 备注 + +- 这份路线图描述的是“距离专业化还有哪些结构性工作”,不是说当前系统不可用。 +- 当前项目已经具备正确方向;接下来的重点是把设计哲学继续固化为代码边界、测试制度和运维约束。 diff --git a/docs/publish-output-examples.md b/docs/publish-output-examples.md new file mode 100644 index 0000000..cdb2707 --- /dev/null +++ b/docs/publish-output-examples.md @@ -0,0 +1,321 @@ +# 发布输出示例与流程说明 + +本文档面向使用者说明 `biliup-next` 的主流程、输入输出、当前已实现功能,以及一次多段同场直播发布后的示例文案。 + +## 项目功能 + +`biliup-next` 将一场直播录播拆成两个最终发布目标: + +- 直播完整版:由外部流程或人工上传到 B 站,本项目负责记录/绑定它的 BV 号,并给它补充置顶时间轴评论、加入完整版合集。 +- 歌曲纯享版:由本项目从直播录播中识别歌曲、切出歌曲片段、合并发布为一个分 P 视频,并给它补充置顶歌单评论、加入纯享版合集。 + +当前主链路: + +```text +stage 输入视频 + -> ingest 导入并归并 session + -> transcribe 语音转字幕 + -> song_detect 识别歌曲 + -> split 切出歌曲片段 + -> publish 发布歌曲纯享版 + -> comment 发布/置顶评论 + -> collection 加入合集 +``` + +## 输入 + +最常见输入是把录播视频放入 `data/workspace/stage/`。 + +支持的形式: + +- 单个视频文件:一场直播只有一个录播文件。 +- 多个视频文件:同一场直播被分成多段录播文件。 +- 浏览器上传:通过控制台上传到 stage。 +- 本机复制:通过控制台把服务器上的文件复制到 stage。 + +输入文件名会用于推测主播和直播开始时间,例如: + +```text +王海颖唱歌录播 04月19日 22时10分.mp4 +王海颖唱歌录播 04月19日 23时05分.mp4 +王海颖唱歌录播 04月20日 00时01分.mp4 +``` + +## Session 归并 + +同一主播、时间接近的多个录播片段会归入同一个 session。 + +同一 session 的行为: + +- 只发布一个歌曲纯享版 BV。 +- 多段录播的歌曲会按时间顺序聚合。 +- 评论按 `P1`、`P2`、`P3` 分段展示。 +- 歌曲序号全局递增,不在每个 P 内重新从 1 开始。 + +示例: + +```text +P1: +1. 程艾影 — 赵雷 +2. 钟无艳 — 谢安琪 + +P2: +3. 慢慢喜欢你 — 莫文蔚 + +P3: +4. 空白格 — 蔡健雅 +``` + +## BV 获取 + +### 歌曲纯享版 BV + +歌曲纯享版由本项目调用 `biliup upload` 发布。 + +发布成功后,项目会从 `biliup` 输出中提取 BV 号,并写入当前 session 目录: + +```text +bvid.txt +``` + +这个 BV 会用于: + +- 纯享版评论发布。 +- 完整版评论顶部反向链接。 +- 纯享版合集同步。 + +### 直播完整版 BV + +完整版 BV 可以来自三种方式: + +- 控制台手动绑定。 +- API/webhook 传入。 +- `biliup list` 标题匹配。 + +`biliup list` 会同时接受 `开放浏览` 和 `审核中` 状态。完整版视频只要上传后生成了 BV,即使仍在审核中,也可以被写入纯享版简介、动态和评论互链。 + +成功解析后会写入: + +```text +full_video_bvid.txt +``` + +默认标题匹配是保守的精确匹配:会先去掉空格、标点、括号、冒号等,只保留中文、英文、数字,再比较标题是否相等。 + +如果 `allow_fuzzy_full_video_match=false`,不会做包含式模糊匹配。为了避免误匹配,推荐在完整版上传完成后手动绑定 BV。 + +## 示例场景 + +假设本次直播由三段录播组成: + +```text +王海颖唱歌录播 04月19日 22时10分 +王海颖唱歌录播 04月19日 23时05分 +王海颖唱歌录播 04月20日 00时01分 +``` + +假设 BV 绑定结果如下: + +```text +本次直播完整版:BVFULLCURR +本次歌曲纯享版:BVPURECURR +上次直播完整版:BVFULLPREV +``` + +假设识别出的歌曲如下: + +```text +P1: +00:06:32 程艾影 — 赵雷 +00:14:45 钟无艳 — 谢安琪 + +P2: +00:20:57 慢慢喜欢你 — 莫文蔚 + +P3: +00:27:16 空白格 — 蔡健雅 +``` + +## 歌曲纯享版标题 + +当前模板: + +```text +【{streamer} (歌曲纯享版)】 {date} 共{song_count}首歌 +``` + +示例: + +```text +【王海颖 (歌曲纯享版)】 04月19日 22时10分 共4首歌 +``` + +## 歌曲纯享版简介 + +当前模板会保持简介较短,完整歌单放到置顶评论中,避免 B 站简介截断。 + +示例: + +```text +王海颖 04月19日 22时10分 歌曲纯享版。 + +完整歌单与时间轴见置顶评论。 +直播完整版:https://www.bilibili.com/video/BVFULLCURR +上次直播:https://www.bilibili.com/video/BVFULLPREV + +本视频为歌曲纯享切片,适合只听歌曲。 +``` + +如果某个链接暂时没有 BV,项目会自动移除对应的空链接行。 + +## 歌曲纯享版动态 + +示例: + +```text +王海颖 04月19日 22时10分 歌曲纯享版已发布。完整歌单见置顶评论。 +直播完整版:https://www.bilibili.com/video/BVFULLCURR +上次直播:https://www.bilibili.com/video/BVFULLPREV +``` + +## 歌曲纯享版置顶评论 + +纯享版评论主要给听歌用户看,不带歌曲时间轴,只展示歌名、歌手和互链。 + +默认由 `runtime/upload_config.json` 的 `comment_template.split_header`、`comment_template.split_part_header`、`comment_template.split_song_line` 生成。 + +示例: + +```text +当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。 +直播完整版:https://www.bilibili.com/video/BVFULLCURR (完整录播,含聊天/互动/完整流程) +上次纯享:https://www.bilibili.com/video/BVPUREPREV (上一场歌曲纯享版) + +P1: +1. 程艾影 — 赵雷 +2. 钟无艳 — 谢安琪 + +P2: +3. 慢慢喜欢你 — 莫文蔚 + +P3: +4. 空白格 — 蔡健雅 +``` + +## 直播完整版置顶评论 + +完整版评论主要给看完整录播的用户跳转歌曲纯享版,并提供完整时间轴。 + +默认由 `runtime/upload_config.json` 的 `comment_template.full_header`、`comment_template.full_part_header`、`comment_template.full_timeline_line` 生成。 + +示例: + +```text +当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。 +歌曲纯享版:https://www.bilibili.com/video/BVPURECURR (只听歌曲看这里) +上次完整版:https://www.bilibili.com/video/BVFULLPREV (上一场完整录播) + +P1: +1. 00:06:32 程艾影 — 赵雷 +2. 00:14:45 钟无艳 — 谢安琪 + +P2: +3. 00:20:57 慢慢喜欢你 — 莫文蔚 + +P3: +4. 00:27:16 空白格 — 蔡健雅 +``` + +## 评论格式配置 + +评论格式可以像标题、简介、动态一样通过 `runtime/upload_config.json` 修改: + +```json +"comment_template": { + "split_header": "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n直播完整版:{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n上次纯享:{previous_pure_video_link} (上一场歌曲纯享版)", + "full_header": "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n歌曲纯享版:{current_pure_video_link} (只听歌曲看这里)\n上次完整版:{previous_full_video_link} (上一场完整录播)", + "split_part_header": "P{part_index}:", + "full_part_header": "P{part_index}:", + "split_song_line": "{song_index}. {title}{artist_suffix}", + "split_text_song_line": "{song_index}. {song_text}", + "full_timeline_line": "{song_index}. {line_text}" +} +``` + +字段含义: + +- `split_header`:纯享版评论顶部说明。 +- `full_header`:完整版评论顶部说明。 +- `split_part_header` / `full_part_header`:多片段 session 的分段标题,例如 `P1:`。 +- `split_song_line`:从 `songs.json` 生成纯享歌单时的单行格式。 +- `split_text_song_line`:`songs.json` 不可用时,从 `songs.txt` 兜底生成纯享歌单的单行格式。 +- `full_timeline_line`:完整版时间轴评论的单行格式。 + +常用变量: + +- `{current_full_video_link}`:本场直播完整版链接。 +- `{current_pure_video_link}`:本场歌曲纯享版链接。 +- `{previous_full_video_link}`:上一场直播完整版链接。 +- `{previous_pure_video_link}`:上一场歌曲纯享版链接。 +- `{part_index}`:P 分段序号。 +- `{song_index}`:歌曲全局序号。 +- `{title}` / `{artist}` / `{artist_suffix}`:歌曲标题、歌手、带分隔符的歌手后缀。 +- `{song_text}`:不带时间戳的歌曲文本。 +- `{line_text}`:原始时间轴行,通常包含时间戳。 + +如果评论头部某一行包含空链接变量,例如 `{previous_full_video_link}` 为空,这一整行会自动省略。 + +## 合集同步 + +项目维护两个合集目标: + +- 合集 A:直播完整版。 +- 合集 B:歌曲纯享版。 + +当前配置中的示例 ID: + +```text +直播完整版合集:7196643 +歌曲纯享版合集:7196624 +``` + +合集同步完成后,如果启用了清理策略,项目可以删除本地原视频或切片视频以节省空间。当前默认不删除。 + +## 幂等与重试 + +项目会在 session 目录写入标记文件,避免重复上传和重复评论。 + +常见标记: + +```text +bvid.txt +full_video_bvid.txt +upload_done.flag +comment_split_done.flag +comment_full_done.flag +collection_a_done.flag +collection_b_done.flag +``` + +发布阶段的关键行为: + +- 首批最多上传 5 个分 P。 +- 超过 5 个分 P 时,后续通过 append 追加。 +- 已经写入 `bvid.txt` 后,重试会优先 append 到已有视频,而不是重新发布。 +- `publish_progress.json` 记录 append 进度,避免重试时重复追加已完成批次。 + +评论阶段的关键行为: + +- 同一 session 只由最早片段负责聚合评论。 +- 非 anchor 片段进入评论步骤时会跳过实际发评。 +- 这样可以避免同一场直播的多个片段重复发布相同评论。 + +## 使用建议 + +发布前建议确认: + +- stage 中的视频文件名能解析出主播和时间。 +- `runtime/upload_config.json` 中标题、简介、动态符合预期。 +- 完整版上传完成后,尽量手动绑定 `full_video_bvid`。 +- worker 重启前确认已有 `bvid.txt` 和 `publish_progress.json` 是否符合当前发布进度。 +- 如需自动匹配完整版 BV,确认 `biliup list` 中完整视频标题与任务标题标准化后相等。 diff --git a/docs/refactor-plan-2026-04-06.md b/docs/refactor-plan-2026-04-06.md index 3d8f603..16b08e9 100644 --- a/docs/refactor-plan-2026-04-06.md +++ b/docs/refactor-plan-2026-04-06.md @@ -1,134 +1,134 @@ -# biliup-next Refactor Plan - 2026-04-06 - -## 目标 - -围绕当前重构项目已暴露出的状态一致性、数据一致性、运行稳定性和控制面性能问题,分阶段推进改造,优先修复会影响真实运行结果的问题,再收敛模型和技术债。 - -## 改造原则 - -- 先修正单一事实源,再优化展示层。 -- 先修正状态机真实行为,再修正文档和 UI 映射。 -- 先处理运行稳定性,再处理性能和结构整理。 -- 每一阶段都要求有可验证的验收结果,避免只做“结构看起来更好”。 - -## 阶段划分 - -### Phase 1: 状态与事实源收敛 - -目标: - -- 让 task 具备真实可用的 `running` 语义。 -- 让 `full_video_bvid` 只有一套权威写入路径。 -- 消除“数据库状态”和“工作区文件状态”互相覆盖的问题。 - -任务: - -- 在 step 开始执行时同步更新 task 运行态。 -- 明确 task 完成后 task 状态如何从 `running` 返回业务态。 -- 统一 `bind/rebind/webhook/ingest` 对 `full_video_bvid` 的读写入口。 -- 明确 `task_contexts`、`session_bindings`、`full_video_bvid.txt` 的职责。 - -验收标准: - -- 控制台能正确筛选和显示运行中的任务。 -- 手工绑定、session 重绑、webhook 注入后,新旧任务读取到相同 BV。 -- 不再出现新任务 ingest 继承旧 BV 的情况。 - -### Phase 2: 运行稳定性加固 - -目标: - -- 让 API 与 worker 并行运行时的 SQLite 行为可控。 -- 降低锁冲突、脏状态和半成功写入风险。 - -任务: - -- 为 SQLite 连接增加 `busy_timeout`、`WAL`、`foreign_keys=ON`。 -- 检查高频 repo 写入点,减少不必要的小事务。 -- 梳理关键写路径是否需要合并成原子操作。 - -验收标准: - -- API 和 worker 并行运行时,不再轻易触发数据库锁错误。 -- 关键任务状态写入具备基本原子性,不出现“步骤更新了、任务没更新”一类半状态。 - -### Phase 3: 控制面装配与查询优化 - -目标: - -- 去掉 API 请求路径上的重复初始化。 -- 解决 `/tasks` 列表的全量扫描和 N+1 查询问题。 - -任务: - -- 将 `ensure_initialized()` 从“每次请求即装配”改为更稳定的应用级初始化方式。 -- 收敛 provider/registry 生命周期,避免每次请求重复扫描 manifest 和实例化 provider。 -- 优化任务列表接口,把可下推的过滤逻辑下推到 repository 或持久化层。 -- 减少列表查询时对工作区文件的逐条读取。 - -验收标准: - -- 常规 API 请求不再重复做全量装配。 -- 大量任务下的列表页和筛选页响应明显改善。 - -### Phase 4: 状态机与文档对齐 - -目标: - -- 让文档状态机、代码状态机、控制面展示口径一致。 - -任务: - -- 决定是否保留 `ingested`、`completed`、`cancelled`。 -- 明确 flag 文件在系统中的角色。 -- 如果数据库是任务状态唯一来源,则把 delivery flag 降级为产物或外部副作用标记。 -- 更新状态机文档、控制面展示文案和开发约束。 - -验收标准: - -- 文档中的状态集合与代码中的状态集合一致。 -- UI 不再依赖不存在或含义不稳定的 task 状态。 - -### Phase 5: 回归测试与维护收尾 - -目标: - -- 为核心编排逻辑补回归保护。 -- 降低后续重构再次引入状态漂移的概率。 - -任务: - -- 新增 `tests/`。 -- 优先覆盖: - - `task_engine` - - `task_policies` - - `task_actions` - - `retry_meta` - - `task_reset` -- 决定 classic 控制台的保留策略。 - -验收标准: - -- 核心状态流转具备最小自动化回归覆盖。 -- 控制台维护策略明确,不再长期双线漂移。 - -## 推荐执行顺序 - -1. Phase 1 -2. Phase 2 -3. Phase 3 -4. Phase 4 -5. Phase 5 - -## 本轮起步范围 - -本轮先从以下子项开始: - -- Phase 1.1: task `running` 状态落地 -- Phase 1.2: `full_video_bvid` 写路径统一 -- Phase 2.1: SQLite 连接配置加固 - -## 过程记录 - -- 2026-04-06:完成代码审查,确认当前优先问题集中在 task 运行态缺失、`full_video_bvid` 多源不一致、SQLite 并发配置不足、重复初始化、列表查询 N+1、状态机文档与实现漂移、测试缺失。 -- 2026-04-06:将问题整理为本改造计划,按阶段拆分,并确定先做状态一致性与运行稳定性。 +# biliup-next Refactor Plan - 2026-04-06 + +## 目标 + +围绕当前重构项目已暴露出的状态一致性、数据一致性、运行稳定性和控制面性能问题,分阶段推进改造,优先修复会影响真实运行结果的问题,再收敛模型和技术债。 + +## 改造原则 + +- 先修正单一事实源,再优化展示层。 +- 先修正状态机真实行为,再修正文档和 UI 映射。 +- 先处理运行稳定性,再处理性能和结构整理。 +- 每一阶段都要求有可验证的验收结果,避免只做“结构看起来更好”。 + +## 阶段划分 + +### Phase 1: 状态与事实源收敛 + +目标: + +- 让 task 具备真实可用的 `running` 语义。 +- 让 `full_video_bvid` 只有一套权威写入路径。 +- 消除“数据库状态”和“工作区文件状态”互相覆盖的问题。 + +任务: + +- 在 step 开始执行时同步更新 task 运行态。 +- 明确 task 完成后 task 状态如何从 `running` 返回业务态。 +- 统一 `bind/rebind/webhook/ingest` 对 `full_video_bvid` 的读写入口。 +- 明确 `task_contexts`、`session_bindings`、`full_video_bvid.txt` 的职责。 + +验收标准: + +- 控制台能正确筛选和显示运行中的任务。 +- 手工绑定、session 重绑、webhook 注入后,新旧任务读取到相同 BV。 +- 不再出现新任务 ingest 继承旧 BV 的情况。 + +### Phase 2: 运行稳定性加固 + +目标: + +- 让 API 与 worker 并行运行时的 SQLite 行为可控。 +- 降低锁冲突、脏状态和半成功写入风险。 + +任务: + +- 为 SQLite 连接增加 `busy_timeout`、`WAL`、`foreign_keys=ON`。 +- 检查高频 repo 写入点,减少不必要的小事务。 +- 梳理关键写路径是否需要合并成原子操作。 + +验收标准: + +- API 和 worker 并行运行时,不再轻易触发数据库锁错误。 +- 关键任务状态写入具备基本原子性,不出现“步骤更新了、任务没更新”一类半状态。 + +### Phase 3: 控制面装配与查询优化 + +目标: + +- 去掉 API 请求路径上的重复初始化。 +- 解决 `/tasks` 列表的全量扫描和 N+1 查询问题。 + +任务: + +- 将 `ensure_initialized()` 从“每次请求即装配”改为更稳定的应用级初始化方式。 +- 收敛 provider/registry 生命周期,避免每次请求重复扫描 manifest 和实例化 provider。 +- 优化任务列表接口,把可下推的过滤逻辑下推到 repository 或持久化层。 +- 减少列表查询时对工作区文件的逐条读取。 + +验收标准: + +- 常规 API 请求不再重复做全量装配。 +- 大量任务下的列表页和筛选页响应明显改善。 + +### Phase 4: 状态机与文档对齐 + +目标: + +- 让文档状态机、代码状态机、控制面展示口径一致。 + +任务: + +- 决定是否保留 `ingested`、`completed`、`cancelled`。 +- 明确 flag 文件在系统中的角色。 +- 如果数据库是任务状态唯一来源,则把 delivery flag 降级为产物或外部副作用标记。 +- 更新状态机文档、控制面展示文案和开发约束。 + +验收标准: + +- 文档中的状态集合与代码中的状态集合一致。 +- UI 不再依赖不存在或含义不稳定的 task 状态。 + +### Phase 5: 回归测试与维护收尾 + +目标: + +- 为核心编排逻辑补回归保护。 +- 降低后续重构再次引入状态漂移的概率。 + +任务: + +- 新增 `tests/`。 +- 优先覆盖: + - `task_engine` + - `task_policies` + - `task_actions` + - `retry_meta` + - `task_reset` +- 决定 classic 控制台的保留策略。 + +验收标准: + +- 核心状态流转具备最小自动化回归覆盖。 +- 控制台维护策略明确,不再长期双线漂移。 + +## 推荐执行顺序 + +1. Phase 1 +2. Phase 2 +3. Phase 3 +4. Phase 4 +5. Phase 5 + +## 本轮起步范围 + +本轮先从以下子项开始: + +- Phase 1.1: task `running` 状态落地 +- Phase 1.2: `full_video_bvid` 写路径统一 +- Phase 2.1: SQLite 连接配置加固 + +## 过程记录 + +- 2026-04-06:完成代码审查,确认当前优先问题集中在 task 运行态缺失、`full_video_bvid` 多源不一致、SQLite 并发配置不足、重复初始化、列表查询 N+1、状态机文档与实现漂移、测试缺失。 +- 2026-04-06:将问题整理为本改造计划,按阶段拆分,并确定先做状态一致性与运行稳定性。 diff --git a/docs/state-machine.md b/docs/state-machine.md index 4865938..32c8b1e 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -1,271 +1,271 @@ -# State Machine - -## Goal - -定义 `biliup-next` 当前实现使用的任务状态机,并明确数据库状态与工作区 flag 的职责边界。 - -状态机目标: - -- 让每个任务始终有明确状态 -- 支持失败重试和人工介入 -- 让 UI 和 API 可以直接消费状态 -- 保证步骤顺序和依赖关系清晰 - -## State Model - -任务状态分为两层: - -- `task status`:任务整体状态 -- `step status`:任务中每一步的执行状态 - -## Task Status - -### Core Statuses - -- `created` -- `running` -- `transcribed` -- `songs_detected` -- `split_done` -- `published` -- `commented` -- `collection_synced` - -### Failure Statuses - -- `failed_retryable` -- `failed_manual` - -### Terminal Statuses - -- `collection_synced` -- `failed_manual` - -## Step Status - -每个步骤都独立维护自己的状态。 - -- `pending` -- `running` -- `succeeded` -- `failed_retryable` -- `failed_manual` -- `skipped` - -## Step Definitions - -### ingest - -负责: - -- 接收输入视频 -- 基础校验 -- 创建任务记录 - -### transcribe - -负责: - -- 生成字幕 -- 记录字幕产物 - -### song_detect - -负责: - -- 识别歌曲列表 -- 生成 `songs.json` 和 `songs.txt` - -### split - -负责: - -- 根据歌单切割视频 -- 生成切片产物 - -### publish - -负责: - -- 上传纯享版视频 -- 同 session 多个 task 时,只由 anchor task 真正执行上传 -- 聚合同 session 的全部 `clip_video` -- 成功后把同一个 `bvid` 写回整组 task - -### comment - -负责: - -- 发布评论 -- 置顶评论 -- split 评论在 session 级聚合为 `P1/P2/P3` -- full 评论在 session 级聚合为 `P1/P2/P3` -- 同一 session 的评论只由 anchor task 执行一次 - -### collection_a - -负责: - -- 将完整版视频加入合集 A - -### collection_b - -负责: - -- 将纯享版视频加入合集 B - -## State Transition Rules - -### Task-Level - -```text -created - -> running - -> transcribed - -> running - -> songs_detected - -> running - -> split_done - -> running - -> published - -> running - -> commented - -> running - -> collection_synced -``` - -说明: - -- `running` 是任务级瞬时状态,表示当前已有某个 step 被 claim 并正在执行。 -- 当该 step 成功结束后,task 会回到对应业务状态,例如 `transcribed`、`split_done`、`published`。 -- 当前实现中未使用 `ingested`、`completed`、`cancelled` 作为 task 状态。 - -### Failure Transition - -任何步骤失败后: - -- 若允许自动重试:任务进入 `failed_retryable` -- 若必须人工介入:任务进入 `failed_manual` - -重试成功后: - -- 任务回到该步骤成功后的下一个合法状态 - -## Dependency Rules - -- `transcribe` 必须依赖 `ingest` -- `song_detect` 必须依赖 `transcribe` -- `split` 必须依赖 `song_detect` -- `publish` 必须依赖 `split` -- `comment` 必须依赖 `publish` -- `collection_b` 必须依赖 `publish` -- `collection_a` 通常依赖外部完整版 BV,可独立于 `publish` - -## Session Semantics - -当多个 task 属于同一个 `session_key` 时,系统会引入 session 级语义: - -- `split` 仍然保持 task 级 -- `publish` 升级为 session 级 -- `comment` 升级为 session 级 - -当前 anchor 规则: - -- 同一 session 内按 `segment_started_at` 升序排序 -- 最早那个 task 作为 anchor - -当前 session 级行为: - -- `publish` - - 只有 anchor task 执行真实上传 - - 其余 task 复用同一个纯享 `BV` -- `comment.split` - - 只有 anchor task 对纯享版视频发评论 - - 评论内容按 `P1/P2/P3` 聚合 -- `comment.full` - - 只有 anchor task 对完整版视频发评论 - - 评论内容按 `P1/P2/P3` 聚合 - -## Special Case: Collection A - -合集 A 的数据来源与主上传链路不同。 - -因此: - -- `collection_a` 不应阻塞主任务完成 -- `collection_a` 可作为独立步骤存在 -- 任务整体完成不必强依赖 `collection_a` 成功 - -当前实现: - -- `collection_synced` 表示当前任务已经完成既定收尾流程。 -- `collection_a` / `collection_b` 仍作为独立 step 存在,但系统暂未额外引入 `completed` 状态。 - -## Retry Strategy - -### Retryable Errors - -适合自动重试: - -- 网络错误 -- 外部 API 临时失败 -- 上传频控 -- 外部命令短时异常 - -### Manual Errors - -需要人工介入: - -- 配置缺失 -- 凭证失效 -- 文件损坏 -- provider 不可用 -- 标题无法匹配完整版 BV - -## Persistence Requirements - -每次状态变更都必须落库: - -- 任务状态 -- 步骤状态 -- 开始时间 -- 结束时间 -- 错误码 -- 错误信息 -- 重试次数 - -## Flags And Files - -工作区中的 flag 文件仍然存在,但它们不是 task 主状态的权威来源。 - -当前职责划分: - -- 数据库: - - task 状态 - - step 状态 - - 重试信息 - - 结构化上下文 -- 工作区文件与 flag: - - 外部副作用是否已执行 - - 产物是否已落地 - - 评论/合集等交付标记 - -换句话说: - -- “任务现在处于什么状态”以数据库为准。 -- “某个外部动作是否已经做过”可以由工作区 flag 辅助表达。 - -## UI Expectations - -UI 至少需要直接展示: - -- 当前任务状态 -- 当前正在运行的步骤 -- 最近失败步骤 -- 重试次数 -- 是否需要人工介入 - -## Non-Goals - -- 不追求一个任务多个步骤完全并发执行 -- 不把工作区 flag 文件当作 task 主状态来源 +# State Machine + +## Goal + +定义 `biliup-next` 当前实现使用的任务状态机,并明确数据库状态与工作区 flag 的职责边界。 + +状态机目标: + +- 让每个任务始终有明确状态 +- 支持失败重试和人工介入 +- 让 UI 和 API 可以直接消费状态 +- 保证步骤顺序和依赖关系清晰 + +## State Model + +任务状态分为两层: + +- `task status`:任务整体状态 +- `step status`:任务中每一步的执行状态 + +## Task Status + +### Core Statuses + +- `created` +- `running` +- `transcribed` +- `songs_detected` +- `split_done` +- `published` +- `commented` +- `collection_synced` + +### Failure Statuses + +- `failed_retryable` +- `failed_manual` + +### Terminal Statuses + +- `collection_synced` +- `failed_manual` + +## Step Status + +每个步骤都独立维护自己的状态。 + +- `pending` +- `running` +- `succeeded` +- `failed_retryable` +- `failed_manual` +- `skipped` + +## Step Definitions + +### ingest + +负责: + +- 接收输入视频 +- 基础校验 +- 创建任务记录 + +### transcribe + +负责: + +- 生成字幕 +- 记录字幕产物 + +### song_detect + +负责: + +- 识别歌曲列表 +- 生成 `songs.json` 和 `songs.txt` + +### split + +负责: + +- 根据歌单切割视频 +- 生成切片产物 + +### publish + +负责: + +- 上传纯享版视频 +- 同 session 多个 task 时,只由 anchor task 真正执行上传 +- 聚合同 session 的全部 `clip_video` +- 成功后把同一个 `bvid` 写回整组 task + +### comment + +负责: + +- 发布评论 +- 置顶评论 +- split 评论在 session 级聚合为 `P1/P2/P3` +- full 评论在 session 级聚合为 `P1/P2/P3` +- 同一 session 的评论只由 anchor task 执行一次 + +### collection_a + +负责: + +- 将完整版视频加入合集 A + +### collection_b + +负责: + +- 将纯享版视频加入合集 B + +## State Transition Rules + +### Task-Level + +```text +created + -> running + -> transcribed + -> running + -> songs_detected + -> running + -> split_done + -> running + -> published + -> running + -> commented + -> running + -> collection_synced +``` + +说明: + +- `running` 是任务级瞬时状态,表示当前已有某个 step 被 claim 并正在执行。 +- 当该 step 成功结束后,task 会回到对应业务状态,例如 `transcribed`、`split_done`、`published`。 +- 当前实现中未使用 `ingested`、`completed`、`cancelled` 作为 task 状态。 + +### Failure Transition + +任何步骤失败后: + +- 若允许自动重试:任务进入 `failed_retryable` +- 若必须人工介入:任务进入 `failed_manual` + +重试成功后: + +- 任务回到该步骤成功后的下一个合法状态 + +## Dependency Rules + +- `transcribe` 必须依赖 `ingest` +- `song_detect` 必须依赖 `transcribe` +- `split` 必须依赖 `song_detect` +- `publish` 必须依赖 `split` +- `comment` 必须依赖 `publish` +- `collection_b` 必须依赖 `publish` +- `collection_a` 通常依赖外部完整版 BV,可独立于 `publish` + +## Session Semantics + +当多个 task 属于同一个 `session_key` 时,系统会引入 session 级语义: + +- `split` 仍然保持 task 级 +- `publish` 升级为 session 级 +- `comment` 升级为 session 级 + +当前 anchor 规则: + +- 同一 session 内按 `segment_started_at` 升序排序 +- 最早那个 task 作为 anchor + +当前 session 级行为: + +- `publish` + - 只有 anchor task 执行真实上传 + - 其余 task 复用同一个纯享 `BV` +- `comment.split` + - 只有 anchor task 对纯享版视频发评论 + - 评论内容按 `P1/P2/P3` 聚合 +- `comment.full` + - 只有 anchor task 对完整版视频发评论 + - 评论内容按 `P1/P2/P3` 聚合 + +## Special Case: Collection A + +合集 A 的数据来源与主上传链路不同。 + +因此: + +- `collection_a` 不应阻塞主任务完成 +- `collection_a` 可作为独立步骤存在 +- 任务整体完成不必强依赖 `collection_a` 成功 + +当前实现: + +- `collection_synced` 表示当前任务已经完成既定收尾流程。 +- `collection_a` / `collection_b` 仍作为独立 step 存在,但系统暂未额外引入 `completed` 状态。 + +## Retry Strategy + +### Retryable Errors + +适合自动重试: + +- 网络错误 +- 外部 API 临时失败 +- 上传频控 +- 外部命令短时异常 + +### Manual Errors + +需要人工介入: + +- 配置缺失 +- 凭证失效 +- 文件损坏 +- provider 不可用 +- 标题无法匹配完整版 BV + +## Persistence Requirements + +每次状态变更都必须落库: + +- 任务状态 +- 步骤状态 +- 开始时间 +- 结束时间 +- 错误码 +- 错误信息 +- 重试次数 + +## Flags And Files + +工作区中的 flag 文件仍然存在,但它们不是 task 主状态的权威来源。 + +当前职责划分: + +- 数据库: + - task 状态 + - step 状态 + - 重试信息 + - 结构化上下文 +- 工作区文件与 flag: + - 外部副作用是否已执行 + - 产物是否已落地 + - 评论/合集等交付标记 + +换句话说: + +- “任务现在处于什么状态”以数据库为准。 +- “某个外部动作是否已经做过”可以由工作区 flag 辅助表达。 + +## UI Expectations + +UI 至少需要直接展示: + +- 当前任务状态 +- 当前正在运行的步骤 +- 最近失败步骤 +- 重试次数 +- 是否需要人工介入 + +## Non-Goals + +- 不追求一个任务多个步骤完全并发执行 +- 不把工作区 flag 文件当作 task 主状态来源 diff --git a/docs/todo-2026-04-06.md b/docs/todo-2026-04-06.md index 597c1b7..0e562ff 100644 --- a/docs/todo-2026-04-06.md +++ b/docs/todo-2026-04-06.md @@ -1,196 +1,196 @@ -# biliup-next Todo - 2026-04-06 - -## 今日待办 - -### P0 - -- 修正任务级 `running` 状态缺失问题。 - - 当前 step 会进入 `running`,但 task 不会进入 `running`,导致控制台“处理中”筛选、优先级判断和注意力状态失真。 - - 相关位置: - - `src/biliup_next/app/task_engine.py` - - `src/biliup_next/app/api_server.py` - - `src/biliup_next/modules/*/service.py` - -- 收敛 `full_video_bvid` 的单一事实源。 - - 当前 `task_contexts`、`session_bindings`、`session/full_video_bvid.txt` 三处状态可能不一致。 - - `rebind_session_full_video_action()` 没有同步更新 `session_bindings`,后续新任务 ingest 仍可能继承旧 BV。 - - 相关位置: - - `src/biliup_next/app/task_actions.py` - - `src/biliup_next/modules/ingest/service.py` - - `src/biliup_next/infra/task_repository.py` - -- 补强 SQLite 并发配置。 - - 当前 API 与 worker 可并行运行,但数据库连接仍是最基础配置,缺少 `busy_timeout`、`WAL`、`foreign_keys=ON` 等保护。 - - 后续任务量或并发操作增加时,容易出现 `database is locked` 一类问题。 - - 相关位置: - - `src/biliup_next/infra/db.py` - -### P1 - -- 消除 API 路径上的重复初始化。 - - `ensure_initialized()` 目前会重复执行配置加载、DB 初始化、插件扫描和 provider 实例化。 - - API 每次请求都可能再次触发整套装配,后续会拖慢控制面并增加维护成本。 - - 相关位置: - - `src/biliup_next/app/bootstrap.py` - - `src/biliup_next/app/api_server.py` - -- 优化 `/tasks` 的全量扫描和 N+1 查询。 - - 当前 `attention/delivery` 过滤会先拉最多 5000 条任务,再逐条补 task payload、step、context 和文件系统状态。 - - 任务规模上来后会明显拖慢列表页和筛选体验。 - - 相关位置: - - `src/biliup_next/app/api_server.py` - - `src/biliup_next/infra/task_repository.py` - -- 收敛文档状态机与代码实现。 - - 文档中存在 `ingested`、`completed`、`cancelled`,并声明不再依赖 flag 文件作为权威状态。 - - 实际实现中这些状态并未完整落地,评论/合集完成态仍依赖多个 flag 文件。 - - 需要统一“文档模型”和“代码真实状态机”,避免后续继续漂移。 - - 相关位置: - - `docs/state-machine.md` - - `src/biliup_next/app/api_server.py` - - `src/biliup_next/modules/comment/providers/bilibili_top_comment.py` - - `src/biliup_next/modules/collection/providers/bilibili_collection.py` - -### P2 - -- 为状态机、重试和手工干预流程补测试。 - - 当前仓库没有看到 `tests/` 或自动化回归覆盖。 - - 优先覆盖: - - `task_engine` - - `task_policies` - - `task_actions` - - `retry_meta` - - `task_reset` - -- 明确两套控制台的维护策略。 - - 当前 React 控制台和 classic 控制台并存。 - - 需要决定 classic 是长期保留、冻结维护,还是逐步退役。 - -## 备注 - -- 以上问题来自 2026-04-06 对 `biliup-next` 当前重构实现的代码审查。 -- 优先顺序按“状态一致性 / 数据一致性 / 运行稳定性 / 控制面性能 / 可维护性”排列。 - -## 过程记录 - -- 2026-04-06:完成首轮代码审查,确认当前优先问题。 -- 2026-04-06:基于问题清单拆出分阶段改造计划,见 `docs/refactor-plan-2026-04-06.md`。 -- 2026-04-06:确定首批执行范围为 task `running` 状态落地、`full_video_bvid` 写路径统一、SQLite 连接加固。 -- 2026-04-06:已完成首轮代码改造。 - - task 在 step 被 claim 后会进入 `running`。 - - `bind/rebind/webhook` 已统一复用 `full_video_bvid` 持久化路径。 - - SQLite 连接已增加 `foreign_keys`、`busy_timeout`、`WAL`、`synchronous=NORMAL`。 - - 已执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。 -- 2026-04-06:已完成第二轮控制面改造。 - - `ensure_initialized()` 已改为进程内复用,避免 API 请求重复装配全套应用状态。 - - `PUT /settings` 后会主动失效并重建缓存状态,避免新旧配置混用。 - - `/tasks` 列表已改为批量预取 task context 和 steps,减少列表页 N+1 查询。 - - 已再次执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。 -- 2026-04-06:已完成状态机文档对齐。 - - `state-machine.md` 与 `architecture.md` 已改成当前代码真实状态集合:`created/running/transcribed/songs_detected/split_done/published/commented/collection_synced/failed_*`。 - - 已明确 `ingested/completed/cancelled` 当前未落地,不再作为现阶段实现口径。 - - 已明确工作区 flag 仅表示交付副作用和产物标记,不作为 task 主状态事实源。 -- 2026-04-06:已补最小回归测试集。 - - 新增 `tests/test_task_engine.py` - - 新增 `tests/test_retry_meta.py` - - 新增 `tests/test_task_actions.py` - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 7 个测试全部通过。 -- 2026-04-06:已继续收口 `task_actions` 的写路径。 - - `rebind_session_full_video_action()` 不再重复 upsert session binding。 - - `merge_session_action()` 在继承 `full_video_bvid` 时已复用统一持久化路径。 - - 已补对应测试,当前测试数为 8,全部通过。 -- 2026-04-06:已补第二层状态流转测试。 - - 新增 `tests/test_task_policies.py` - - 新增 `tests/test_task_runner.py` - - 已覆盖 disabled step fallback、publish 重试调度、reset 后回退状态、step claim 后 task 进入 `running` - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 12 个测试全部通过。 -- 2026-04-06:已完成一轮 API 代码清理。 - - `api_server.py` 新增批量 task payload 组装 helper。 - - `/tasks` 与 `/sessions/:session_key` 已复用同一套 task payload 预取与组装逻辑。 - - 已重新执行测试,当前 12 个测试全部通过。 -- 2026-04-06:已整理专业化路线图。 - - 新增 `docs/professionalization-roadmap-2026-04-06.md` - - 按平台边界、领域模型、接口契约、测试体系、运维成熟度五个维度拆解后续改进方向。 - - 已明确下一批优先项为 adapter 边界、session/delivery 领域服务收敛、serializer 层、SQLite/API 测试与 OpenAPI 对齐。 -- 2026-04-06:已开始落最小 adapter 边界。 - - 新增 `infra/adapters/codex_cli.py` - - 新增 `infra/adapters/biliup_cli.py` - - 新增 `infra/adapters/bilibili_api.py` - - `codex`、`biliup_cli`、`bilibili_top_comment`、`bilibili_collection` provider 已改为依赖 adapter - - 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。 -- 2026-04-06:已开始落 serializer 层。 - - 新增 `app/serializers.py` - - task list / task detail / session detail 的 payload 组装已从 `api_server.py` 抽到 `ControlPlaneSerializer` - - `api_server.py` 进一步收敛为路由、鉴权和响应控制 - - 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。 -- 2026-04-06:已继续收口 serializer 层。 - - task timeline 的组装逻辑已从 `api_server.py` 抽到 `ControlPlaneSerializer.timeline_payload()` - - `api_server.py` 中 task 详情相关展示逻辑继续变薄 - - 已重新执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。 -- 2026-04-06:已补 serializer 层测试。 - - 新增 `tests/test_serializers.py` - - 已覆盖 task payload、session payload、timeline payload 的控制面展示契约 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 15 个测试全部通过。 -- 2026-04-06:已补 repository 的 SQLite 集成测试。 - - 新增 `tests/test_task_repository_sqlite.py` - - 已覆盖 `query_tasks`、批量 context/steps 查询、`session_bindings` upsert 与 fallback 读取 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 18 个测试全部通过。 -- 2026-04-06:已补 API 行为测试。 - - 扩展 `tests/test_api_server.py` - - 已覆盖 `GET /tasks`、`GET /tasks/:id/timeline`、`GET /sessions/:session_key`、`PUT /settings` - - 已覆盖 control token 鉴权分支 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` -- 2026-04-06:已继续补执行面 API 行为测试。 - - `tests/test_api_server.py` 已新增 `POST /tasks`、`POST /tasks/:id/actions/run`、`POST /tasks/:id/actions/retry-step`、`POST /tasks/:id/actions/reset-to-step` - - 已覆盖写操作成功分支与 `missing step_name` 参数校验 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 28 个测试全部通过。 -- 2026-04-06:已补人工干预相关 API 行为测试。 - - `tests/test_api_server.py` 已新增 `POST /tasks/:id/bind-full-video`、`POST /sessions/:session_key/rebind`、`POST /sessions/:session_key/merge`、`POST /webhooks/full-video-uploaded` - - 已覆盖成功分支、参数校验,以及 `TASK_NOT_FOUND/SESSION_NOT_FOUND` 的状态码映射 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 37 个测试全部通过。 -- 2026-04-06:已补运行面 API 行为测试。 - - `tests/test_api_server.py` 已新增 `POST /worker/run-once`、`POST /scheduler/run-once`、`POST /runtime/services/:name/:action`、`POST /stage/import` - - 已覆盖 action record 落库、副作用返回值、`invalid action` 和 `missing source_path` 错误分支 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 43 个测试全部通过。 -- 2026-04-06:已补剩余控制面 GET 与上传接口测试。 - - `tests/test_api_server.py` 已新增 `GET /history`、`GET /modules`、`GET /scheduler/preview`、`GET /settings/schema`、`POST /stage/upload` - - `stage/upload` 成功分支已通过 patch `cgi.FieldStorage` 固定最小 handler 契约,避免 multipart 解析细节导致测试脆弱 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 49 个测试全部通过。 -- 2026-04-06:已开始收口 session / delivery 领域服务。 - - 新增 `app/session_delivery_service.py`,承接 `bind/rebind/merge/webhook` 的核心规则与持久化路径 - - `app/task_actions.py` 已改为薄封装,仅保留 `ensure_initialized()`、审计记录与 service 调用 - - 新增 `tests/test_session_delivery_service.py` - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 51 个测试全部通过。 -- 2026-04-06:已继续收口 task control 领域服务。 - - 新增 `app/task_control_service.py`,承接 `run/retry/reset` 编排 - - `app/task_actions.py` 已进一步变薄,`run_task_action/retry_step_action/reset_to_step_action` 改为纯 service 封装 + 审计 - - 新增 `tests/test_task_control_service.py` - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 54 个测试全部通过。 -- 2026-04-06:已将 POST 路径分发从 API handler 中下沉。 - - 新增 `app/control_plane_post_dispatcher.py`,统一承接 POST 路径的用例分发、状态码映射和运行面 action record - - `app/api_server.py` 的 `do_POST()` 已收敛为请求解析、dispatcher 调用和响应写出 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 54 个测试全部通过。 -- 2026-04-06:已补 dispatcher 直测。 - - 新增 `tests/test_control_plane_get_dispatcher.py` - - 新增 `tests/test_control_plane_post_dispatcher.py` - - 已覆盖 dispatcher 层的状态码映射、过滤逻辑、运行面 action record 与创建任务冲突映射 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 62 个测试全部通过。 -- 2026-04-06:已开始做可迁移交付清理。 - - `config/settings.json` 与 `config/settings.staged.json` 已替换为 standalone 默认模板,不再携带本机绝对路径和真实密钥 - - `runtime/cookies.json` 与 `runtime/upload_config.json` 已替换为可分发模板 - - 新增 `docs/cold-start-checklist.md` - - `README.md` 已补充冷启动入口说明 - - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` - - 当前 63 个测试全部通过。 +# biliup-next Todo - 2026-04-06 + +## 今日待办 + +### P0 + +- 修正任务级 `running` 状态缺失问题。 + - 当前 step 会进入 `running`,但 task 不会进入 `running`,导致控制台“处理中”筛选、优先级判断和注意力状态失真。 + - 相关位置: + - `src/biliup_next/app/task_engine.py` + - `src/biliup_next/app/api_server.py` + - `src/biliup_next/modules/*/service.py` + +- 收敛 `full_video_bvid` 的单一事实源。 + - 当前 `task_contexts`、`session_bindings`、`session/full_video_bvid.txt` 三处状态可能不一致。 + - `rebind_session_full_video_action()` 没有同步更新 `session_bindings`,后续新任务 ingest 仍可能继承旧 BV。 + - 相关位置: + - `src/biliup_next/app/task_actions.py` + - `src/biliup_next/modules/ingest/service.py` + - `src/biliup_next/infra/task_repository.py` + +- 补强 SQLite 并发配置。 + - 当前 API 与 worker 可并行运行,但数据库连接仍是最基础配置,缺少 `busy_timeout`、`WAL`、`foreign_keys=ON` 等保护。 + - 后续任务量或并发操作增加时,容易出现 `database is locked` 一类问题。 + - 相关位置: + - `src/biliup_next/infra/db.py` + +### P1 + +- 消除 API 路径上的重复初始化。 + - `ensure_initialized()` 目前会重复执行配置加载、DB 初始化、插件扫描和 provider 实例化。 + - API 每次请求都可能再次触发整套装配,后续会拖慢控制面并增加维护成本。 + - 相关位置: + - `src/biliup_next/app/bootstrap.py` + - `src/biliup_next/app/api_server.py` + +- 优化 `/tasks` 的全量扫描和 N+1 查询。 + - 当前 `attention/delivery` 过滤会先拉最多 5000 条任务,再逐条补 task payload、step、context 和文件系统状态。 + - 任务规模上来后会明显拖慢列表页和筛选体验。 + - 相关位置: + - `src/biliup_next/app/api_server.py` + - `src/biliup_next/infra/task_repository.py` + +- 收敛文档状态机与代码实现。 + - 文档中存在 `ingested`、`completed`、`cancelled`,并声明不再依赖 flag 文件作为权威状态。 + - 实际实现中这些状态并未完整落地,评论/合集完成态仍依赖多个 flag 文件。 + - 需要统一“文档模型”和“代码真实状态机”,避免后续继续漂移。 + - 相关位置: + - `docs/state-machine.md` + - `src/biliup_next/app/api_server.py` + - `src/biliup_next/modules/comment/providers/bilibili_top_comment.py` + - `src/biliup_next/modules/collection/providers/bilibili_collection.py` + +### P2 + +- 为状态机、重试和手工干预流程补测试。 + - 当前仓库没有看到 `tests/` 或自动化回归覆盖。 + - 优先覆盖: + - `task_engine` + - `task_policies` + - `task_actions` + - `retry_meta` + - `task_reset` + +- 明确两套控制台的维护策略。 + - 当前 React 控制台和 classic 控制台并存。 + - 需要决定 classic 是长期保留、冻结维护,还是逐步退役。 + +## 备注 + +- 以上问题来自 2026-04-06 对 `biliup-next` 当前重构实现的代码审查。 +- 优先顺序按“状态一致性 / 数据一致性 / 运行稳定性 / 控制面性能 / 可维护性”排列。 + +## 过程记录 + +- 2026-04-06:完成首轮代码审查,确认当前优先问题。 +- 2026-04-06:基于问题清单拆出分阶段改造计划,见 `docs/refactor-plan-2026-04-06.md`。 +- 2026-04-06:确定首批执行范围为 task `running` 状态落地、`full_video_bvid` 写路径统一、SQLite 连接加固。 +- 2026-04-06:已完成首轮代码改造。 + - task 在 step 被 claim 后会进入 `running`。 + - `bind/rebind/webhook` 已统一复用 `full_video_bvid` 持久化路径。 + - SQLite 连接已增加 `foreign_keys`、`busy_timeout`、`WAL`、`synchronous=NORMAL`。 + - 已执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。 +- 2026-04-06:已完成第二轮控制面改造。 + - `ensure_initialized()` 已改为进程内复用,避免 API 请求重复装配全套应用状态。 + - `PUT /settings` 后会主动失效并重建缓存状态,避免新旧配置混用。 + - `/tasks` 列表已改为批量预取 task context 和 steps,减少列表页 N+1 查询。 + - 已再次执行 `python -m compileall biliup-next/src/biliup_next` 验证语法通过。 +- 2026-04-06:已完成状态机文档对齐。 + - `state-machine.md` 与 `architecture.md` 已改成当前代码真实状态集合:`created/running/transcribed/songs_detected/split_done/published/commented/collection_synced/failed_*`。 + - 已明确 `ingested/completed/cancelled` 当前未落地,不再作为现阶段实现口径。 + - 已明确工作区 flag 仅表示交付副作用和产物标记,不作为 task 主状态事实源。 +- 2026-04-06:已补最小回归测试集。 + - 新增 `tests/test_task_engine.py` + - 新增 `tests/test_retry_meta.py` + - 新增 `tests/test_task_actions.py` + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 7 个测试全部通过。 +- 2026-04-06:已继续收口 `task_actions` 的写路径。 + - `rebind_session_full_video_action()` 不再重复 upsert session binding。 + - `merge_session_action()` 在继承 `full_video_bvid` 时已复用统一持久化路径。 + - 已补对应测试,当前测试数为 8,全部通过。 +- 2026-04-06:已补第二层状态流转测试。 + - 新增 `tests/test_task_policies.py` + - 新增 `tests/test_task_runner.py` + - 已覆盖 disabled step fallback、publish 重试调度、reset 后回退状态、step claim 后 task 进入 `running` + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 12 个测试全部通过。 +- 2026-04-06:已完成一轮 API 代码清理。 + - `api_server.py` 新增批量 task payload 组装 helper。 + - `/tasks` 与 `/sessions/:session_key` 已复用同一套 task payload 预取与组装逻辑。 + - 已重新执行测试,当前 12 个测试全部通过。 +- 2026-04-06:已整理专业化路线图。 + - 新增 `docs/professionalization-roadmap-2026-04-06.md` + - 按平台边界、领域模型、接口契约、测试体系、运维成熟度五个维度拆解后续改进方向。 + - 已明确下一批优先项为 adapter 边界、session/delivery 领域服务收敛、serializer 层、SQLite/API 测试与 OpenAPI 对齐。 +- 2026-04-06:已开始落最小 adapter 边界。 + - 新增 `infra/adapters/codex_cli.py` + - 新增 `infra/adapters/biliup_cli.py` + - 新增 `infra/adapters/bilibili_api.py` + - `codex`、`biliup_cli`、`bilibili_top_comment`、`bilibili_collection` provider 已改为依赖 adapter + - 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。 +- 2026-04-06:已开始落 serializer 层。 + - 新增 `app/serializers.py` + - task list / task detail / session detail 的 payload 组装已从 `api_server.py` 抽到 `ControlPlaneSerializer` + - `api_server.py` 进一步收敛为路由、鉴权和响应控制 + - 已执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。 +- 2026-04-06:已继续收口 serializer 层。 + - task timeline 的组装逻辑已从 `api_server.py` 抽到 `ControlPlaneSerializer.timeline_payload()` + - `api_server.py` 中 task 详情相关展示逻辑继续变薄 + - 已重新执行 unittest 与 `python -m compileall biliup-next/src/biliup_next`,当前验证通过。 +- 2026-04-06:已补 serializer 层测试。 + - 新增 `tests/test_serializers.py` + - 已覆盖 task payload、session payload、timeline payload 的控制面展示契约 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 15 个测试全部通过。 +- 2026-04-06:已补 repository 的 SQLite 集成测试。 + - 新增 `tests/test_task_repository_sqlite.py` + - 已覆盖 `query_tasks`、批量 context/steps 查询、`session_bindings` upsert 与 fallback 读取 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 18 个测试全部通过。 +- 2026-04-06:已补 API 行为测试。 + - 扩展 `tests/test_api_server.py` + - 已覆盖 `GET /tasks`、`GET /tasks/:id/timeline`、`GET /sessions/:session_key`、`PUT /settings` + - 已覆盖 control token 鉴权分支 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` +- 2026-04-06:已继续补执行面 API 行为测试。 + - `tests/test_api_server.py` 已新增 `POST /tasks`、`POST /tasks/:id/actions/run`、`POST /tasks/:id/actions/retry-step`、`POST /tasks/:id/actions/reset-to-step` + - 已覆盖写操作成功分支与 `missing step_name` 参数校验 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 28 个测试全部通过。 +- 2026-04-06:已补人工干预相关 API 行为测试。 + - `tests/test_api_server.py` 已新增 `POST /tasks/:id/bind-full-video`、`POST /sessions/:session_key/rebind`、`POST /sessions/:session_key/merge`、`POST /webhooks/full-video-uploaded` + - 已覆盖成功分支、参数校验,以及 `TASK_NOT_FOUND/SESSION_NOT_FOUND` 的状态码映射 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 37 个测试全部通过。 +- 2026-04-06:已补运行面 API 行为测试。 + - `tests/test_api_server.py` 已新增 `POST /worker/run-once`、`POST /scheduler/run-once`、`POST /runtime/services/:name/:action`、`POST /stage/import` + - 已覆盖 action record 落库、副作用返回值、`invalid action` 和 `missing source_path` 错误分支 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 43 个测试全部通过。 +- 2026-04-06:已补剩余控制面 GET 与上传接口测试。 + - `tests/test_api_server.py` 已新增 `GET /history`、`GET /modules`、`GET /scheduler/preview`、`GET /settings/schema`、`POST /stage/upload` + - `stage/upload` 成功分支已通过 patch `cgi.FieldStorage` 固定最小 handler 契约,避免 multipart 解析细节导致测试脆弱 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 49 个测试全部通过。 +- 2026-04-06:已开始收口 session / delivery 领域服务。 + - 新增 `app/session_delivery_service.py`,承接 `bind/rebind/merge/webhook` 的核心规则与持久化路径 + - `app/task_actions.py` 已改为薄封装,仅保留 `ensure_initialized()`、审计记录与 service 调用 + - 新增 `tests/test_session_delivery_service.py` + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 51 个测试全部通过。 +- 2026-04-06:已继续收口 task control 领域服务。 + - 新增 `app/task_control_service.py`,承接 `run/retry/reset` 编排 + - `app/task_actions.py` 已进一步变薄,`run_task_action/retry_step_action/reset_to_step_action` 改为纯 service 封装 + 审计 + - 新增 `tests/test_task_control_service.py` + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 54 个测试全部通过。 +- 2026-04-06:已将 POST 路径分发从 API handler 中下沉。 + - 新增 `app/control_plane_post_dispatcher.py`,统一承接 POST 路径的用例分发、状态码映射和运行面 action record + - `app/api_server.py` 的 `do_POST()` 已收敛为请求解析、dispatcher 调用和响应写出 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 54 个测试全部通过。 +- 2026-04-06:已补 dispatcher 直测。 + - 新增 `tests/test_control_plane_get_dispatcher.py` + - 新增 `tests/test_control_plane_post_dispatcher.py` + - 已覆盖 dispatcher 层的状态码映射、过滤逻辑、运行面 action record 与创建任务冲突映射 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 62 个测试全部通过。 +- 2026-04-06:已开始做可迁移交付清理。 + - `config/settings.json` 与 `config/settings.staged.json` 已替换为 standalone 默认模板,不再携带本机绝对路径和真实密钥 + - `runtime/cookies.json` 与 `runtime/upload_config.json` 已替换为可分发模板 + - 新增 `docs/cold-start-checklist.md` + - `README.md` 已补充冷启动入口说明 + - 已执行 `PYTHONPATH=biliup-next/src python -m unittest discover -s biliup-next/tests -v` + - 当前 63 个测试全部通过。 diff --git a/docs/vision.md b/docs/vision.md index eb11222..9b4f10c 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,54 +1,54 @@ -# Vision - -## Goal - -将当前基于目录监听和脚本拼接的流水线,重构为一个模块化、可扩展、可观测、可运维的单体系统。 - -系统负责: - -- 接收本地视频任务 -- 执行转录、识歌、切歌、上传、评论、合集归档 -- 记录任务状态、产物、错误和外部结果 -- 提供统一配置和管理入口 - -系统不负责: - -- 直播录制 -- 完整版视频的外部发布流程 -- 多账号复杂运营后台 -- 分布式调度 - -## Users - -- 运维者:部署、启动、排查、重试任务 -- 内容生产者:投放视频、观察任务状态 -- 开发者:新增模块、替换外部依赖、扩展功能 - -## Problems In Current Project - -- 状态分散在目录名、flag 文件、日志中,缺少单一事实来源 -- 业务逻辑和运维逻辑耦合严重 -- 配置项散落在多个脚本和常量中 -- 同类逻辑重复实现,例如 B 站列表解析、合集处理、任务扫描 -- 可观测性不足,失败后需要人工翻日志定位 -- 扩展新能力时只能继续加脚本,结构会越来越乱 - -## Target Characteristics - -- 模块化单体,而不是脚本集合 -- 显式任务状态机 -- 统一配置系统 -- 外部依赖适配器化 -- 结构化任务存储 -- 插件式扩展点 -- Web 管理台 -- 文档优先 - -## Milestones - -1. 定义架构、领域模型、模块接口和 API。 -2. 建立新系统骨架,不影响旧系统运行。 -3. 落地统一配置、任务状态存储和最小管理 API。 -4. 按模块迁移旧能力:转录、识歌、切歌、上传、评论、合集。 -5. 接入 Web 管理台。 -6. 逐步切换生产流量,最终替换旧脚本体系。 +# Vision + +## Goal + +将当前基于目录监听和脚本拼接的流水线,重构为一个模块化、可扩展、可观测、可运维的单体系统。 + +系统负责: + +- 接收本地视频任务 +- 执行转录、识歌、切歌、上传、评论、合集归档 +- 记录任务状态、产物、错误和外部结果 +- 提供统一配置和管理入口 + +系统不负责: + +- 直播录制 +- 完整版视频的外部发布流程 +- 多账号复杂运营后台 +- 分布式调度 + +## Users + +- 运维者:部署、启动、排查、重试任务 +- 内容生产者:投放视频、观察任务状态 +- 开发者:新增模块、替换外部依赖、扩展功能 + +## Problems In Current Project + +- 状态分散在目录名、flag 文件、日志中,缺少单一事实来源 +- 业务逻辑和运维逻辑耦合严重 +- 配置项散落在多个脚本和常量中 +- 同类逻辑重复实现,例如 B 站列表解析、合集处理、任务扫描 +- 可观测性不足,失败后需要人工翻日志定位 +- 扩展新能力时只能继续加脚本,结构会越来越乱 + +## Target Characteristics + +- 模块化单体,而不是脚本集合 +- 显式任务状态机 +- 统一配置系统 +- 外部依赖适配器化 +- 结构化任务存储 +- 插件式扩展点 +- Web 管理台 +- 文档优先 + +## Milestones + +1. 定义架构、领域模型、模块接口和 API。 +2. 建立新系统骨架,不影响旧系统运行。 +3. 落地统一配置、任务状态存储和最小管理 API。 +4. 按模块迁移旧能力:转录、识歌、切歌、上传、评论、合集。 +5. 接入 Web 管理台。 +6. 逐步切换生产流量,最终替换旧脚本体系。 diff --git a/frontend/README.md b/frontend/README.md index 42c4f6b..9127314 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,71 +1,71 @@ -# Frontend - -`frontend/` 是新的 React + Vite 控制台迁移骨架,目标是逐步替换当前 `src/biliup_next/app/static/` 下的原生前端。 - -当前已迁入: - -- 基础导航:`Overview / Tasks / Settings / Logs` -- `Overview` 第一版 -- `Tasks` 工作台第一版 -- `Logs` 工作台第一版 -- 任务表 -- 任务详情 -- 与现有 Python API 的直连 - -## 目录 - -- `src/App.jsx` -- `src/components/` -- `src/api/client.js` -- `src/lib/format.js` -- `src/styles.css` - -## 启动 - -当前机器未安装 `npm` 或 `corepack`,所以这套前端骨架还没有在本机完成依赖安装。 - -有包管理器后,在 `frontend/` 下执行: - -```bash -npm install -npm run dev -``` - -开发服务器默认地址: - -```text -http://127.0.0.1:5173/ui/ -``` - -默认会通过 `vite.config.mjs` 把这些路径代理到现有后端 `http://127.0.0.1:8787`: - -- `/health` -- `/doctor` -- `/tasks` -- `/settings` -- `/runtime` -- `/history` -- `/logs` -- `/modules` -- `/scheduler` -- `/worker` -- `/stage` - -生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它: - -```text -http://127.0.0.1:8787/ -``` - -旧控制台回退入口: - -```text -http://127.0.0.1:8787/classic -``` - -## 当前状态 - -- React 控制台已接管默认首页 -- 任务页已支持 `session context / bind full video / session merge / session rebind` -- 高频任务操作已改为局部刷新 -- 旧原生控制台仍保留作回退路径 +# Frontend + +`frontend/` 是新的 React + Vite 控制台迁移骨架,目标是逐步替换当前 `src/biliup_next/app/static/` 下的原生前端。 + +当前已迁入: + +- 基础导航:`Overview / Tasks / Settings / Logs` +- `Overview` 第一版 +- `Tasks` 工作台第一版 +- `Logs` 工作台第一版 +- 任务表 +- 任务详情 +- 与现有 Python API 的直连 + +## 目录 + +- `src/App.jsx` +- `src/components/` +- `src/api/client.js` +- `src/lib/format.js` +- `src/styles.css` + +## 启动 + +当前机器未安装 `npm` 或 `corepack`,所以这套前端骨架还没有在本机完成依赖安装。 + +有包管理器后,在 `frontend/` 下执行: + +```bash +npm install +npm run dev +``` + +开发服务器默认地址: + +```text +http://127.0.0.1:5173/ui/ +``` + +默认会通过 `vite.config.mjs` 把这些路径代理到现有后端 `http://127.0.0.1:8787`: + +- `/health` +- `/doctor` +- `/tasks` +- `/settings` +- `/runtime` +- `/history` +- `/logs` +- `/modules` +- `/scheduler` +- `/worker` +- `/stage` + +生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它: + +```text +http://127.0.0.1:8787/ +``` + +旧控制台回退入口: + +```text +http://127.0.0.1:8787/classic +``` + +## 当前状态 + +- React 控制台已接管默认首页 +- 任务页已支持 `session context / bind full video / session merge / session rebind` +- 高频任务操作已改为局部刷新 +- 旧原生控制台仍保留作回退路径 diff --git a/frontend/index.html b/frontend/index.html index 2814bf3..3117fdd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,12 +1,12 @@ - - - - - - biliup-next Frontend - - -
- - - + + + + + + biliup-next Frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d3c008..df7e2a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,1815 +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" - } - } -} +{ + "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 index 7ca172a..890d74d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,19 +1,19 @@ -{ - "name": "biliup-next-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@vitejs/plugin-react": "^5.0.0", - "vite": "^7.0.0" - } -} +{ + "name": "biliup-next-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.0.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 759cc89..116d87e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,886 +1,886 @@ -import { useEffect, useState, useDeferredValue, startTransition } from "react"; -import { useRef } from "react"; - -import { fetchJson, fetchJsonCached, invalidateJsonCache, primeJsonCache, 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 { - attentionLabel, - currentStepLabel, - summarizeAttention, - summarizeDelivery, - taskDisplayStatus, - taskPrimaryActionLabel, -} from "./lib/format.js"; - -const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"]; - -function buildTasksUrl(query) { - const params = new URLSearchParams(); - params.set("limit", String(query.limit || 24)); - params.set("offset", String(query.offset || 0)); - params.set("sort", String(query.sort || "updated_desc")); - if (query.status) params.set("status", query.status); - if (query.search) params.set("search", query.search); - if (query.attention) params.set("attention", query.attention); - if (query.delivery) params.set("delivery", query.delivery); - return `/tasks?${params.toString()}`; -} - -function parseHashState() { - const raw = window.location.hash.replace(/^#/, ""); - const [viewPart, queryPart = ""] = raw.split("?"); - const params = new URLSearchParams(queryPart); - return { - view: NAV_ITEMS.includes(viewPart) ? viewPart : "Tasks", - taskId: params.get("task") || "", - }; -} - -function syncHashState(view, taskId) { - const params = new URLSearchParams(); - if (taskId && view === "Tasks") params.set("task", taskId); - const suffix = params.toString() ? `?${params.toString()}` : ""; - window.history.replaceState(null, "", `#${view}${suffix}`); -} - -function FocusQueue({ tasks, selectedTaskId, onSelectTask, onRunTask }) { - const focusItems = tasks - .filter((task) => ["manual_now", "retry_now", "waiting_retry"].includes(summarizeAttention(task))) - .sort((a, b) => { - const score = { manual_now: 0, retry_now: 1, waiting_retry: 2 }; - const diff = score[summarizeAttention(a)] - score[summarizeAttention(b)]; - if (diff !== 0) return diff; - return String(b.updated_at).localeCompare(String(a.updated_at)); - }) - .slice(0, 6); - - if (!focusItems.length) return null; - - return ( -
-
-
-

Priority Queue

-

需要优先处理的任务

-
-
{focusItems.length} tasks
-
-
- {focusItems.map((task) => ( - - -
- - ))} - -
- ); -} - -function TasksView({ - tasks, - taskTotal, - taskQuery, - selectedTaskId, - onSelectTask, - onRunTask, - taskDetail, - session, - loading, - detailLoading, - actionBusy, - selectedStepName, - onSelectStep, - onRetryStep, - onResetStep, - onBindFullVideo, - onOpenSessionTask, - onSessionMerge, - onSessionRebind, - onTaskQueryChange, -}) { - const deferredSearch = useDeferredValue(taskQuery.search); - - const filtered = tasks.filter((task) => { - const haystack = `${task.id} ${task.title}`.toLowerCase(); - if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false; - return true; - }); - - const pageStart = taskTotal ? taskQuery.offset + 1 : 0; - const pageEnd = taskQuery.offset + tasks.length; - const canPrev = taskQuery.offset > 0; - const canNext = taskQuery.offset + taskQuery.limit < taskTotal; - - return ( -
-
- -
-
-
-

Tasks Workspace

-

Task Table

-
-
{loading ? "syncing..." : `${pageStart}-${pageEnd} / ${taskTotal}`}
-
-
- onTaskQueryChange({ search: event.target.value, offset: 0 })} - placeholder="搜索任务标题或 task id" - /> - - - - - -
-
- - -
- -
-
- -
- ); -} - -export default function App() { - const initialLocation = parseHashState(); - const [view, setView] = useState(initialLocation.view); - const [health, setHealth] = useState(false); - const [doctorOk, setDoctorOk] = useState(false); - const [tasks, setTasks] = useState([]); - const [taskTotal, setTaskTotal] = useState(0); - const [taskQuery, setTaskQuery] = useState({ - search: "", - status: "", - attention: "", - delivery: "", - sort: "updated_desc", - limit: 24, - offset: 0, - }); - 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(initialLocation.taskId); - const [selectedStepName, setSelectedStepName] = useState(""); - const [taskDetail, setTaskDetail] = useState(null); - const [currentSession, setCurrentSession] = 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 [actionBusy, setActionBusy] = useState(""); - const [panelBusy, setPanelBusy] = useState(""); - const [toasts, setToasts] = useState([]); - const detailCacheRef = useRef(new Map()); - - function pushToast(kind, text) { - const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - setToasts((current) => [...current, { id, kind, text }]); - } - - function removeToast(id) { - setToasts((current) => current.filter((item) => item.id !== id)); - } - - async function loadOverviewPanels() { - const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([ - fetchJsonCached("/runtime/services"), - fetchJsonCached("/scheduler/preview"), - fetchJsonCached("/history?limit=20"), - ]); - setServices(servicesPayload); - setScheduler(schedulerPayload); - setHistory(historyPayload); - } - - async function loadShell() { - setLoading(true); - try { - const [healthPayload, doctorPayload, taskPayload] = await Promise.all([ - fetchJsonCached("/health"), - fetchJsonCached("/doctor"), - fetchJson(buildTasksUrl(taskQuery)), - ]); - setHealth(Boolean(healthPayload.ok)); - setDoctorOk(Boolean(doctorPayload.ok)); - setTasks(taskPayload.items || []); - setTaskTotal(taskPayload.total || 0); - startTransition(() => { - if (!selectedTaskId && taskPayload.items?.length) { - setSelectedTaskId(taskPayload.items[0].id); - } - }); - } finally { - setLoading(false); - } - } - - async function loadTasksOnly(query = taskQuery) { - const url = buildTasksUrl(query); - const taskPayload = await fetchJson(url); - primeJsonCache(url, taskPayload); - setTasks(taskPayload.items || []); - setTaskTotal(taskPayload.total || 0); - return taskPayload.items || []; - } - - async function loadSessionDetail(sessionKey) { - if (!sessionKey) { - setCurrentSession(null); - return null; - } - const payload = await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`); - primeJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`, payload); - setCurrentSession(payload); - return payload; - } - - async function loadTaskDetail(taskId) { - const cached = detailCacheRef.current.get(taskId); - if (cached) { - setTaskDetail(cached); - void loadSessionDetail(cached.context?.session_key); - setDetailLoading(false); - } - setDetailLoading(true); - try { - const [task, steps, artifacts, history, timeline, context] = await Promise.all([ - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`), - ]); - const payload = { task, steps, artifacts, history, timeline, context }; - detailCacheRef.current.set(taskId, payload); - primeJsonCache(`/tasks/${encodeURIComponent(taskId)}`, task); - primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/steps`, steps); - primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/artifacts`, artifacts); - primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/history`, history); - primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/timeline`, timeline); - primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/context`, context); - setTaskDetail(payload); - await loadSessionDetail(context?.session_key); - 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); - } - } - - async function refreshSelectedTask(taskId = selectedTaskId, { refreshTasks = true } = {}) { - if (refreshTasks) { - const refreshedTasks = await loadTasksOnly(); - if (!taskId && refreshedTasks.length) { - taskId = refreshedTasks[0].id; - } - } - if (!taskId) { - setTaskDetail(null); - setCurrentSession(null); - return; - } - await loadTaskDetail(taskId); - } - - function invalidateTaskCaches(taskId) { - invalidateJsonCache("/tasks?"); - if (taskId) { - detailCacheRef.current.delete(taskId); - invalidateJsonCache(`/tasks/${encodeURIComponent(taskId)}`); - } - } - - function invalidateSessionCaches(sessionKey) { - if (!sessionKey) return; - invalidateJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`); - } - - async function prefetchTaskDetail(taskId) { - if (!taskId || detailCacheRef.current.has(taskId)) return; - try { - const [task, steps, artifacts, history, timeline, context] = await Promise.all([ - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`), - fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`), - ]); - detailCacheRef.current.set(taskId, { task, steps, artifacts, history, timeline, context }); - } catch { - // Ignore prefetch failures; normal navigation will surface the actual error. - } - } - - useEffect(() => { - let cancelled = false; - loadShell().catch((error) => { - if (!cancelled) pushToast("hot", `初始化失败: ${error}`); - }); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - syncHashState(view, selectedTaskId); - }, [view, selectedTaskId]); - - useEffect(() => { - if (view !== "Tasks") return; - loadTasksOnly(taskQuery).catch((error) => pushToast("hot", `任务列表加载失败: ${error}`)); - }, [taskQuery, view]); - - useEffect(() => { - if (!toasts.length) return undefined; - const timer = window.setTimeout(() => setToasts((current) => current.slice(1)), 3200); - return () => window.clearTimeout(timer); - }, [toasts]); - - useEffect(() => { - function handleHashChange() { - const next = parseHashState(); - setView(next.view); - if (next.taskId) { - setSelectedTaskId(next.taskId); - } - } - window.addEventListener("hashchange", handleHashChange); - return () => window.removeEventListener("hashchange", handleHashChange); - }, []); - - useEffect(() => { - if (view !== "Overview") return; - let cancelled = false; - async function loadOverviewView() { - setOverviewLoading(true); - try { - const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([ - fetchJsonCached("/runtime/services"), - fetchJsonCached("/scheduler/preview"), - fetchJsonCached("/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) pushToast("hot", `任务详情加载失败: ${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) pushToast("hot", `日志加载失败: ${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([ - fetchJsonCached("/settings"), - fetchJsonCached("/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 ( - { - setPanelBusy("refresh_scheduler"); - try { - const payload = await fetchJson("/scheduler/preview"); - setScheduler(payload); - pushToast("good", "Scheduler 已刷新"); - } finally { - setPanelBusy(""); - } - }} - onRefreshHistory={async () => { - setPanelBusy("refresh_history"); - try { - const payload = await fetchJson("/history?limit=20"); - setHistory(payload); - pushToast("good", "Recent Actions 已刷新"); - } finally { - setPanelBusy(""); - } - }} - onStageImport={async (sourcePath) => { - setPanelBusy("stage_import"); - try { - const result = await fetchJson("/stage/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_path: sourcePath }), - }); - await loadTasksOnly(); - pushToast("good", `已导入到 stage: ${result.target_path}`); - } finally { - setPanelBusy(""); - } - }} - onStageUpload={async (file) => { - setPanelBusy("stage_upload"); - try { - const result = await uploadFile("/stage/upload", file); - await loadTasksOnly(); - pushToast("good", `已上传到 stage: ${result.target_path}`); - } finally { - setPanelBusy(""); - } - }} - onRunOnce={async () => { - setPanelBusy("run_once"); - try { - await fetchJson("/worker/run-once", { method: "POST" }); - invalidateJsonCache("/tasks?"); - await loadTasksOnly(); - if (selectedTaskId) await refreshSelectedTask(selectedTaskId, { refreshTasks: false }); - pushToast("good", "Worker 已执行一轮"); - } finally { - setPanelBusy(""); - } - }} - onServiceAction={async (serviceId, action) => { - const busyKey = `service:${serviceId}:${action}`; - setPanelBusy(busyKey); - try { - await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" }); - invalidateJsonCache("/runtime/services"); - await loadShell(); - if (view === "Overview") { - await loadOverviewPanels(); - } - pushToast("good", `${serviceId} ${action} 完成`); - } finally { - setPanelBusy(""); - } - }} - busy={panelBusy} - /> - ); - } - if (view === "Tasks") { - return ( - { - if (options.prefetch) { - prefetchTaskDetail(taskId); - return; - } - startTransition(() => { - setSelectedTaskId(taskId); - setSelectedStepName(""); - }); - }} - onRunTask={async (taskId) => { - setActionBusy("run"); - try { - const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" }); - invalidateTaskCaches(taskId); - invalidateSessionCaches(taskDetail?.context?.session_key); - await refreshSelectedTask(taskId); - pushToast("good", `任务已推进: ${taskId} / processed=${result.processed.length}`); - } finally { - setActionBusy(""); - } - }} - taskDetail={taskDetail} - session={currentSession} - loading={loading} - detailLoading={detailLoading} - actionBusy={actionBusy} - selectedStepName={selectedStepName} - onSelectStep={setSelectedStepName} - onRetryStep={async (stepName) => { - if (!selectedTaskId || !stepName) return; - setActionBusy("retry"); - try { - const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step_name: stepName }), - }); - invalidateTaskCaches(selectedTaskId); - invalidateSessionCaches(taskDetail?.context?.session_key); - await refreshSelectedTask(selectedTaskId); - pushToast("good", `已重试 ${stepName} / processed=${result.processed.length}`); - } finally { - setActionBusy(""); - } - }} - onResetStep={async (stepName) => { - if (!selectedTaskId || !stepName) return; - if (!window.confirm(`确认重置到 step=${stepName} 并清理其后的产物吗?`)) return; - setActionBusy("reset"); - try { - const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step_name: stepName }), - }); - invalidateTaskCaches(selectedTaskId); - invalidateSessionCaches(taskDetail?.context?.session_key); - await refreshSelectedTask(selectedTaskId); - pushToast("good", `已重置到 ${stepName} / processed=${result.run.processed.length}`); - } finally { - setActionBusy(""); - } - }} - onBindFullVideo={async (fullVideoBvid) => { - if (!selectedTaskId || !fullVideoBvid) return; - setActionBusy("bind_full_video"); - try { - await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/bind-full-video`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ full_video_bvid: fullVideoBvid }), - }); - invalidateTaskCaches(selectedTaskId); - invalidateSessionCaches(taskDetail?.context?.session_key); - await refreshSelectedTask(selectedTaskId); - pushToast("good", `已绑定完整版 BV: ${fullVideoBvid}`); - } finally { - setActionBusy(""); - } - }} - onOpenSessionTask={(taskId) => { - startTransition(() => { - setSelectedTaskId(taskId); - setSelectedStepName(""); - }); - }} - onSessionMerge={async (rawTaskIds) => { - const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key; - const taskIds = String(rawTaskIds) - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - if (!sessionKey || !taskIds.length) return; - setActionBusy("session_merge"); - try { - await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/merge`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ task_ids: taskIds }), - }); - invalidateJsonCache("/tasks?"); - invalidateSessionCaches(sessionKey); - taskIds.forEach((taskId) => invalidateTaskCaches(taskId)); - await refreshSelectedTask(selectedTaskId); - pushToast("good", `已合并 ${taskIds.length} 个任务到 session ${sessionKey}`); - } finally { - setActionBusy(""); - } - }} - onSessionRebind={async (fullVideoBvid) => { - const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key; - if (!sessionKey || !fullVideoBvid) return; - setActionBusy("session_rebind"); - try { - await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/rebind`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ full_video_bvid: fullVideoBvid }), - }); - invalidateSessionCaches(sessionKey); - if (selectedTaskId) invalidateTaskCaches(selectedTaskId); - await refreshSelectedTask(selectedTaskId); - pushToast("good", `已为 session ${sessionKey} 绑定完整版 BV`); - } finally { - setActionBusy(""); - } - }} - onTaskQueryChange={(patch) => { - setTaskQuery((current) => ({ ...current, ...patch })); - }} - /> - ); - } - if (view === "Settings") { - return ( - { - await fetchJson("/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - invalidateJsonCache("/settings"); - invalidateJsonCache("/settings/schema"); - const refreshed = await fetchJson("/settings"); - setSettings(refreshed); - pushToast("good", "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; - setPanelBusy("refresh_log"); - try { - await loadCurrentLogContent(selectedLogName); - pushToast("good", "日志已刷新"); - } finally { - setPanelBusy(""); - } - }} - busy={panelBusy === "refresh_log"} - /> - ); - })(); - - return ( -
- -
-
-
-

Migration Workspace

-

{view}

-
-
- API {health ? "ok" : "down"} - Doctor {doctorOk ? "ready" : "warn"} - {taskTotal} tasks -
-
- {toasts.length ? ( -
- {toasts.map((toast) => ( -
- {toast.text} - -
- ))} -
- ) : null} - {currentView} -
-
- ); -} +import { useEffect, useState, useDeferredValue, startTransition } from "react"; +import { useRef } from "react"; + +import { fetchJson, fetchJsonCached, invalidateJsonCache, primeJsonCache, 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 { + attentionLabel, + currentStepLabel, + summarizeAttention, + summarizeDelivery, + taskDisplayStatus, + taskPrimaryActionLabel, +} from "./lib/format.js"; + +const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"]; + +function buildTasksUrl(query) { + const params = new URLSearchParams(); + params.set("limit", String(query.limit || 24)); + params.set("offset", String(query.offset || 0)); + params.set("sort", String(query.sort || "updated_desc")); + if (query.status) params.set("status", query.status); + if (query.search) params.set("search", query.search); + if (query.attention) params.set("attention", query.attention); + if (query.delivery) params.set("delivery", query.delivery); + return `/tasks?${params.toString()}`; +} + +function parseHashState() { + const raw = window.location.hash.replace(/^#/, ""); + const [viewPart, queryPart = ""] = raw.split("?"); + const params = new URLSearchParams(queryPart); + return { + view: NAV_ITEMS.includes(viewPart) ? viewPart : "Tasks", + taskId: params.get("task") || "", + }; +} + +function syncHashState(view, taskId) { + const params = new URLSearchParams(); + if (taskId && view === "Tasks") params.set("task", taskId); + const suffix = params.toString() ? `?${params.toString()}` : ""; + window.history.replaceState(null, "", `#${view}${suffix}`); +} + +function FocusQueue({ tasks, selectedTaskId, onSelectTask, onRunTask }) { + const focusItems = tasks + .filter((task) => ["manual_now", "retry_now", "waiting_retry"].includes(summarizeAttention(task))) + .sort((a, b) => { + const score = { manual_now: 0, retry_now: 1, waiting_retry: 2 }; + const diff = score[summarizeAttention(a)] - score[summarizeAttention(b)]; + if (diff !== 0) return diff; + return String(b.updated_at).localeCompare(String(a.updated_at)); + }) + .slice(0, 6); + + if (!focusItems.length) return null; + + return ( +
+
+
+

Priority Queue

+

需要优先处理的任务

+
+
{focusItems.length} tasks
+
+
+ {focusItems.map((task) => ( + + +
+ + ))} + +
+ ); +} + +function TasksView({ + tasks, + taskTotal, + taskQuery, + selectedTaskId, + onSelectTask, + onRunTask, + taskDetail, + session, + loading, + detailLoading, + actionBusy, + selectedStepName, + onSelectStep, + onRetryStep, + onResetStep, + onBindFullVideo, + onOpenSessionTask, + onSessionMerge, + onSessionRebind, + onTaskQueryChange, +}) { + const deferredSearch = useDeferredValue(taskQuery.search); + + const filtered = tasks.filter((task) => { + const haystack = `${task.id} ${task.title}`.toLowerCase(); + if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false; + return true; + }); + + const pageStart = taskTotal ? taskQuery.offset + 1 : 0; + const pageEnd = taskQuery.offset + tasks.length; + const canPrev = taskQuery.offset > 0; + const canNext = taskQuery.offset + taskQuery.limit < taskTotal; + + return ( +
+
+ +
+
+
+

Tasks Workspace

+

Task Table

+
+
{loading ? "syncing..." : `${pageStart}-${pageEnd} / ${taskTotal}`}
+
+
+ onTaskQueryChange({ search: event.target.value, offset: 0 })} + placeholder="搜索任务标题或 task id" + /> + + + + + +
+
+ + +
+ +
+
+ +
+ ); +} + +export default function App() { + const initialLocation = parseHashState(); + const [view, setView] = useState(initialLocation.view); + const [health, setHealth] = useState(false); + const [doctorOk, setDoctorOk] = useState(false); + const [tasks, setTasks] = useState([]); + const [taskTotal, setTaskTotal] = useState(0); + const [taskQuery, setTaskQuery] = useState({ + search: "", + status: "", + attention: "", + delivery: "", + sort: "updated_desc", + limit: 24, + offset: 0, + }); + 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(initialLocation.taskId); + const [selectedStepName, setSelectedStepName] = useState(""); + const [taskDetail, setTaskDetail] = useState(null); + const [currentSession, setCurrentSession] = 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 [actionBusy, setActionBusy] = useState(""); + const [panelBusy, setPanelBusy] = useState(""); + const [toasts, setToasts] = useState([]); + const detailCacheRef = useRef(new Map()); + + function pushToast(kind, text) { + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + setToasts((current) => [...current, { id, kind, text }]); + } + + function removeToast(id) { + setToasts((current) => current.filter((item) => item.id !== id)); + } + + async function loadOverviewPanels() { + const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([ + fetchJsonCached("/runtime/services"), + fetchJsonCached("/scheduler/preview"), + fetchJsonCached("/history?limit=20"), + ]); + setServices(servicesPayload); + setScheduler(schedulerPayload); + setHistory(historyPayload); + } + + async function loadShell() { + setLoading(true); + try { + const [healthPayload, doctorPayload, taskPayload] = await Promise.all([ + fetchJsonCached("/health"), + fetchJsonCached("/doctor"), + fetchJson(buildTasksUrl(taskQuery)), + ]); + setHealth(Boolean(healthPayload.ok)); + setDoctorOk(Boolean(doctorPayload.ok)); + setTasks(taskPayload.items || []); + setTaskTotal(taskPayload.total || 0); + startTransition(() => { + if (!selectedTaskId && taskPayload.items?.length) { + setSelectedTaskId(taskPayload.items[0].id); + } + }); + } finally { + setLoading(false); + } + } + + async function loadTasksOnly(query = taskQuery) { + const url = buildTasksUrl(query); + const taskPayload = await fetchJson(url); + primeJsonCache(url, taskPayload); + setTasks(taskPayload.items || []); + setTaskTotal(taskPayload.total || 0); + return taskPayload.items || []; + } + + async function loadSessionDetail(sessionKey) { + if (!sessionKey) { + setCurrentSession(null); + return null; + } + const payload = await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`); + primeJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`, payload); + setCurrentSession(payload); + return payload; + } + + async function loadTaskDetail(taskId) { + const cached = detailCacheRef.current.get(taskId); + if (cached) { + setTaskDetail(cached); + void loadSessionDetail(cached.context?.session_key); + setDetailLoading(false); + } + setDetailLoading(true); + try { + const [task, steps, artifacts, history, timeline, context] = await Promise.all([ + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`), + ]); + const payload = { task, steps, artifacts, history, timeline, context }; + detailCacheRef.current.set(taskId, payload); + primeJsonCache(`/tasks/${encodeURIComponent(taskId)}`, task); + primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/steps`, steps); + primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/artifacts`, artifacts); + primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/history`, history); + primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/timeline`, timeline); + primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/context`, context); + setTaskDetail(payload); + await loadSessionDetail(context?.session_key); + 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); + } + } + + async function refreshSelectedTask(taskId = selectedTaskId, { refreshTasks = true } = {}) { + if (refreshTasks) { + const refreshedTasks = await loadTasksOnly(); + if (!taskId && refreshedTasks.length) { + taskId = refreshedTasks[0].id; + } + } + if (!taskId) { + setTaskDetail(null); + setCurrentSession(null); + return; + } + await loadTaskDetail(taskId); + } + + function invalidateTaskCaches(taskId) { + invalidateJsonCache("/tasks?"); + if (taskId) { + detailCacheRef.current.delete(taskId); + invalidateJsonCache(`/tasks/${encodeURIComponent(taskId)}`); + } + } + + function invalidateSessionCaches(sessionKey) { + if (!sessionKey) return; + invalidateJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`); + } + + async function prefetchTaskDetail(taskId) { + if (!taskId || detailCacheRef.current.has(taskId)) return; + try { + const [task, steps, artifacts, history, timeline, context] = await Promise.all([ + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`), + fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`), + ]); + detailCacheRef.current.set(taskId, { task, steps, artifacts, history, timeline, context }); + } catch { + // Ignore prefetch failures; normal navigation will surface the actual error. + } + } + + useEffect(() => { + let cancelled = false; + loadShell().catch((error) => { + if (!cancelled) pushToast("hot", `初始化失败: ${error}`); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + syncHashState(view, selectedTaskId); + }, [view, selectedTaskId]); + + useEffect(() => { + if (view !== "Tasks") return; + loadTasksOnly(taskQuery).catch((error) => pushToast("hot", `任务列表加载失败: ${error}`)); + }, [taskQuery, view]); + + useEffect(() => { + if (!toasts.length) return undefined; + const timer = window.setTimeout(() => setToasts((current) => current.slice(1)), 3200); + return () => window.clearTimeout(timer); + }, [toasts]); + + useEffect(() => { + function handleHashChange() { + const next = parseHashState(); + setView(next.view); + if (next.taskId) { + setSelectedTaskId(next.taskId); + } + } + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, []); + + useEffect(() => { + if (view !== "Overview") return; + let cancelled = false; + async function loadOverviewView() { + setOverviewLoading(true); + try { + const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([ + fetchJsonCached("/runtime/services"), + fetchJsonCached("/scheduler/preview"), + fetchJsonCached("/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) pushToast("hot", `任务详情加载失败: ${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) pushToast("hot", `日志加载失败: ${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([ + fetchJsonCached("/settings"), + fetchJsonCached("/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 ( + { + setPanelBusy("refresh_scheduler"); + try { + const payload = await fetchJson("/scheduler/preview"); + setScheduler(payload); + pushToast("good", "Scheduler 已刷新"); + } finally { + setPanelBusy(""); + } + }} + onRefreshHistory={async () => { + setPanelBusy("refresh_history"); + try { + const payload = await fetchJson("/history?limit=20"); + setHistory(payload); + pushToast("good", "Recent Actions 已刷新"); + } finally { + setPanelBusy(""); + } + }} + onStageImport={async (sourcePath) => { + setPanelBusy("stage_import"); + try { + const result = await fetchJson("/stage/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_path: sourcePath }), + }); + await loadTasksOnly(); + pushToast("good", `已导入到 stage: ${result.target_path}`); + } finally { + setPanelBusy(""); + } + }} + onStageUpload={async (file) => { + setPanelBusy("stage_upload"); + try { + const result = await uploadFile("/stage/upload", file); + await loadTasksOnly(); + pushToast("good", `已上传到 stage: ${result.target_path}`); + } finally { + setPanelBusy(""); + } + }} + onRunOnce={async () => { + setPanelBusy("run_once"); + try { + await fetchJson("/worker/run-once", { method: "POST" }); + invalidateJsonCache("/tasks?"); + await loadTasksOnly(); + if (selectedTaskId) await refreshSelectedTask(selectedTaskId, { refreshTasks: false }); + pushToast("good", "Worker 已执行一轮"); + } finally { + setPanelBusy(""); + } + }} + onServiceAction={async (serviceId, action) => { + const busyKey = `service:${serviceId}:${action}`; + setPanelBusy(busyKey); + try { + await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" }); + invalidateJsonCache("/runtime/services"); + await loadShell(); + if (view === "Overview") { + await loadOverviewPanels(); + } + pushToast("good", `${serviceId} ${action} 完成`); + } finally { + setPanelBusy(""); + } + }} + busy={panelBusy} + /> + ); + } + if (view === "Tasks") { + return ( + { + if (options.prefetch) { + prefetchTaskDetail(taskId); + return; + } + startTransition(() => { + setSelectedTaskId(taskId); + setSelectedStepName(""); + }); + }} + onRunTask={async (taskId) => { + setActionBusy("run"); + try { + const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" }); + invalidateTaskCaches(taskId); + invalidateSessionCaches(taskDetail?.context?.session_key); + await refreshSelectedTask(taskId); + pushToast("good", `任务已推进: ${taskId} / processed=${result.processed.length}`); + } finally { + setActionBusy(""); + } + }} + taskDetail={taskDetail} + session={currentSession} + loading={loading} + detailLoading={detailLoading} + actionBusy={actionBusy} + selectedStepName={selectedStepName} + onSelectStep={setSelectedStepName} + onRetryStep={async (stepName) => { + if (!selectedTaskId || !stepName) return; + setActionBusy("retry"); + try { + const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step_name: stepName }), + }); + invalidateTaskCaches(selectedTaskId); + invalidateSessionCaches(taskDetail?.context?.session_key); + await refreshSelectedTask(selectedTaskId); + pushToast("good", `已重试 ${stepName} / processed=${result.processed.length}`); + } finally { + setActionBusy(""); + } + }} + onResetStep={async (stepName) => { + if (!selectedTaskId || !stepName) return; + if (!window.confirm(`确认重置到 step=${stepName} 并清理其后的产物吗?`)) return; + setActionBusy("reset"); + try { + const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step_name: stepName }), + }); + invalidateTaskCaches(selectedTaskId); + invalidateSessionCaches(taskDetail?.context?.session_key); + await refreshSelectedTask(selectedTaskId); + pushToast("good", `已重置到 ${stepName} / processed=${result.run.processed.length}`); + } finally { + setActionBusy(""); + } + }} + onBindFullVideo={async (fullVideoBvid) => { + if (!selectedTaskId || !fullVideoBvid) return; + setActionBusy("bind_full_video"); + try { + await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/bind-full-video`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_video_bvid: fullVideoBvid }), + }); + invalidateTaskCaches(selectedTaskId); + invalidateSessionCaches(taskDetail?.context?.session_key); + await refreshSelectedTask(selectedTaskId); + pushToast("good", `已绑定完整版 BV: ${fullVideoBvid}`); + } finally { + setActionBusy(""); + } + }} + onOpenSessionTask={(taskId) => { + startTransition(() => { + setSelectedTaskId(taskId); + setSelectedStepName(""); + }); + }} + onSessionMerge={async (rawTaskIds) => { + const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key; + const taskIds = String(rawTaskIds) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (!sessionKey || !taskIds.length) return; + setActionBusy("session_merge"); + try { + await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/merge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ task_ids: taskIds }), + }); + invalidateJsonCache("/tasks?"); + invalidateSessionCaches(sessionKey); + taskIds.forEach((taskId) => invalidateTaskCaches(taskId)); + await refreshSelectedTask(selectedTaskId); + pushToast("good", `已合并 ${taskIds.length} 个任务到 session ${sessionKey}`); + } finally { + setActionBusy(""); + } + }} + onSessionRebind={async (fullVideoBvid) => { + const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key; + if (!sessionKey || !fullVideoBvid) return; + setActionBusy("session_rebind"); + try { + await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/rebind`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_video_bvid: fullVideoBvid }), + }); + invalidateSessionCaches(sessionKey); + if (selectedTaskId) invalidateTaskCaches(selectedTaskId); + await refreshSelectedTask(selectedTaskId); + pushToast("good", `已为 session ${sessionKey} 绑定完整版 BV`); + } finally { + setActionBusy(""); + } + }} + onTaskQueryChange={(patch) => { + setTaskQuery((current) => ({ ...current, ...patch })); + }} + /> + ); + } + if (view === "Settings") { + return ( + { + await fetchJson("/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + invalidateJsonCache("/settings"); + invalidateJsonCache("/settings/schema"); + const refreshed = await fetchJson("/settings"); + setSettings(refreshed); + pushToast("good", "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; + setPanelBusy("refresh_log"); + try { + await loadCurrentLogContent(selectedLogName); + pushToast("good", "日志已刷新"); + } finally { + setPanelBusy(""); + } + }} + busy={panelBusy === "refresh_log"} + /> + ); + })(); + + return ( +
+ +
+
+
+

Migration Workspace

+

{view}

+
+
+ API {health ? "ok" : "down"} + Doctor {doctorOk ? "ready" : "warn"} + {taskTotal} tasks +
+
+ {toasts.length ? ( +
+ {toasts.map((toast) => ( +
+ {toast.text} + +
+ ))} +
+ ) : null} + {currentView} +
+
+ ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 0b0c28c..87ee3fb 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,67 +1,67 @@ -const jsonCache = new Map(); - -function cacheKey(url, options = {}) { - return JSON.stringify({ - url, - method: options.method || "GET", - headers: options.headers || {}, - }); -} - -export async function fetchJson(url, options = {}) { - const token = localStorage.getItem("biliup_next_token") || ""; - const headers = { ...(options.headers || {}) }; - if (token) headers["X-Biliup-Token"] = token; - const response = await fetch(url, { ...options, headers }); - const payload = await response.json(); - if (!response.ok) { - throw new Error(payload.message || payload.error || JSON.stringify(payload)); - } - return payload; -} - -export async function fetchJsonCached(url, options = {}, ttlMs = 8000) { - const method = options.method || "GET"; - if (method !== "GET") { - return fetchJson(url, options); - } - const key = cacheKey(url, options); - const cached = jsonCache.get(key); - if (cached && Date.now() - cached.time < ttlMs) { - return cached.payload; - } - const payload = await fetchJson(url, options); - jsonCache.set(key, { time: Date.now(), payload }); - return payload; -} - -export function primeJsonCache(url, payload, options = {}) { - const key = cacheKey(url, options); - jsonCache.set(key, { time: Date.now(), payload }); -} - -export function invalidateJsonCache(match) { - for (const key of jsonCache.keys()) { - if (typeof match === "string" ? key.includes(match) : match.test(key)) { - jsonCache.delete(key); - } - } -} - -export async function uploadFile(url, file) { - const token = localStorage.getItem("biliup_next_token") || ""; - const form = new FormData(); - form.append("file", file); - const headers = {}; - if (token) headers["X-Biliup-Token"] = token; - const response = await fetch(url, { - method: "POST", - headers, - body: form, - }); - const payload = await response.json(); - if (!response.ok) { - throw new Error(payload.message || payload.error || JSON.stringify(payload)); - } - return payload; -} +const jsonCache = new Map(); + +function cacheKey(url, options = {}) { + return JSON.stringify({ + url, + method: options.method || "GET", + headers: options.headers || {}, + }); +} + +export async function fetchJson(url, options = {}) { + const token = localStorage.getItem("biliup_next_token") || ""; + const headers = { ...(options.headers || {}) }; + if (token) headers["X-Biliup-Token"] = token; + const response = await fetch(url, { ...options, headers }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.message || payload.error || JSON.stringify(payload)); + } + return payload; +} + +export async function fetchJsonCached(url, options = {}, ttlMs = 8000) { + const method = options.method || "GET"; + if (method !== "GET") { + return fetchJson(url, options); + } + const key = cacheKey(url, options); + const cached = jsonCache.get(key); + if (cached && Date.now() - cached.time < ttlMs) { + return cached.payload; + } + const payload = await fetchJson(url, options); + jsonCache.set(key, { time: Date.now(), payload }); + return payload; +} + +export function primeJsonCache(url, payload, options = {}) { + const key = cacheKey(url, options); + jsonCache.set(key, { time: Date.now(), payload }); +} + +export function invalidateJsonCache(match) { + for (const key of jsonCache.keys()) { + if (typeof match === "string" ? key.includes(match) : match.test(key)) { + jsonCache.delete(key); + } + } +} + +export async function uploadFile(url, file) { + const token = localStorage.getItem("biliup_next_token") || ""; + const form = new FormData(); + form.append("file", file); + const headers = {}; + if (token) headers["X-Biliup-Token"] = token; + const response = await fetch(url, { + method: "POST", + headers, + body: form, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.message || payload.error || JSON.stringify(payload)); + } + return payload; +} diff --git a/frontend/src/components/LogsPanel.jsx b/frontend/src/components/LogsPanel.jsx index 33bb4c4..701350b 100644 --- a/frontend/src/components/LogsPanel.jsx +++ b/frontend/src/components/LogsPanel.jsx @@ -1,90 +1,90 @@ -import { useMemo, useState } from "react"; - -function buildFilteredLines(lines, query) { - if (!query) return lines; - const needle = query.toLowerCase(); - return lines.filter((line) => String(line).toLowerCase().includes(needle)); -} - -export default function LogsPanel({ - logs, - selectedLogName, - onSelectLog, - logContent, - loading, - onRefreshLog, - currentTaskTitle, - filterCurrentTask, - onToggleFilterCurrentTask, - autoRefresh, - onToggleAutoRefresh, - busy, -}) { - const [search, setSearch] = useState(""); - const [lineFilter, setLineFilter] = useState(""); - - const visibleLogs = useMemo(() => { - if (!search) return logs; - const needle = search.toLowerCase(); - return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle)); - }, [logs, search]); - - const filteredLines = useMemo( - () => buildFilteredLines(logContent?.lines || [], lineFilter), - [logContent?.lines, lineFilter], - ); - - return ( -
-
-
-
-

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..." : "暂无日志内容")}
-
-
- ); -} +import { useMemo, useState } from "react"; + +function buildFilteredLines(lines, query) { + if (!query) return lines; + const needle = query.toLowerCase(); + return lines.filter((line) => String(line).toLowerCase().includes(needle)); +} + +export default function LogsPanel({ + logs, + selectedLogName, + onSelectLog, + logContent, + loading, + onRefreshLog, + currentTaskTitle, + filterCurrentTask, + onToggleFilterCurrentTask, + autoRefresh, + onToggleAutoRefresh, + busy, +}) { + const [search, setSearch] = useState(""); + const [lineFilter, setLineFilter] = useState(""); + + const visibleLogs = useMemo(() => { + if (!search) return logs; + const needle = search.toLowerCase(); + return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle)); + }, [logs, search]); + + const filteredLines = useMemo( + () => buildFilteredLines(logContent?.lines || [], lineFilter), + [logContent?.lines, lineFilter], + ); + + return ( +
+
+
+
+

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 index 9445a2c..991b021 100644 --- a/frontend/src/components/OverviewPanel.jsx +++ b/frontend/src/components/OverviewPanel.jsx @@ -1,175 +1,175 @@ -import { useState } from "react"; - -import StatusBadge from "./StatusBadge.jsx"; -import { attentionLabel, summarizeAttention } from "../lib/format.js"; - -function SummaryCard({ label, value, tone = "" }) { - return ( -
- {label} - {value} - {tone ? {tone} : null} -
- ); -} - -export default function OverviewPanel({ - health, - doctorOk, - tasks, - services, - scheduler, - history, - loading, - onRefreshScheduler, - onRefreshHistory, - onRunOnce, - onServiceAction, - onStageImport, - onStageUpload, - busy, -}) { - const [stageSourcePath, setStageSourcePath] = useState(""); - const [stageFile, setStageFile] = useState(null); - const taskItems = tasks?.items || []; - const serviceItems = services?.items || []; - const actionItems = history?.items || []; - const scheduled = scheduler?.scheduled || []; - const deferred = scheduler?.deferred || []; - const attentionCounts = taskItems.reduce( - (acc, task) => { - const key = summarizeAttention(task); - acc[key] = (acc[key] || 0) + 1; - return acc; - }, - {}, - ); - - return ( -
-
- - - -
- -
-
-
-

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} -
-
-
-
- ); -} +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, + busy, +}) { + const [stageSourcePath, setStageSourcePath] = useState(""); + const [stageFile, setStageFile] = useState(null); + const taskItems = tasks?.items || []; + const serviceItems = services?.items || []; + const actionItems = history?.items || []; + const scheduled = scheduler?.scheduled || []; + const deferred = scheduler?.deferred || []; + const attentionCounts = taskItems.reduce( + (acc, task) => { + const key = summarizeAttention(task); + acc[key] = (acc[key] || 0) + 1; + return acc; + }, + {}, + ); + + return ( +
+
+ + + +
+ +
+
+
+

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 index f5e5520..08518ee 100644 --- a/frontend/src/components/SettingsPanel.jsx +++ b/frontend/src/components/SettingsPanel.jsx @@ -1,310 +1,310 @@ -import { useEffect, useMemo, useState } from "react"; - -const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__"; - -function clone(value) { - return JSON.parse(JSON.stringify(value)); -} - -function fieldKey(groupName, fieldName) { - return `${groupName}.${fieldName}`; -} - -function compareEntries(a, b) { - const orderA = Number(a[1].ui_order || 9999); - const orderB = Number(b[1].ui_order || 9999); - if (orderA !== orderB) return orderA - orderB; - return String(a[0]).localeCompare(String(b[0]), "zh-CN"); -} - -function stableStringify(value) { - return JSON.stringify(value ?? {}, null, 2); -} - -function FieldInput({ groupName, fieldName, schema, value, onChange }) { - if (schema.type === "boolean") { - return ( - 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,以及下一次重试时间。
-
-
-
-
-
-
- - - - - -""" +from __future__ import annotations + + +def render_dashboard_html() -> str: + return """ + + + + + biliup-next Control + + + +
+ + +
+
+
+

Operational Workspace

+

Overview

+
+
+
API · -
+
Doctor · -
+
Tasks · -
+
+
+ + + +
+
+
+

Runtime Snapshot

+
+
+ Health + - +
+
+ Doctor + - +
+
+ Tasks + - +
+
+
+
+ +
+

Import To Stage

+
+ + +
+
+ + +
+

只会复制或上传到 `biliup-next/data/workspace/stage/`,不会移动原文件。

+
+
+ +
+
+

Services

+
+
+ +
+
+

Recent Actions

+ +
+
+ + + +
+
+
+
+ +
+
+
+

Scheduler Queue

+ +
+
+
+
+ +
+

Stage Scan Result

+
+
+
+ +
+
+

Doctor Checks

+
+
+ +
+

Retry & Manual Attention

+
+
+
+ +
+
+

Modules

+
+
+ +
+

Overview Notes

+
+
+ 先看 Health / Doctor +
系统级异常通常先体现在依赖检查,而不是单任务状态。
+
+
+ 再看 Retry Summary +
优先处理已到重试时间和需要人工介入的任务。
+
+
+ 最后看 Recent Actions +
用动作流判断最近系统是否真的在前进。
+
+
+
+
+
+ +
+
+
+

Task Index

+
+
+
+
-
+
+ + + +
+
+
+
+ + + + + +
+
正在加载任务列表…
+
+
+ +
+
+
+

Task Detail

+
+ + + +
+
+
选择一个任务后,这里会显示当前链路、重试状态和最近动作。
+
选择一个任务后,这里会显示当前链路、重试状态和最近动作。
+ +
+
+
暂无最近结果
+
+
+ +
+
+

Session Workspace

+
+ +
+
+
当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。
+
+
+ +
+
+

Steps

+
+
+
+

Artifacts

+
+
+
+ +
+
+

History

+
+
+
+

Timeline

+
+
+
+
+
+
+ +
+
+

Settings

+
+ +
+ + +
+
+
+
+ Advanced JSON Editor + +
+

敏感字段显示为 `__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 index 07c36b6..19bd2e3 100644 --- a/src/biliup_next/app/retry_meta.py +++ b/src/biliup_next/app/retry_meta.py @@ -1,55 +1,77 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone - +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + STEP_SETTINGS_GROUP = { + "transcribe": "transcribe", + "song_detect": "song_detect", "publish": "publish", "comment": "comment", } - - -def parse_iso(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value) - except ValueError: - return None - - -def retry_schedule_seconds( - settings: dict[str, object], - *, - count_key: str, - backoff_key: str, - default_count: int, - default_backoff: int, -) -> list[int]: - raw_schedule = settings.get("retry_schedule_minutes") - if isinstance(raw_schedule, list): - schedule: list[int] = [] - for item in raw_schedule: - if isinstance(item, int) and not isinstance(item, bool) and item >= 0: - schedule.append(item * 60) - if schedule: - return schedule - - retry_count = settings.get(count_key, default_count) - retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else default_count - retry_count = max(retry_count, 0) - - retry_backoff = settings.get(backoff_key, default_backoff) - retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else default_backoff - retry_backoff = max(retry_backoff, 0) - return [retry_backoff] * retry_count - - + + +def parse_iso(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def retry_schedule_seconds( + settings: dict[str, object], + *, + count_key: str, + backoff_key: str, + default_count: int, + default_backoff: int, +) -> list[int]: + raw_schedule = settings.get("retry_schedule_minutes") + if isinstance(raw_schedule, list): + schedule: list[int] = [] + for item in raw_schedule: + if isinstance(item, int) and not isinstance(item, bool) and item >= 0: + schedule.append(item * 60) + if schedule: + return schedule + + retry_count = settings.get(count_key, default_count) + retry_count = retry_count if isinstance(retry_count, int) and not isinstance(retry_count, bool) else default_count + retry_count = max(retry_count, 0) + + retry_backoff = settings.get(backoff_key, default_backoff) + retry_backoff = retry_backoff if isinstance(retry_backoff, int) and not isinstance(retry_backoff, bool) else default_backoff + retry_backoff = max(retry_backoff, 0) + return [retry_backoff] * retry_count + + def publish_retry_schedule_seconds(settings: dict[str, object]) -> list[int]: + return retry_schedule_seconds( + settings, + count_key="retry_count", + backoff_key="retry_backoff_seconds", + default_count=5, + default_backoff=300, + ) + + +def transcribe_retry_schedule_seconds(settings: dict[str, object]) -> list[int]: return retry_schedule_seconds( settings, count_key="retry_count", backoff_key="retry_backoff_seconds", - default_count=5, + default_count=3, + default_backoff=300, + ) + + +def song_detect_retry_schedule_seconds(settings: dict[str, object]) -> list[int]: + return retry_schedule_seconds( + settings, + count_key="retry_count", + backoff_key="retry_backoff_seconds", + default_count=3, default_backoff=300, ) @@ -58,60 +80,64 @@ def comment_retry_schedule_seconds(settings: dict[str, object]) -> list[int]: return retry_schedule_seconds( settings, count_key="max_retries", - backoff_key="base_delay_seconds", - default_count=5, - default_backoff=180, - ) - - -def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def] - if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0: - return None - - step_name = getattr(step, "step_name", None) - settings_group = STEP_SETTINGS_GROUP.get(step_name) - if settings_group is None: - return None - - group_settings = settings_by_group.get(settings_group, {}) - if not isinstance(group_settings, dict): - group_settings = {} - - if step_name == "publish": + backoff_key="base_delay_seconds", + default_count=5, + default_backoff=180, + ) + + +def retry_meta_for_step(step, settings_by_group: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def] + if getattr(step, "status", None) != "failed_retryable" or getattr(step, "retry_count", 0) <= 0: + return None + + step_name = getattr(step, "step_name", None) + settings_group = STEP_SETTINGS_GROUP.get(step_name) + if settings_group is None: + return None + + group_settings = settings_by_group.get(settings_group, {}) + if not isinstance(group_settings, dict): + group_settings = {} + + if step_name == "transcribe": + schedule = transcribe_retry_schedule_seconds(group_settings) + elif step_name == "song_detect": + schedule = song_detect_retry_schedule_seconds(group_settings) + elif step_name == "publish": schedule = publish_retry_schedule_seconds(group_settings) elif step_name == "comment": schedule = comment_retry_schedule_seconds(group_settings) - else: - return None - - attempt_index = step.retry_count - 1 - if attempt_index >= len(schedule): - return { - "retry_due": False, - "retry_exhausted": True, - "retry_wait_seconds": None, - "retry_remaining_seconds": None, - "next_retry_at": None, - } - - wait_seconds = schedule[attempt_index] - reference = parse_iso(getattr(step, "finished_at", None)) or parse_iso(getattr(step, "started_at", None)) - if reference is None: - return { - "retry_due": True, - "retry_exhausted": False, - "retry_wait_seconds": wait_seconds, - "retry_remaining_seconds": 0, - "next_retry_at": datetime.now(timezone.utc).isoformat(), - } - - next_retry_at = reference + timedelta(seconds=wait_seconds) - now = datetime.now(timezone.utc) - remaining_seconds = max(int((next_retry_at - now).total_seconds()), 0) - return { - "retry_due": now >= next_retry_at, - "retry_exhausted": False, - "retry_wait_seconds": wait_seconds, - "retry_remaining_seconds": remaining_seconds, - "next_retry_at": next_retry_at.isoformat(), - } + else: + return None + + attempt_index = step.retry_count - 1 + if attempt_index >= len(schedule): + return { + "retry_due": False, + "retry_exhausted": True, + "retry_wait_seconds": None, + "retry_remaining_seconds": None, + "next_retry_at": None, + } + + wait_seconds = schedule[attempt_index] + reference = parse_iso(getattr(step, "finished_at", None)) or parse_iso(getattr(step, "started_at", None)) + if reference is None: + return { + "retry_due": True, + "retry_exhausted": False, + "retry_wait_seconds": wait_seconds, + "retry_remaining_seconds": 0, + "next_retry_at": datetime.now(timezone.utc).isoformat(), + } + + next_retry_at = reference + timedelta(seconds=wait_seconds) + now = datetime.now(timezone.utc) + remaining_seconds = max(int((next_retry_at - now).total_seconds()), 0) + return { + "retry_due": now >= next_retry_at, + "retry_exhausted": False, + "retry_wait_seconds": wait_seconds, + "retry_remaining_seconds": remaining_seconds, + "next_retry_at": next_retry_at.isoformat(), + } diff --git a/src/biliup_next/app/scheduler.py b/src/biliup_next/app/scheduler.py index 58fae33..c45c0d4 100644 --- a/src/biliup_next/app/scheduler.py +++ b/src/biliup_next/app/scheduler.py @@ -1,181 +1,181 @@ -from __future__ import annotations - -from dataclasses import asdict -from dataclasses import dataclass - -from biliup_next.app.bootstrap import ensure_initialized -from biliup_next.app.task_engine import next_runnable_step, settings_for - - -DEFAULT_STATUS_PRIORITY = [ - "failed_retryable", - "created", - "transcribed", - "songs_detected", - "split_done", - "published", - "commented", - "collection_synced", -] - - -@dataclass(slots=True) -class ScheduledTask: - task_id: str - reason: str - step_name: str | None = None - remaining_seconds: int | None = None - task_status: str | None = None - updated_at: str | None = None - - -@dataclass(slots=True) -class SchedulerCycle: - preview: dict[str, object] - scheduled: list[ScheduledTask] - deferred: list[dict[str, object]] - - -def serialize_scheduled_task(item: ScheduledTask) -> dict[str, object]: - return asdict(item) - - -def scheduler_settings(state: dict[str, object]) -> dict[str, object]: - return dict(state["settings"].get("scheduler", {})) - - -def _status_priority_map(state: dict[str, object]) -> dict[str, int]: - configured = scheduler_settings(state).get("status_priority", DEFAULT_STATUS_PRIORITY) - ordered = configured if isinstance(configured, list) else DEFAULT_STATUS_PRIORITY - return {str(status): index for index, status in enumerate(ordered)} - - -def _task_sort_key(state: dict[str, object], item: ScheduledTask) -> tuple[int, int, str]: - settings = scheduler_settings(state) - status_priority = _status_priority_map(state) - retry_rank = 0 if settings.get("prioritize_retry_due", True) and item.reason == "retry_due" else 1 - status_rank = status_priority.get(item.task_status or "", len(status_priority) + 10) - updated_at = item.updated_at or "" - if not settings.get("oldest_first", True): - updated_at = "".join(chr(255 - ord(ch)) for ch in updated_at) - return retry_rank, status_rank, updated_at - - -def _strategy_payload(state: dict[str, object], *, requested_limit: int) -> dict[str, object]: - settings = scheduler_settings(state) - return { - "candidate_scan_limit": int(settings.get("candidate_scan_limit", requested_limit)), - "max_tasks_per_cycle": int(settings.get("max_tasks_per_cycle", requested_limit)), - "prioritize_retry_due": bool(settings.get("prioritize_retry_due", True)), - "oldest_first": bool(settings.get("oldest_first", True)), - "status_priority": list(settings.get("status_priority", DEFAULT_STATUS_PRIORITY)), - } - - -def scan_stage_once(state: dict[str, object]) -> dict[str, object]: - ingest_settings = settings_for(state, "ingest") - return state["ingest_service"].scan_stage(ingest_settings) - - -def select_scheduled_tasks(state: dict[str, object], *, limit: int = 200) -> tuple[list[ScheduledTask], list[dict[str, object]]]: - repo = state["repo"] - scheduled: list[ScheduledTask] = [] - deferred: list[dict[str, object]] = [] - settings = scheduler_settings(state) - candidate_limit = int(settings.get("candidate_scan_limit", limit)) - max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit)) - for task in repo.list_tasks(limit=candidate_limit): - if task.status == "failed_manual": - continue - steps = {step.step_name: step for step in repo.list_steps(task.id)} - step_name, waiting_payload = next_runnable_step(task, steps, state) - if waiting_payload is not None: - deferred.append(waiting_payload) - continue - if step_name is None: - continue - scheduled.append( - ScheduledTask( - task.id, - reason="retry_due" if task.status == "failed_retryable" else "ready", - step_name=step_name, - remaining_seconds=None, - task_status=task.status, - updated_at=task.updated_at, - ) - ) - scheduled.sort(key=lambda item: _task_sort_key(state, item)) - return scheduled[:max_tasks_per_cycle], deferred - - -def build_scheduler_preview(state: dict[str, object], *, limit: int = 200, include_stage_scan: bool = False) -> dict[str, object]: - repo = state["repo"] - settings = scheduler_settings(state) - candidate_limit = int(settings.get("candidate_scan_limit", limit)) - max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit)) - - stage_scan_result: dict[str, object] = {"accepted": [], "rejected": [], "skipped": []} - if include_stage_scan: - stage_scan_result = scan_stage_once(state) - - scheduled_all: list[ScheduledTask] = [] - deferred: list[dict[str, object]] = [] - skipped_counts = { - "failed_manual": 0, - "no_runnable_step": 0, - } - scanned_count = 0 - - for task in repo.list_tasks(limit=candidate_limit): - scanned_count += 1 - if task.status == "failed_manual": - skipped_counts["failed_manual"] += 1 - continue - steps = {step.step_name: step for step in repo.list_steps(task.id)} - step_name, waiting_payload = next_runnable_step(task, steps, state) - if waiting_payload is not None: - deferred.append(waiting_payload) - continue - if step_name is None: - skipped_counts["no_runnable_step"] += 1 - continue - scheduled_all.append( - ScheduledTask( - task.id, - reason="retry_due" if task.status == "failed_retryable" else "ready", - step_name=step_name, - remaining_seconds=None, - task_status=task.status, - updated_at=task.updated_at, - ) - ) - - scheduled_all.sort(key=lambda item: _task_sort_key(state, item)) - scheduled = scheduled_all[:max_tasks_per_cycle] - truncated_count = max(0, len(scheduled_all) - len(scheduled)) - - return { - "stage_scan": stage_scan_result, - "scheduled": [serialize_scheduled_task(item) for item in scheduled], - "deferred": deferred, - "summary": { - "scanned_count": scanned_count, - "scheduled_count": len(scheduled), - "deferred_count": len(deferred), - "truncated_count": truncated_count, - "skipped_counts": skipped_counts, - }, - "strategy": _strategy_payload(state, requested_limit=limit), - } - - -def run_scheduled_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> tuple[dict[str, object], list[ScheduledTask], list[dict[str, object]]]: - cycle = run_scheduler_cycle(include_stage_scan=include_stage_scan, limit=limit) - return cycle.preview["stage_scan"], cycle.scheduled, cycle.deferred - - -def run_scheduler_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> SchedulerCycle: - state = ensure_initialized() - preview = build_scheduler_preview(state, include_stage_scan=include_stage_scan, limit=limit) - scheduled = [ScheduledTask(**item) for item in preview["scheduled"]] - return SchedulerCycle(preview=preview, scheduled=scheduled, deferred=preview["deferred"]) +from __future__ import annotations + +from dataclasses import asdict +from dataclasses import dataclass + +from biliup_next.app.bootstrap import ensure_initialized +from biliup_next.app.task_engine import next_runnable_step, settings_for + + +DEFAULT_STATUS_PRIORITY = [ + "failed_retryable", + "created", + "transcribed", + "songs_detected", + "split_done", + "published", + "commented", + "collection_synced", +] + + +@dataclass(slots=True) +class ScheduledTask: + task_id: str + reason: str + step_name: str | None = None + remaining_seconds: int | None = None + task_status: str | None = None + updated_at: str | None = None + + +@dataclass(slots=True) +class SchedulerCycle: + preview: dict[str, object] + scheduled: list[ScheduledTask] + deferred: list[dict[str, object]] + + +def serialize_scheduled_task(item: ScheduledTask) -> dict[str, object]: + return asdict(item) + + +def scheduler_settings(state: dict[str, object]) -> dict[str, object]: + return dict(state["settings"].get("scheduler", {})) + + +def _status_priority_map(state: dict[str, object]) -> dict[str, int]: + configured = scheduler_settings(state).get("status_priority", DEFAULT_STATUS_PRIORITY) + ordered = configured if isinstance(configured, list) else DEFAULT_STATUS_PRIORITY + return {str(status): index for index, status in enumerate(ordered)} + + +def _task_sort_key(state: dict[str, object], item: ScheduledTask) -> tuple[int, int, str]: + settings = scheduler_settings(state) + status_priority = _status_priority_map(state) + retry_rank = 0 if settings.get("prioritize_retry_due", True) and item.reason == "retry_due" else 1 + status_rank = status_priority.get(item.task_status or "", len(status_priority) + 10) + updated_at = item.updated_at or "" + if not settings.get("oldest_first", True): + updated_at = "".join(chr(255 - ord(ch)) for ch in updated_at) + return retry_rank, status_rank, updated_at + + +def _strategy_payload(state: dict[str, object], *, requested_limit: int) -> dict[str, object]: + settings = scheduler_settings(state) + return { + "candidate_scan_limit": int(settings.get("candidate_scan_limit", requested_limit)), + "max_tasks_per_cycle": int(settings.get("max_tasks_per_cycle", requested_limit)), + "prioritize_retry_due": bool(settings.get("prioritize_retry_due", True)), + "oldest_first": bool(settings.get("oldest_first", True)), + "status_priority": list(settings.get("status_priority", DEFAULT_STATUS_PRIORITY)), + } + + +def scan_stage_once(state: dict[str, object]) -> dict[str, object]: + ingest_settings = settings_for(state, "ingest") + return state["ingest_service"].scan_stage(ingest_settings) + + +def select_scheduled_tasks(state: dict[str, object], *, limit: int = 200) -> tuple[list[ScheduledTask], list[dict[str, object]]]: + repo = state["repo"] + scheduled: list[ScheduledTask] = [] + deferred: list[dict[str, object]] = [] + settings = scheduler_settings(state) + candidate_limit = int(settings.get("candidate_scan_limit", limit)) + max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit)) + for task in repo.list_tasks(limit=candidate_limit): + if task.status == "failed_manual": + continue + steps = {step.step_name: step for step in repo.list_steps(task.id)} + step_name, waiting_payload = next_runnable_step(task, steps, state) + if waiting_payload is not None: + deferred.append(waiting_payload) + continue + if step_name is None: + continue + scheduled.append( + ScheduledTask( + task.id, + reason="retry_due" if task.status == "failed_retryable" else "ready", + step_name=step_name, + remaining_seconds=None, + task_status=task.status, + updated_at=task.updated_at, + ) + ) + scheduled.sort(key=lambda item: _task_sort_key(state, item)) + return scheduled[:max_tasks_per_cycle], deferred + + +def build_scheduler_preview(state: dict[str, object], *, limit: int = 200, include_stage_scan: bool = False) -> dict[str, object]: + repo = state["repo"] + settings = scheduler_settings(state) + candidate_limit = int(settings.get("candidate_scan_limit", limit)) + max_tasks_per_cycle = int(settings.get("max_tasks_per_cycle", limit)) + + stage_scan_result: dict[str, object] = {"accepted": [], "rejected": [], "skipped": []} + if include_stage_scan: + stage_scan_result = scan_stage_once(state) + + scheduled_all: list[ScheduledTask] = [] + deferred: list[dict[str, object]] = [] + skipped_counts = { + "failed_manual": 0, + "no_runnable_step": 0, + } + scanned_count = 0 + + for task in repo.list_tasks(limit=candidate_limit): + scanned_count += 1 + if task.status == "failed_manual": + skipped_counts["failed_manual"] += 1 + continue + steps = {step.step_name: step for step in repo.list_steps(task.id)} + step_name, waiting_payload = next_runnable_step(task, steps, state) + if waiting_payload is not None: + deferred.append(waiting_payload) + continue + if step_name is None: + skipped_counts["no_runnable_step"] += 1 + continue + scheduled_all.append( + ScheduledTask( + task.id, + reason="retry_due" if task.status == "failed_retryable" else "ready", + step_name=step_name, + remaining_seconds=None, + task_status=task.status, + updated_at=task.updated_at, + ) + ) + + scheduled_all.sort(key=lambda item: _task_sort_key(state, item)) + scheduled = scheduled_all[:max_tasks_per_cycle] + truncated_count = max(0, len(scheduled_all) - len(scheduled)) + + return { + "stage_scan": stage_scan_result, + "scheduled": [serialize_scheduled_task(item) for item in scheduled], + "deferred": deferred, + "summary": { + "scanned_count": scanned_count, + "scheduled_count": len(scheduled), + "deferred_count": len(deferred), + "truncated_count": truncated_count, + "skipped_counts": skipped_counts, + }, + "strategy": _strategy_payload(state, requested_limit=limit), + } + + +def run_scheduled_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> tuple[dict[str, object], list[ScheduledTask], list[dict[str, object]]]: + cycle = run_scheduler_cycle(include_stage_scan=include_stage_scan, limit=limit) + return cycle.preview["stage_scan"], cycle.scheduled, cycle.deferred + + +def run_scheduler_cycle(*, include_stage_scan: bool = True, limit: int = 200) -> SchedulerCycle: + state = ensure_initialized() + preview = build_scheduler_preview(state, include_stage_scan=include_stage_scan, limit=limit) + scheduled = [ScheduledTask(**item) for item in preview["scheduled"]] + return SchedulerCycle(preview=preview, scheduled=scheduled, deferred=preview["deferred"]) diff --git a/src/biliup_next/app/serializers.py b/src/biliup_next/app/serializers.py index 267a4c4..fa50647 100644 --- a/src/biliup_next/app/serializers.py +++ b/src/biliup_next/app/serializers.py @@ -1,255 +1,255 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from biliup_next.app.retry_meta import retry_meta_for_step -from biliup_next.infra.workspace_paths import resolve_task_work_dir - - -class ControlPlaneSerializer: - def __init__(self, state: dict[str, object]): - self.state = state - - @staticmethod - def video_url(bvid: object) -> str | None: - if isinstance(bvid, str) and bvid.startswith("BV"): - return f"https://www.bilibili.com/video/{bvid}" - return None - - def task_related_maps( - self, - tasks, - ) -> tuple[dict[str, object], dict[str, list[object]]]: # type: ignore[no-untyped-def] - task_ids = [task.id for task in tasks] - contexts_by_task_id = self.state["repo"].list_task_contexts_for_task_ids(task_ids) - steps_by_task_id = self.state["repo"].list_steps_for_task_ids(task_ids) - return contexts_by_task_id, steps_by_task_id - - def task_payload(self, task_id: str) -> dict[str, object] | None: - task = self.state["repo"].get_task(task_id) - if task is None: - return None - return self.task_payload_from_task(task) - - def task_payloads_from_tasks(self, tasks) -> list[dict[str, object]]: # type: ignore[no-untyped-def] - contexts_by_task_id, steps_by_task_id = self.task_related_maps(tasks) - return [ - self.task_payload_from_task( - task, - context=contexts_by_task_id.get(task.id), - steps=steps_by_task_id.get(task.id, []), - ) - for task in tasks - ] - - def task_payload_from_task( - self, - task, - *, - context=None, # type: ignore[no-untyped-def] - steps=None, # type: ignore[no-untyped-def] - ) -> dict[str, object]: - payload = task.to_dict() - session_context = self.task_context_payload(task.id, task=task, context=context) - if session_context: - payload["session_context"] = session_context - retry_state = self.task_retry_state(task.id, steps=steps) - if retry_state: - payload["retry_state"] = retry_state - payload["delivery_state"] = self.task_delivery_state(task.id, task=task) - return payload - - def step_payload(self, step) -> dict[str, object]: # type: ignore[no-untyped-def] - payload = step.to_dict() - retry_meta = retry_meta_for_step(step, self.state["settings"]) - if retry_meta: - payload.update(retry_meta) - return payload - - def task_retry_state(self, task_id: str, *, steps=None) -> dict[str, object] | None: # type: ignore[no-untyped-def] - step_items = steps if steps is not None else self.state["repo"].list_steps(task_id) - for step in step_items: - retry_meta = retry_meta_for_step(step, self.state["settings"]) - if retry_meta: - return {"step_name": step.step_name, **retry_meta} - return None - - def task_delivery_state(self, task_id: str, *, task=None) -> dict[str, object]: # type: ignore[no-untyped-def] - task = task or self.state["repo"].get_task(task_id) - if task is None: - return {} - session_dir = resolve_task_work_dir(task) - source_path = Path(task.source_path) - split_dir = session_dir / "split_video" - - def comment_status(flag_name: str, *, enabled: bool) -> str: - if not enabled: - return "disabled" - return "done" if (session_dir / flag_name).exists() else "pending" - - return { - "split_comment": comment_status("comment_split_done.flag", enabled=self.state["settings"]["comment"].get("post_split_comment", True)), - "full_video_timeline_comment": comment_status( - "comment_full_done.flag", - enabled=self.state["settings"]["comment"].get("post_full_video_timeline_comment", True), - ), - "full_video_bvid_resolved": (session_dir / "full_video_bvid.txt").exists(), - "source_video_present": source_path.exists(), - "split_videos_present": split_dir.exists(), - "cleanup_enabled": { - "delete_source_video_after_collection_synced": self.state["settings"].get("cleanup", {}).get("delete_source_video_after_collection_synced", False), - "delete_split_videos_after_collection_synced": self.state["settings"].get("cleanup", {}).get("delete_split_videos_after_collection_synced", False), - }, - } - - def task_context_payload(self, task_id: str, *, task=None, context=None) -> dict[str, object] | None: # type: ignore[no-untyped-def] - task = task or self.state["repo"].get_task(task_id) - if task is None: - return None - context = context or self.state["repo"].get_task_context(task_id) - if context is None: - payload = { - "task_id": task.id, - "session_key": None, - "streamer": None, - "room_id": None, - "source_title": task.title, - "segment_started_at": None, - "segment_duration_seconds": None, - "full_video_bvid": None, - "created_at": task.created_at, - "updated_at": task.updated_at, - "context_source": "fallback", - } - else: - payload = context.to_dict() - payload["context_source"] = "task_context" - payload["split_bvid"] = self.read_task_text_artifact(task_id, "bvid.txt", task=task) - full_video_bvid = self.read_task_text_artifact(task_id, "full_video_bvid.txt", task=task) - if full_video_bvid: - payload["full_video_bvid"] = full_video_bvid - payload["video_links"] = { - "split_video_url": self.video_url(payload.get("split_bvid")), - "full_video_url": self.video_url(payload.get("full_video_bvid")), - } - return payload - - def session_payload(self, session_key: str) -> dict[str, object] | None: - contexts = self.state["repo"].list_task_contexts_by_session_key(session_key) - if not contexts: - return None - tasks = [] - full_video_bvid = None - for context in contexts: - task = self.state["repo"].get_task(context.task_id) - if task is None: - continue - tasks.append(task) - if not full_video_bvid and context.full_video_bvid: - full_video_bvid = context.full_video_bvid - return { - "session_key": session_key, - "task_count": len(tasks), - "full_video_bvid": full_video_bvid, - "full_video_url": self.video_url(full_video_bvid), - "tasks": self.task_payloads_from_tasks(tasks), - } - - def timeline_payload(self, task_id: str) -> dict[str, object] | None: - task = self.state["repo"].get_task(task_id) - if task is None: - return None - steps = self.state["repo"].list_steps(task_id) - artifacts = self.state["repo"].list_artifacts(task_id) - actions = self.state["repo"].list_action_records(task_id, limit=200) - items: list[dict[str, object]] = [] - if task.created_at: - items.append({ - "kind": "task", - "time": task.created_at, - "title": "Task Created", - "summary": task.title, - "status": task.status, - }) - if task.updated_at and task.updated_at != task.created_at: - items.append({ - "kind": "task", - "time": task.updated_at, - "title": "Task Updated", - "summary": task.status, - "status": task.status, - }) - for step in steps: - if step.started_at: - items.append({ - "kind": "step", - "time": step.started_at, - "title": f"{step.step_name} started", - "summary": step.status, - "status": step.status, - }) - if step.finished_at: - retry_meta = retry_meta_for_step(step, self.state["settings"]) - retry_note = "" - if retry_meta and retry_meta.get("next_retry_at"): - retry_note = f" | next retry: {retry_meta['next_retry_at']}" - items.append({ - "kind": "step", - "time": step.finished_at, - "title": f"{step.step_name} finished", - "summary": f"{step.error_message or step.status}{retry_note}", - "status": step.status, - "retry_state": retry_meta, - }) - for artifact in artifacts: - if artifact.created_at: - items.append({ - "kind": "artifact", - "time": artifact.created_at, - "title": artifact.artifact_type, - "summary": artifact.path, - "status": "created", - }) - for action in actions: - summary = action.summary - try: - details = json.loads(action.details_json or "{}") - except json.JSONDecodeError: - details = {} - if action.action_name == "comment" and isinstance(details, dict): - split_status = details.get("split", {}).get("status") - full_status = details.get("full", {}).get("status") - fragments = [] - if split_status: - fragments.append(f"split={split_status}") - if full_status: - fragments.append(f"full={full_status}") - if fragments: - summary = f"{summary} | {' '.join(fragments)}" - if action.action_name in {"collection_a", "collection_b"} and isinstance(details, dict): - cleanup = details.get("result", {}).get("cleanup") or details.get("cleanup") - if isinstance(cleanup, dict): - removed = cleanup.get("removed") or [] - if removed: - summary = f"{summary} | cleanup removed={len(removed)}" - items.append({ - "kind": "action", - "time": action.created_at, - "title": action.action_name, - "summary": summary, - "status": action.status, - }) - items.sort(key=lambda item: str(item["time"]), reverse=True) - return {"items": items} - - def read_task_text_artifact(self, task_id: str, filename: str, *, task=None) -> str | None: # type: ignore[no-untyped-def] - task = task or self.state["repo"].get_task(task_id) - if task is None: - return None - session_dir = resolve_task_work_dir(task) - path = session_dir / filename - if not path.exists(): - return None - value = path.read_text(encoding="utf-8").strip() - return value or None +from __future__ import annotations + +import json +from pathlib import Path + +from biliup_next.app.retry_meta import retry_meta_for_step +from biliup_next.infra.workspace_paths import resolve_task_work_dir + + +class ControlPlaneSerializer: + def __init__(self, state: dict[str, object]): + self.state = state + + @staticmethod + def video_url(bvid: object) -> str | None: + if isinstance(bvid, str) and bvid.startswith("BV"): + return f"https://www.bilibili.com/video/{bvid}" + return None + + def task_related_maps( + self, + tasks, + ) -> tuple[dict[str, object], dict[str, list[object]]]: # type: ignore[no-untyped-def] + task_ids = [task.id for task in tasks] + contexts_by_task_id = self.state["repo"].list_task_contexts_for_task_ids(task_ids) + steps_by_task_id = self.state["repo"].list_steps_for_task_ids(task_ids) + return contexts_by_task_id, steps_by_task_id + + def task_payload(self, task_id: str) -> dict[str, object] | None: + task = self.state["repo"].get_task(task_id) + if task is None: + return None + return self.task_payload_from_task(task) + + def task_payloads_from_tasks(self, tasks) -> list[dict[str, object]]: # type: ignore[no-untyped-def] + contexts_by_task_id, steps_by_task_id = self.task_related_maps(tasks) + return [ + self.task_payload_from_task( + task, + context=contexts_by_task_id.get(task.id), + steps=steps_by_task_id.get(task.id, []), + ) + for task in tasks + ] + + def task_payload_from_task( + self, + task, + *, + context=None, # type: ignore[no-untyped-def] + steps=None, # type: ignore[no-untyped-def] + ) -> dict[str, object]: + payload = task.to_dict() + session_context = self.task_context_payload(task.id, task=task, context=context) + if session_context: + payload["session_context"] = session_context + retry_state = self.task_retry_state(task.id, steps=steps) + if retry_state: + payload["retry_state"] = retry_state + payload["delivery_state"] = self.task_delivery_state(task.id, task=task) + return payload + + def step_payload(self, step) -> dict[str, object]: # type: ignore[no-untyped-def] + payload = step.to_dict() + retry_meta = retry_meta_for_step(step, self.state["settings"]) + if retry_meta: + payload.update(retry_meta) + return payload + + def task_retry_state(self, task_id: str, *, steps=None) -> dict[str, object] | None: # type: ignore[no-untyped-def] + step_items = steps if steps is not None else self.state["repo"].list_steps(task_id) + for step in step_items: + retry_meta = retry_meta_for_step(step, self.state["settings"]) + if retry_meta: + return {"step_name": step.step_name, **retry_meta} + return None + + def task_delivery_state(self, task_id: str, *, task=None) -> dict[str, object]: # type: ignore[no-untyped-def] + task = task or self.state["repo"].get_task(task_id) + if task is None: + return {} + session_dir = resolve_task_work_dir(task) + source_path = Path(task.source_path) + split_dir = session_dir / "split_video" + + def comment_status(flag_name: str, *, enabled: bool) -> str: + if not enabled: + return "disabled" + return "done" if (session_dir / flag_name).exists() else "pending" + + return { + "split_comment": comment_status("comment_split_done.flag", enabled=self.state["settings"]["comment"].get("post_split_comment", True)), + "full_video_timeline_comment": comment_status( + "comment_full_done.flag", + enabled=self.state["settings"]["comment"].get("post_full_video_timeline_comment", True), + ), + "full_video_bvid_resolved": (session_dir / "full_video_bvid.txt").exists(), + "source_video_present": source_path.exists(), + "split_videos_present": split_dir.exists(), + "cleanup_enabled": { + "delete_source_video_after_collection_synced": self.state["settings"].get("cleanup", {}).get("delete_source_video_after_collection_synced", False), + "delete_split_videos_after_collection_synced": self.state["settings"].get("cleanup", {}).get("delete_split_videos_after_collection_synced", False), + }, + } + + def task_context_payload(self, task_id: str, *, task=None, context=None) -> dict[str, object] | None: # type: ignore[no-untyped-def] + task = task or self.state["repo"].get_task(task_id) + if task is None: + return None + context = context or self.state["repo"].get_task_context(task_id) + if context is None: + payload = { + "task_id": task.id, + "session_key": None, + "streamer": None, + "room_id": None, + "source_title": task.title, + "segment_started_at": None, + "segment_duration_seconds": None, + "full_video_bvid": None, + "created_at": task.created_at, + "updated_at": task.updated_at, + "context_source": "fallback", + } + else: + payload = context.to_dict() + payload["context_source"] = "task_context" + payload["split_bvid"] = self.read_task_text_artifact(task_id, "bvid.txt", task=task) + full_video_bvid = self.read_task_text_artifact(task_id, "full_video_bvid.txt", task=task) + if full_video_bvid: + payload["full_video_bvid"] = full_video_bvid + payload["video_links"] = { + "split_video_url": self.video_url(payload.get("split_bvid")), + "full_video_url": self.video_url(payload.get("full_video_bvid")), + } + return payload + + def session_payload(self, session_key: str) -> dict[str, object] | None: + contexts = self.state["repo"].list_task_contexts_by_session_key(session_key) + if not contexts: + return None + tasks = [] + full_video_bvid = None + for context in contexts: + task = self.state["repo"].get_task(context.task_id) + if task is None: + continue + tasks.append(task) + if not full_video_bvid and context.full_video_bvid: + full_video_bvid = context.full_video_bvid + return { + "session_key": session_key, + "task_count": len(tasks), + "full_video_bvid": full_video_bvid, + "full_video_url": self.video_url(full_video_bvid), + "tasks": self.task_payloads_from_tasks(tasks), + } + + def timeline_payload(self, task_id: str) -> dict[str, object] | None: + task = self.state["repo"].get_task(task_id) + if task is None: + return None + steps = self.state["repo"].list_steps(task_id) + artifacts = self.state["repo"].list_artifacts(task_id) + actions = self.state["repo"].list_action_records(task_id, limit=200) + items: list[dict[str, object]] = [] + if task.created_at: + items.append({ + "kind": "task", + "time": task.created_at, + "title": "Task Created", + "summary": task.title, + "status": task.status, + }) + if task.updated_at and task.updated_at != task.created_at: + items.append({ + "kind": "task", + "time": task.updated_at, + "title": "Task Updated", + "summary": task.status, + "status": task.status, + }) + for step in steps: + if step.started_at: + items.append({ + "kind": "step", + "time": step.started_at, + "title": f"{step.step_name} started", + "summary": step.status, + "status": step.status, + }) + if step.finished_at: + retry_meta = retry_meta_for_step(step, self.state["settings"]) + retry_note = "" + if retry_meta and retry_meta.get("next_retry_at"): + retry_note = f" | next retry: {retry_meta['next_retry_at']}" + items.append({ + "kind": "step", + "time": step.finished_at, + "title": f"{step.step_name} finished", + "summary": f"{step.error_message or step.status}{retry_note}", + "status": step.status, + "retry_state": retry_meta, + }) + for artifact in artifacts: + if artifact.created_at: + items.append({ + "kind": "artifact", + "time": artifact.created_at, + "title": artifact.artifact_type, + "summary": artifact.path, + "status": "created", + }) + for action in actions: + summary = action.summary + try: + details = json.loads(action.details_json or "{}") + except json.JSONDecodeError: + details = {} + if action.action_name == "comment" and isinstance(details, dict): + split_status = details.get("split", {}).get("status") + full_status = details.get("full", {}).get("status") + fragments = [] + if split_status: + fragments.append(f"split={split_status}") + if full_status: + fragments.append(f"full={full_status}") + if fragments: + summary = f"{summary} | {' '.join(fragments)}" + if action.action_name in {"collection_a", "collection_b"} and isinstance(details, dict): + cleanup = details.get("result", {}).get("cleanup") or details.get("cleanup") + if isinstance(cleanup, dict): + removed = cleanup.get("removed") or [] + if removed: + summary = f"{summary} | cleanup removed={len(removed)}" + items.append({ + "kind": "action", + "time": action.created_at, + "title": action.action_name, + "summary": summary, + "status": action.status, + }) + items.sort(key=lambda item: str(item["time"]), reverse=True) + return {"items": items} + + def read_task_text_artifact(self, task_id: str, filename: str, *, task=None) -> str | None: # type: ignore[no-untyped-def] + task = task or self.state["repo"].get_task(task_id) + if task is None: + return None + session_dir = resolve_task_work_dir(task) + path = session_dir / filename + if not path.exists(): + return None + value = path.read_text(encoding="utf-8").strip() + return value or None diff --git a/src/biliup_next/app/session_delivery_service.py b/src/biliup_next/app/session_delivery_service.py index f0ce6ea..e175d60 100644 --- a/src/biliup_next/app/session_delivery_service.py +++ b/src/biliup_next/app/session_delivery_service.py @@ -1,259 +1,259 @@ -from __future__ import annotations - -import json -from pathlib import Path -import re - -from biliup_next.core.models import ActionRecord, SessionBinding, TaskContext, utc_now_iso -from biliup_next.infra.workspace_paths import resolve_task_work_dir - - -class SessionDeliveryService: - def __init__(self, state: dict[str, object]): - self.state = state - self.repo = state["repo"] - self.settings = state["settings"] - - def bind_task_full_video(self, task_id: str, full_video_bvid: str) -> dict[str, object]: - task = self.repo.get_task(task_id) - if task is None: - return {"error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}} - - bvid = self._normalize_bvid(full_video_bvid) - if bvid is None: - return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}} - - now = utc_now_iso() - context = self.repo.get_task_context(task_id) - if context is None: - context = TaskContext( - id=None, - task_id=task.id, - session_key=f"task:{task.id}", - streamer=None, - room_id=None, - source_title=task.title, - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid=bvid, - created_at=task.created_at, - updated_at=now, - ) - full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) - return { - "task_id": task.id, - "session_key": context.session_key, - "full_video_bvid": bvid, - "path": str(full_video_bvid_path), - } - - def rebind_session_full_video(self, session_key: str, full_video_bvid: str) -> dict[str, object]: - bvid = self._normalize_bvid(full_video_bvid) - if bvid is None: - return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}} - - contexts = self.repo.list_task_contexts_by_session_key(session_key) - if not contexts: - return {"error": {"code": "SESSION_NOT_FOUND", "message": f"session not found: {session_key}"}} - - now = utc_now_iso() - self.repo.update_session_full_video_bvid(session_key, bvid, now) - - updated_tasks: list[dict[str, object]] = [] - for context in contexts: - task = self.repo.get_task(context.task_id) - if task is None: - continue - full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) - updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)}) - - return { - "session_key": session_key, - "full_video_bvid": bvid, - "updated_count": len(updated_tasks), - "tasks": updated_tasks, - } - - def merge_session(self, session_key: str, task_ids: list[str]) -> dict[str, object]: - normalized_task_ids: list[str] = [] - for raw in task_ids: - task_id = str(raw).strip() - if task_id and task_id not in normalized_task_ids: - normalized_task_ids.append(task_id) - if not normalized_task_ids: - return {"error": {"code": "TASK_IDS_EMPTY", "message": "task_ids is empty"}} - - now = utc_now_iso() - inherited_bvid = None - existing_contexts = self.repo.list_task_contexts_by_session_key(session_key) - for context in existing_contexts: - if context.full_video_bvid: - inherited_bvid = context.full_video_bvid - break - - merged_tasks: list[dict[str, object]] = [] - missing_tasks: list[str] = [] - - for task_id in normalized_task_ids: - task = self.repo.get_task(task_id) - if task is None: - missing_tasks.append(task_id) - continue - - context = self.repo.get_task_context(task_id) - if context is None: - context = TaskContext( - id=None, - task_id=task.id, - session_key=session_key, - streamer=None, - room_id=None, - source_title=task.title, - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid=inherited_bvid, - created_at=task.created_at, - updated_at=now, - ) - else: - context.session_key = session_key - context.updated_at = now - if inherited_bvid and not context.full_video_bvid: - context.full_video_bvid = inherited_bvid - self.repo.upsert_task_context(context) - - if context.full_video_bvid: - full_video_bvid_path = self._persist_task_full_video_bvid(task, context, context.full_video_bvid, now=now) - else: - full_video_bvid_path = None - - payload = { - "task_id": task.id, - "session_key": session_key, - "full_video_bvid": context.full_video_bvid, - } - if full_video_bvid_path is not None: - payload["path"] = str(full_video_bvid_path) - merged_tasks.append(payload) - - return { - "session_key": session_key, - "merged_count": len(merged_tasks), - "tasks": merged_tasks, - "missing_task_ids": missing_tasks, - } - - def receive_full_video_webhook(self, payload: dict[str, object]) -> dict[str, object]: - raw_bvid = str(payload.get("full_video_bvid") or payload.get("bvid") or "").strip() - bvid = self._normalize_bvid(raw_bvid) - if bvid is None: - return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {raw_bvid}"}} - - session_key = str(payload.get("session_key") or "").strip() or None - source_title = str(payload.get("source_title") or "").strip() or None - streamer = str(payload.get("streamer") or "").strip() or None - room_id = str(payload.get("room_id") or "").strip() or None - if session_key is None and source_title is None: - return {"error": {"code": "SESSION_KEY_OR_SOURCE_TITLE_REQUIRED", "message": "session_key or source_title required"}} - - source_contexts = self.repo.list_task_contexts_by_source_title(source_title) if source_title else [] - if session_key is None and source_contexts: - session_key = source_contexts[0].session_key - - now = utc_now_iso() - self.repo.upsert_session_binding( - SessionBinding( - id=None, - session_key=session_key, - source_title=source_title, - streamer=streamer, - room_id=room_id, - full_video_bvid=bvid, - created_at=now, - updated_at=now, - ) - ) - - contexts = self.repo.list_task_contexts_by_session_key(session_key) if session_key else [] - if not contexts and source_contexts: - contexts = source_contexts - - updated_tasks: list[dict[str, object]] = [] - for context in contexts: - task = self.repo.get_task(context.task_id) - if task is None: - continue - if session_key and (context.session_key.startswith("task:") or context.session_key != session_key): - context.session_key = session_key - full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) - updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)}) - - self.repo.add_action_record( - ActionRecord( - id=None, - task_id=None, - action_name="webhook_full_video_uploaded", - status="ok", - summary=f"full video webhook received: {bvid}", - details_json=json.dumps( - { - "session_key": session_key, - "source_title": source_title, - "streamer": streamer, - "room_id": room_id, - "updated_count": len(updated_tasks), - }, - ensure_ascii=False, - ), - created_at=now, - ) - ) - return { - "ok": True, - "session_key": session_key, - "source_title": source_title, - "full_video_bvid": bvid, - "updated_count": len(updated_tasks), - "tasks": updated_tasks, - } - - def _normalize_bvid(self, full_video_bvid: str) -> str | None: - bvid = full_video_bvid.strip() - if not re.fullmatch(r"BV[0-9A-Za-z]+", bvid): - return None - return bvid - - def _full_video_bvid_path(self, task) -> Path: # type: ignore[no-untyped-def] - work_dir = resolve_task_work_dir(task) - work_dir.mkdir(parents=True, exist_ok=True) - return work_dir / "full_video_bvid.txt" - - def _upsert_session_binding_for_context(self, context: TaskContext, full_video_bvid: str, now: str) -> None: - self.repo.upsert_session_binding( - SessionBinding( - id=None, - session_key=context.session_key, - source_title=context.source_title, - streamer=context.streamer, - room_id=context.room_id, - full_video_bvid=full_video_bvid, - created_at=now, - updated_at=now, - ) - ) - - def _persist_task_full_video_bvid( - self, - task, - context: TaskContext, - full_video_bvid: str, - *, - now: str, - ) -> Path: # type: ignore[no-untyped-def] - context.full_video_bvid = full_video_bvid - context.updated_at = now - self.repo.upsert_task_context(context) - self._upsert_session_binding_for_context(context, full_video_bvid, now) - path = self._full_video_bvid_path(task) - path.write_text(full_video_bvid, encoding="utf-8") - return path +from __future__ import annotations + +import json +from pathlib import Path +import re + +from biliup_next.core.models import ActionRecord, SessionBinding, TaskContext, utc_now_iso +from biliup_next.infra.workspace_paths import resolve_task_work_dir + + +class SessionDeliveryService: + def __init__(self, state: dict[str, object]): + self.state = state + self.repo = state["repo"] + self.settings = state["settings"] + + def bind_task_full_video(self, task_id: str, full_video_bvid: str) -> dict[str, object]: + task = self.repo.get_task(task_id) + if task is None: + return {"error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}} + + bvid = self._normalize_bvid(full_video_bvid) + if bvid is None: + return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}} + + now = utc_now_iso() + context = self.repo.get_task_context(task_id) + if context is None: + context = TaskContext( + id=None, + task_id=task.id, + session_key=f"task:{task.id}", + streamer=None, + room_id=None, + source_title=task.title, + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid=bvid, + created_at=task.created_at, + updated_at=now, + ) + full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) + return { + "task_id": task.id, + "session_key": context.session_key, + "full_video_bvid": bvid, + "path": str(full_video_bvid_path), + } + + def rebind_session_full_video(self, session_key: str, full_video_bvid: str) -> dict[str, object]: + bvid = self._normalize_bvid(full_video_bvid) + if bvid is None: + return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {full_video_bvid}"}} + + contexts = self.repo.list_task_contexts_by_session_key(session_key) + if not contexts: + return {"error": {"code": "SESSION_NOT_FOUND", "message": f"session not found: {session_key}"}} + + now = utc_now_iso() + self.repo.update_session_full_video_bvid(session_key, bvid, now) + + updated_tasks: list[dict[str, object]] = [] + for context in contexts: + task = self.repo.get_task(context.task_id) + if task is None: + continue + full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) + updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)}) + + return { + "session_key": session_key, + "full_video_bvid": bvid, + "updated_count": len(updated_tasks), + "tasks": updated_tasks, + } + + def merge_session(self, session_key: str, task_ids: list[str]) -> dict[str, object]: + normalized_task_ids: list[str] = [] + for raw in task_ids: + task_id = str(raw).strip() + if task_id and task_id not in normalized_task_ids: + normalized_task_ids.append(task_id) + if not normalized_task_ids: + return {"error": {"code": "TASK_IDS_EMPTY", "message": "task_ids is empty"}} + + now = utc_now_iso() + inherited_bvid = None + existing_contexts = self.repo.list_task_contexts_by_session_key(session_key) + for context in existing_contexts: + if context.full_video_bvid: + inherited_bvid = context.full_video_bvid + break + + merged_tasks: list[dict[str, object]] = [] + missing_tasks: list[str] = [] + + for task_id in normalized_task_ids: + task = self.repo.get_task(task_id) + if task is None: + missing_tasks.append(task_id) + continue + + context = self.repo.get_task_context(task_id) + if context is None: + context = TaskContext( + id=None, + task_id=task.id, + session_key=session_key, + streamer=None, + room_id=None, + source_title=task.title, + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid=inherited_bvid, + created_at=task.created_at, + updated_at=now, + ) + else: + context.session_key = session_key + context.updated_at = now + if inherited_bvid and not context.full_video_bvid: + context.full_video_bvid = inherited_bvid + self.repo.upsert_task_context(context) + + if context.full_video_bvid: + full_video_bvid_path = self._persist_task_full_video_bvid(task, context, context.full_video_bvid, now=now) + else: + full_video_bvid_path = None + + payload = { + "task_id": task.id, + "session_key": session_key, + "full_video_bvid": context.full_video_bvid, + } + if full_video_bvid_path is not None: + payload["path"] = str(full_video_bvid_path) + merged_tasks.append(payload) + + return { + "session_key": session_key, + "merged_count": len(merged_tasks), + "tasks": merged_tasks, + "missing_task_ids": missing_tasks, + } + + def receive_full_video_webhook(self, payload: dict[str, object]) -> dict[str, object]: + raw_bvid = str(payload.get("full_video_bvid") or payload.get("bvid") or "").strip() + bvid = self._normalize_bvid(raw_bvid) + if bvid is None: + return {"error": {"code": "INVALID_BVID", "message": f"invalid bvid: {raw_bvid}"}} + + session_key = str(payload.get("session_key") or "").strip() or None + source_title = str(payload.get("source_title") or "").strip() or None + streamer = str(payload.get("streamer") or "").strip() or None + room_id = str(payload.get("room_id") or "").strip() or None + if session_key is None and source_title is None: + return {"error": {"code": "SESSION_KEY_OR_SOURCE_TITLE_REQUIRED", "message": "session_key or source_title required"}} + + source_contexts = self.repo.list_task_contexts_by_source_title(source_title) if source_title else [] + if session_key is None and source_contexts: + session_key = source_contexts[0].session_key + + now = utc_now_iso() + self.repo.upsert_session_binding( + SessionBinding( + id=None, + session_key=session_key, + source_title=source_title, + streamer=streamer, + room_id=room_id, + full_video_bvid=bvid, + created_at=now, + updated_at=now, + ) + ) + + contexts = self.repo.list_task_contexts_by_session_key(session_key) if session_key else [] + if not contexts and source_contexts: + contexts = source_contexts + + updated_tasks: list[dict[str, object]] = [] + for context in contexts: + task = self.repo.get_task(context.task_id) + if task is None: + continue + if session_key and (context.session_key.startswith("task:") or context.session_key != session_key): + context.session_key = session_key + full_video_bvid_path = self._persist_task_full_video_bvid(task, context, bvid, now=now) + updated_tasks.append({"task_id": task.id, "path": str(full_video_bvid_path)}) + + self.repo.add_action_record( + ActionRecord( + id=None, + task_id=None, + action_name="webhook_full_video_uploaded", + status="ok", + summary=f"full video webhook received: {bvid}", + details_json=json.dumps( + { + "session_key": session_key, + "source_title": source_title, + "streamer": streamer, + "room_id": room_id, + "updated_count": len(updated_tasks), + }, + ensure_ascii=False, + ), + created_at=now, + ) + ) + return { + "ok": True, + "session_key": session_key, + "source_title": source_title, + "full_video_bvid": bvid, + "updated_count": len(updated_tasks), + "tasks": updated_tasks, + } + + def _normalize_bvid(self, full_video_bvid: str) -> str | None: + bvid = full_video_bvid.strip() + if not re.fullmatch(r"BV[0-9A-Za-z]+", bvid): + return None + return bvid + + def _full_video_bvid_path(self, task) -> Path: # type: ignore[no-untyped-def] + work_dir = resolve_task_work_dir(task) + work_dir.mkdir(parents=True, exist_ok=True) + return work_dir / "full_video_bvid.txt" + + def _upsert_session_binding_for_context(self, context: TaskContext, full_video_bvid: str, now: str) -> None: + self.repo.upsert_session_binding( + SessionBinding( + id=None, + session_key=context.session_key, + source_title=context.source_title, + streamer=context.streamer, + room_id=context.room_id, + full_video_bvid=full_video_bvid, + created_at=now, + updated_at=now, + ) + ) + + def _persist_task_full_video_bvid( + self, + task, + context: TaskContext, + full_video_bvid: str, + *, + now: str, + ) -> Path: # type: ignore[no-untyped-def] + context.full_video_bvid = full_video_bvid + context.updated_at = now + self.repo.upsert_task_context(context) + self._upsert_session_binding_for_context(context, full_video_bvid, now) + path = self._full_video_bvid_path(task) + path.write_text(full_video_bvid, encoding="utf-8") + return path diff --git a/src/biliup_next/app/static/app/actions.js b/src/biliup_next/app/static/app/actions.js index b09c55f..b656e9f 100644 --- a/src/biliup_next/app/static/app/actions.js +++ b/src/biliup_next/app/static/app/actions.js @@ -1,222 +1,222 @@ -import { fetchJson } from "./api.js"; -import { navigate } from "./router.js"; -import { - clearSettingsFieldState, - resetTaskPage, - setLogAutoRefreshTimer, - setSelectedTask, - setTaskPage, - setTaskPageSize, - state, -} from "./state.js"; -import { showBanner, syncSettingsEditorFromState, withButtonBusy } from "./utils.js"; -import { renderSettingsForm } from "./views/settings.js"; -import { renderTasks } from "./views/tasks.js"; - -export function bindActions({ - loadOverview, - loadTaskDetail, - refreshSelectedTaskOnly, - refreshLog, - handleSettingsFieldChange, -}) { - document.querySelectorAll(".nav-btn").forEach((button) => { - button.onclick = () => navigate(button.dataset.view); - }); - - document.getElementById("refreshBtn").onclick = async () => { - await loadOverview(); - showBanner("视图已刷新", "ok"); - }; - - document.getElementById("runOnceBtn").onclick = async () => { - try { - const result = await fetchJson("/worker/run-once", { method: "POST" }); - await loadOverview(); - showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok"); - } catch (err) { - showBanner(String(err), "err"); - } - }; - - document.getElementById("saveSettingsBtn").onclick = async () => { - try { - const payload = JSON.parse(document.getElementById("settingsEditor").value); - await fetchJson("/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - clearSettingsFieldState(); - await loadOverview(); - showBanner("Settings 已保存", "ok"); - } catch (err) { - showBanner(`保存失败: ${err}`, "err"); - } - }; - - document.getElementById("syncFormToJsonBtn").onclick = () => { - syncSettingsEditorFromState(); - showBanner("表单已同步到 JSON", "ok"); - }; - - document.getElementById("syncJsonToFormBtn").onclick = () => { - try { - state.currentSettings = JSON.parse(document.getElementById("settingsEditor").value); - clearSettingsFieldState(); - renderSettingsForm(handleSettingsFieldChange); - showBanner("JSON 已重绘到表单", "ok"); - } catch (err) { - showBanner(`JSON 解析失败: ${err}`, "err"); - } - }; - - document.getElementById("settingsSearch").oninput = () => renderSettingsForm(handleSettingsFieldChange); - document.getElementById("settingsForm").onclick = (event) => { - const button = event.target.closest("button[data-revert-group]"); - if (!button) return; - const group = button.dataset.revertGroup; - const field = button.dataset.revertField; - const originalValue = state.originalSettings[group]?.[field]; - state.currentSettings[group] ??= {}; - if (originalValue === undefined) delete state.currentSettings[group][field]; - else state.currentSettings[group][field] = JSON.parse(JSON.stringify(originalValue)); - clearSettingsFieldState(); - renderSettingsForm(handleSettingsFieldChange); - showBanner(`已撤销 ${group}.${field}`, "ok"); - }; - const rerenderTasks = () => renderTasks(async (taskId) => { - setSelectedTask(taskId); - await loadTaskDetail(taskId); - }); - document.getElementById("taskSearchInput").oninput = () => { resetTaskPage(); rerenderTasks(); }; - document.getElementById("taskStatusFilter").onchange = () => { resetTaskPage(); rerenderTasks(); }; - document.getElementById("taskSortSelect").onchange = () => { resetTaskPage(); rerenderTasks(); }; - document.getElementById("taskDeliveryFilter").onchange = () => { resetTaskPage(); rerenderTasks(); }; - document.getElementById("taskAttentionFilter").onchange = () => { resetTaskPage(); rerenderTasks(); }; - document.getElementById("taskPageSizeSelect").onchange = () => { - setTaskPageSize(Number(document.getElementById("taskPageSizeSelect").value) || 24); - resetTaskPage(); - rerenderTasks(); - }; - document.getElementById("taskPrevPageBtn").onclick = () => { - setTaskPage(Math.max(1, state.taskPage - 1)); - rerenderTasks(); - }; - document.getElementById("taskNextPageBtn").onclick = () => { - setTaskPage(state.taskPage + 1); - rerenderTasks(); - }; - - document.getElementById("importStageBtn").onclick = async () => { - const sourcePath = document.getElementById("stageSourcePath").value.trim(); - if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn"); - try { - const result = await fetchJson("/stage/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_path: sourcePath }), - }); - document.getElementById("stageSourcePath").value = ""; - showBanner(`已导入到 stage: ${result.target_path}`, "ok"); - } catch (err) { - showBanner(`导入失败: ${err}`, "err"); - } - }; - - document.getElementById("uploadStageBtn").onclick = async () => { - const input = document.getElementById("stageFileInput"); - if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn"); - try { - const form = new FormData(); - form.append("file", input.files[0]); - const res = await fetch("/stage/upload", { method: "POST", body: form }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || JSON.stringify(data)); - input.value = ""; - showBanner(`已上传到 stage: ${data.target_path}`, "ok"); - } catch (err) { - showBanner(`上传失败: ${err}`, "err"); - } - }; - - document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); - document.getElementById("logSearchInput").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); - document.getElementById("logLineFilter").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); - document.getElementById("logAutoRefresh").onchange = () => { - if (state.logAutoRefreshTimer) { - clearInterval(state.logAutoRefreshTimer); - setLogAutoRefreshTimer(null); - } - if (document.getElementById("logAutoRefresh").checked) { - const timer = window.setInterval(() => { - refreshLog().catch(() => {}); - }, 5000); - setLogAutoRefreshTimer(timer); - } - }; - document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err")); - document.getElementById("refreshSchedulerBtn").onclick = () => loadOverview().then(() => showBanner("调度队列已刷新", "ok")).catch((err) => showBanner(`调度队列刷新失败: ${err}`, "err")); - - document.getElementById("saveTokenBtn").onclick = async () => { - const token = document.getElementById("tokenInput").value.trim(); - localStorage.setItem("biliup_next_token", token); - try { - await loadOverview(); - showBanner("Token 已保存并生效", "ok"); - } catch (err) { - showBanner(`Token 验证失败: ${err}`, "err"); - } - }; - - document.getElementById("runTaskBtn").onclick = async () => { - if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn"); - await withButtonBusy(document.getElementById("runTaskBtn"), "执行中…", async () => { - try { - const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" }); - await refreshSelectedTaskOnly(state.selectedTaskId); - showBanner(`任务已推进,processed=${result.processed.length}`, "ok"); - } catch (err) { - showBanner(`任务执行失败: ${err}`, "err"); - } - }); - }; - - document.getElementById("retryStepBtn").onclick = async () => { - if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn"); - if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn"); - await withButtonBusy(document.getElementById("retryStepBtn"), "重试中…", async () => { - try { - const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step_name: state.selectedStepName }), - }); - await refreshSelectedTaskOnly(state.selectedTaskId); - showBanner(`已重试 step=${state.selectedStepName},processed=${result.processed.length}`, "ok"); - } catch (err) { - showBanner(`重试失败: ${err}`, "err"); - } - }); - }; - - document.getElementById("resetStepBtn").onclick = async () => { - if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn"); - if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn"); - const ok = window.confirm(`确认重置到 step=${state.selectedStepName} 并清理其后的产物吗?`); - if (!ok) return; - await withButtonBusy(document.getElementById("resetStepBtn"), "重置中…", async () => { - try { - const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step_name: state.selectedStepName }), - }); - await refreshSelectedTaskOnly(state.selectedTaskId); - showBanner(`已重置并重跑 step=${state.selectedStepName},processed=${result.run.processed.length}`, "ok"); - } catch (err) { - showBanner(`重置失败: ${err}`, "err"); - } - }); - }; -} +import { fetchJson } from "./api.js"; +import { navigate } from "./router.js"; +import { + clearSettingsFieldState, + resetTaskPage, + setLogAutoRefreshTimer, + setSelectedTask, + setTaskPage, + setTaskPageSize, + state, +} from "./state.js"; +import { showBanner, syncSettingsEditorFromState, withButtonBusy } from "./utils.js"; +import { renderSettingsForm } from "./views/settings.js"; +import { renderTasks } from "./views/tasks.js"; + +export function bindActions({ + loadOverview, + loadTaskDetail, + refreshSelectedTaskOnly, + refreshLog, + handleSettingsFieldChange, +}) { + document.querySelectorAll(".nav-btn").forEach((button) => { + button.onclick = () => navigate(button.dataset.view); + }); + + document.getElementById("refreshBtn").onclick = async () => { + await loadOverview(); + showBanner("视图已刷新", "ok"); + }; + + document.getElementById("runOnceBtn").onclick = async () => { + try { + const result = await fetchJson("/worker/run-once", { method: "POST" }); + await loadOverview(); + showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok"); + } catch (err) { + showBanner(String(err), "err"); + } + }; + + document.getElementById("saveSettingsBtn").onclick = async () => { + try { + const payload = JSON.parse(document.getElementById("settingsEditor").value); + await fetchJson("/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + clearSettingsFieldState(); + await loadOverview(); + showBanner("Settings 已保存", "ok"); + } catch (err) { + showBanner(`保存失败: ${err}`, "err"); + } + }; + + document.getElementById("syncFormToJsonBtn").onclick = () => { + syncSettingsEditorFromState(); + showBanner("表单已同步到 JSON", "ok"); + }; + + document.getElementById("syncJsonToFormBtn").onclick = () => { + try { + state.currentSettings = JSON.parse(document.getElementById("settingsEditor").value); + clearSettingsFieldState(); + renderSettingsForm(handleSettingsFieldChange); + showBanner("JSON 已重绘到表单", "ok"); + } catch (err) { + showBanner(`JSON 解析失败: ${err}`, "err"); + } + }; + + document.getElementById("settingsSearch").oninput = () => renderSettingsForm(handleSettingsFieldChange); + document.getElementById("settingsForm").onclick = (event) => { + const button = event.target.closest("button[data-revert-group]"); + if (!button) return; + const group = button.dataset.revertGroup; + const field = button.dataset.revertField; + const originalValue = state.originalSettings[group]?.[field]; + state.currentSettings[group] ??= {}; + if (originalValue === undefined) delete state.currentSettings[group][field]; + else state.currentSettings[group][field] = JSON.parse(JSON.stringify(originalValue)); + clearSettingsFieldState(); + renderSettingsForm(handleSettingsFieldChange); + showBanner(`已撤销 ${group}.${field}`, "ok"); + }; + const rerenderTasks = () => renderTasks(async (taskId) => { + setSelectedTask(taskId); + await loadTaskDetail(taskId); + }); + document.getElementById("taskSearchInput").oninput = () => { resetTaskPage(); rerenderTasks(); }; + document.getElementById("taskStatusFilter").onchange = () => { resetTaskPage(); rerenderTasks(); }; + document.getElementById("taskSortSelect").onchange = () => { resetTaskPage(); rerenderTasks(); }; + document.getElementById("taskDeliveryFilter").onchange = () => { resetTaskPage(); rerenderTasks(); }; + document.getElementById("taskAttentionFilter").onchange = () => { resetTaskPage(); rerenderTasks(); }; + document.getElementById("taskPageSizeSelect").onchange = () => { + setTaskPageSize(Number(document.getElementById("taskPageSizeSelect").value) || 24); + resetTaskPage(); + rerenderTasks(); + }; + document.getElementById("taskPrevPageBtn").onclick = () => { + setTaskPage(Math.max(1, state.taskPage - 1)); + rerenderTasks(); + }; + document.getElementById("taskNextPageBtn").onclick = () => { + setTaskPage(state.taskPage + 1); + rerenderTasks(); + }; + + document.getElementById("importStageBtn").onclick = async () => { + const sourcePath = document.getElementById("stageSourcePath").value.trim(); + if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn"); + try { + const result = await fetchJson("/stage/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_path: sourcePath }), + }); + document.getElementById("stageSourcePath").value = ""; + showBanner(`已导入到 stage: ${result.target_path}`, "ok"); + } catch (err) { + showBanner(`导入失败: ${err}`, "err"); + } + }; + + document.getElementById("uploadStageBtn").onclick = async () => { + const input = document.getElementById("stageFileInput"); + if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn"); + try { + const form = new FormData(); + form.append("file", input.files[0]); + const res = await fetch("/stage/upload", { method: "POST", body: form }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || JSON.stringify(data)); + input.value = ""; + showBanner(`已上传到 stage: ${data.target_path}`, "ok"); + } catch (err) { + showBanner(`上传失败: ${err}`, "err"); + } + }; + + document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); + document.getElementById("logSearchInput").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); + document.getElementById("logLineFilter").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); + document.getElementById("logAutoRefresh").onchange = () => { + if (state.logAutoRefreshTimer) { + clearInterval(state.logAutoRefreshTimer); + setLogAutoRefreshTimer(null); + } + if (document.getElementById("logAutoRefresh").checked) { + const timer = window.setInterval(() => { + refreshLog().catch(() => {}); + }, 5000); + setLogAutoRefreshTimer(timer); + } + }; + document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err")); + document.getElementById("refreshSchedulerBtn").onclick = () => loadOverview().then(() => showBanner("调度队列已刷新", "ok")).catch((err) => showBanner(`调度队列刷新失败: ${err}`, "err")); + + document.getElementById("saveTokenBtn").onclick = async () => { + const token = document.getElementById("tokenInput").value.trim(); + localStorage.setItem("biliup_next_token", token); + try { + await loadOverview(); + showBanner("Token 已保存并生效", "ok"); + } catch (err) { + showBanner(`Token 验证失败: ${err}`, "err"); + } + }; + + document.getElementById("runTaskBtn").onclick = async () => { + if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn"); + await withButtonBusy(document.getElementById("runTaskBtn"), "执行中…", async () => { + try { + const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" }); + await refreshSelectedTaskOnly(state.selectedTaskId); + showBanner(`任务已推进,processed=${result.processed.length}`, "ok"); + } catch (err) { + showBanner(`任务执行失败: ${err}`, "err"); + } + }); + }; + + document.getElementById("retryStepBtn").onclick = async () => { + if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn"); + if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn"); + await withButtonBusy(document.getElementById("retryStepBtn"), "重试中…", async () => { + try { + const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step_name: state.selectedStepName }), + }); + await refreshSelectedTaskOnly(state.selectedTaskId); + showBanner(`已重试 step=${state.selectedStepName},processed=${result.processed.length}`, "ok"); + } catch (err) { + showBanner(`重试失败: ${err}`, "err"); + } + }); + }; + + document.getElementById("resetStepBtn").onclick = async () => { + if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn"); + if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn"); + const ok = window.confirm(`确认重置到 step=${state.selectedStepName} 并清理其后的产物吗?`); + if (!ok) return; + await withButtonBusy(document.getElementById("resetStepBtn"), "重置中…", async () => { + try { + const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step_name: state.selectedStepName }), + }); + await refreshSelectedTaskOnly(state.selectedTaskId); + showBanner(`已重置并重跑 step=${state.selectedStepName},processed=${result.run.processed.length}`, "ok"); + } catch (err) { + showBanner(`重置失败: ${err}`, "err"); + } + }); + }; +} diff --git a/src/biliup_next/app/static/app/api.js b/src/biliup_next/app/static/app/api.js index 93606b3..8e0b839 100644 --- a/src/biliup_next/app/static/app/api.js +++ b/src/biliup_next/app/static/app/api.js @@ -1,61 +1,61 @@ -import { state } from "./state.js"; - -export async function fetchJson(url, options) { - const token = localStorage.getItem("biliup_next_token") || ""; - const opts = options ? { ...options } : {}; - opts.headers = { ...(opts.headers || {}) }; - if (token) opts.headers["X-Biliup-Token"] = token; - const res = await fetch(url, opts); - const data = await res.json(); - if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data)); - return data; -} - -export function buildHistoryUrl() { - const params = new URLSearchParams(); - params.set("limit", "20"); - const status = document.getElementById("historyStatusFilter")?.value || ""; - const actionName = document.getElementById("historyActionFilter")?.value.trim() || ""; - const currentOnly = document.getElementById("historyCurrentTask")?.checked; - if (status) params.set("status", status); - if (actionName) params.set("action_name", actionName); - if (currentOnly && state.selectedTaskId) params.set("task_id", state.selectedTaskId); - return `/history?${params.toString()}`; -} - -export async function loadOverviewPayload() { - const historyUrl = buildHistoryUrl(); - const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler] = await Promise.all([ - fetchJson("/health"), - fetchJson("/doctor"), - fetchJson("/tasks?limit=100"), - fetchJson("/modules"), - fetchJson("/settings"), - fetchJson("/settings/schema"), - fetchJson("/runtime/services"), - fetchJson("/logs"), - fetchJson(historyUrl), - fetchJson("/scheduler/preview"), - ]); - return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler }; -} - -export async function loadTasksPayload(limit = 100) { - return fetchJson(`/tasks?limit=${limit}`); -} - -export async function loadTaskPayload(taskId) { - const [task, steps, artifacts, history, timeline, context] = await Promise.all([ - fetchJson(`/tasks/${taskId}`), - fetchJson(`/tasks/${taskId}/steps`), - fetchJson(`/tasks/${taskId}/artifacts`), - fetchJson(`/tasks/${taskId}/history`), - fetchJson(`/tasks/${taskId}/timeline`), - fetchJson(`/tasks/${taskId}/context`).catch(() => null), - ]); - return { task, steps, artifacts, history, timeline, context }; -} - -export async function loadSessionPayload(sessionKey) { - return fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`); -} +import { state } from "./state.js"; + +export async function fetchJson(url, options) { + const token = localStorage.getItem("biliup_next_token") || ""; + const opts = options ? { ...options } : {}; + opts.headers = { ...(opts.headers || {}) }; + if (token) opts.headers["X-Biliup-Token"] = token; + const res = await fetch(url, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data)); + return data; +} + +export function buildHistoryUrl() { + const params = new URLSearchParams(); + params.set("limit", "20"); + const status = document.getElementById("historyStatusFilter")?.value || ""; + const actionName = document.getElementById("historyActionFilter")?.value.trim() || ""; + const currentOnly = document.getElementById("historyCurrentTask")?.checked; + if (status) params.set("status", status); + if (actionName) params.set("action_name", actionName); + if (currentOnly && state.selectedTaskId) params.set("task_id", state.selectedTaskId); + return `/history?${params.toString()}`; +} + +export async function loadOverviewPayload() { + const historyUrl = buildHistoryUrl(); + const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler] = await Promise.all([ + fetchJson("/health"), + fetchJson("/doctor"), + fetchJson("/tasks?limit=100"), + fetchJson("/modules"), + fetchJson("/settings"), + fetchJson("/settings/schema"), + fetchJson("/runtime/services"), + fetchJson("/logs"), + fetchJson(historyUrl), + fetchJson("/scheduler/preview"), + ]); + return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler }; +} + +export async function loadTasksPayload(limit = 100) { + return fetchJson(`/tasks?limit=${limit}`); +} + +export async function loadTaskPayload(taskId) { + const [task, steps, artifacts, history, timeline, context] = await Promise.all([ + fetchJson(`/tasks/${taskId}`), + fetchJson(`/tasks/${taskId}/steps`), + fetchJson(`/tasks/${taskId}/artifacts`), + fetchJson(`/tasks/${taskId}/history`), + fetchJson(`/tasks/${taskId}/timeline`), + fetchJson(`/tasks/${taskId}/context`).catch(() => null), + ]); + return { task, steps, artifacts, history, timeline, context }; +} + +export async function loadSessionPayload(sessionKey) { + return fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`); +} diff --git a/src/biliup_next/app/static/app/components/artifact-list.js b/src/biliup_next/app/static/app/components/artifact-list.js index 4df0078..494dd62 100644 --- a/src/biliup_next/app/static/app/components/artifact-list.js +++ b/src/biliup_next/app/static/app/components/artifact-list.js @@ -1,16 +1,16 @@ -import { escapeHtml, formatDate } from "../utils.js"; - -export function renderArtifactList(artifacts) { - const artifactWrap = document.getElementById("artifactList"); - artifactWrap.innerHTML = ""; - artifacts.items.forEach((artifact) => { - const row = document.createElement("div"); - row.className = "row-card"; - row.innerHTML = ` -
${escapeHtml(artifact.artifact_type)}
-
${escapeHtml(artifact.path)}
-
${escapeHtml(formatDate(artifact.created_at))}
- `; - artifactWrap.appendChild(row); - }); -} +import { escapeHtml, formatDate } from "../utils.js"; + +export function renderArtifactList(artifacts) { + const artifactWrap = document.getElementById("artifactList"); + artifactWrap.innerHTML = ""; + artifacts.items.forEach((artifact) => { + const row = document.createElement("div"); + row.className = "row-card"; + row.innerHTML = ` +
${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 index 20fad3f..39103ca 100644 --- a/src/biliup_next/app/static/app/components/doctor-check-list.js +++ b/src/biliup_next/app/static/app/components/doctor-check-list.js @@ -1,15 +1,15 @@ -import { escapeHtml } from "../utils.js"; - -export function renderDoctor(checks) { - const wrap = document.getElementById("doctorChecks"); - wrap.innerHTML = ""; - for (const check of checks) { - const row = document.createElement("div"); - row.className = "row-card"; - row.innerHTML = ` -
${escapeHtml(check.name)}${check.ok ? "ok" : "fail"}
-
${escapeHtml(check.detail)}
- `; - wrap.appendChild(row); - } -} +import { escapeHtml } from "../utils.js"; + +export function renderDoctor(checks) { + const wrap = document.getElementById("doctorChecks"); + wrap.innerHTML = ""; + for (const check of checks) { + const row = document.createElement("div"); + row.className = "row-card"; + row.innerHTML = ` +
${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 index bf7070d..eb1f03d 100644 --- a/src/biliup_next/app/static/app/components/history-list.js +++ b/src/biliup_next/app/static/app/components/history-list.js @@ -1,23 +1,23 @@ -import { escapeHtml, formatDate, statusClass } from "../utils.js"; - -export function renderHistoryList(history) { - const historyWrap = document.getElementById("historyList"); - historyWrap.innerHTML = ""; - history.items.forEach((item) => { - let details = ""; - try { - details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2); - } catch { - details = item.details_json || ""; - } - const row = document.createElement("div"); - row.className = "row-card"; - row.innerHTML = ` -
${escapeHtml(item.action_name)}${escapeHtml(item.status)}
-
${escapeHtml(item.summary)}
-
${escapeHtml(formatDate(item.created_at))}
-
${escapeHtml(details)}
- `; - historyWrap.appendChild(row); - }); -} +import { escapeHtml, formatDate, statusClass } from "../utils.js"; + +export function renderHistoryList(history) { + const historyWrap = document.getElementById("historyList"); + historyWrap.innerHTML = ""; + history.items.forEach((item) => { + let details = ""; + try { + details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2); + } catch { + details = item.details_json || ""; + } + const row = document.createElement("div"); + row.className = "row-card"; + row.innerHTML = ` +
${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 index d8b33d7..baff064 100644 --- a/src/biliup_next/app/static/app/components/modules-list.js +++ b/src/biliup_next/app/static/app/components/modules-list.js @@ -1,15 +1,15 @@ -import { escapeHtml } from "../utils.js"; - -export function renderModules(items) { - const wrap = document.getElementById("moduleList"); - wrap.innerHTML = ""; - for (const item of items) { - const row = document.createElement("div"); - row.className = "row-card"; - row.innerHTML = ` -
${escapeHtml(item.id)}${escapeHtml(item.provider_type)}
-
${escapeHtml(item.entrypoint)}
- `; - wrap.appendChild(row); - } -} +import { escapeHtml } from "../utils.js"; + +export function renderModules(items) { + const wrap = document.getElementById("moduleList"); + wrap.innerHTML = ""; + for (const item of items) { + const row = document.createElement("div"); + row.className = "row-card"; + row.innerHTML = ` +
${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 index 1f81047..6e66fd2 100644 --- a/src/biliup_next/app/static/app/components/overview-runtime.js +++ b/src/biliup_next/app/static/app/components/overview-runtime.js @@ -1,11 +1,11 @@ -export function renderRuntimeSnapshot({ health, doctor, tasks }) { - const healthText = health.ok ? "OK" : "FAIL"; - const doctorText = doctor.ok ? "OK" : "FAIL"; - document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || ""; - document.getElementById("healthValue").textContent = healthText; - document.getElementById("doctorValue").textContent = doctorText; - document.getElementById("tasksValue").textContent = tasks.items.length; - document.getElementById("overviewHealthValue").textContent = healthText; - document.getElementById("overviewDoctorValue").textContent = doctorText; - document.getElementById("overviewTasksValue").textContent = tasks.items.length; -} +export function renderRuntimeSnapshot({ health, doctor, tasks }) { + const healthText = health.ok ? "OK" : "FAIL"; + const doctorText = doctor.ok ? "OK" : "FAIL"; + document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || ""; + document.getElementById("healthValue").textContent = healthText; + document.getElementById("doctorValue").textContent = doctorText; + document.getElementById("tasksValue").textContent = tasks.items.length; + document.getElementById("overviewHealthValue").textContent = healthText; + document.getElementById("overviewDoctorValue").textContent = doctorText; + document.getElementById("overviewTasksValue").textContent = tasks.items.length; +} 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 index f66129e..72224a0 100644 --- a/src/biliup_next/app/static/app/components/overview-task-summary.js +++ b/src/biliup_next/app/static/app/components/overview-task-summary.js @@ -1,46 +1,46 @@ -import { statusClass } from "../utils.js"; - -export function renderOverviewTaskSummary(tasks) { - const wrap = document.getElementById("overviewTaskSummary"); - if (!wrap) return; - const counts = new Map(); - tasks.forEach((task) => counts.set(task.status, (counts.get(task.status) || 0) + 1)); - const ordered = ["running", "failed_retryable", "failed_manual", "published", "collection_synced", "created", "transcribed", "songs_detected", "split_done"]; - wrap.innerHTML = ""; - ordered.forEach((status) => { - const count = counts.get(status); - if (!count) return; - const pill = document.createElement("div"); - pill.className = `pill ${statusClass(status)}`; - pill.textContent = `${status} ${count}`; - wrap.appendChild(pill); - }); - if (!wrap.children.length) { - const pill = document.createElement("div"); - pill.className = "pill"; - pill.textContent = "no tasks"; - wrap.appendChild(pill); - } -} - -export function renderOverviewRetrySummary(tasks) { - const wrap = document.getElementById("overviewRetrySummary"); - if (!wrap) return; - const waitingRetry = tasks.filter((task) => task.retry_state?.next_retry_at && !task.retry_state?.retry_due); - const dueRetry = tasks.filter((task) => task.retry_state?.retry_due); - const failedManual = tasks.filter((task) => task.status === "failed_manual"); - wrap.innerHTML = ` -
- Waiting Retry -
${waitingRetry.length} 个任务正在等待下一次重试
-
-
- Retry Due -
${dueRetry.length} 个任务已到重试时间
-
-
- Manual Attention -
${failedManual.length} 个任务需要人工处理
-
- `; -} +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 index 278d71c..5fe8a9d 100644 --- a/src/biliup_next/app/static/app/components/recent-actions-list.js +++ b/src/biliup_next/app/static/app/components/recent-actions-list.js @@ -1,19 +1,19 @@ -import { escapeHtml, formatDate, statusClass } from "../utils.js"; - -export function renderRecentActions(items) { - const wrap = document.getElementById("recentActionList"); - wrap.innerHTML = ""; - for (const item of items) { - const row = document.createElement("div"); - row.className = "row-card"; - row.innerHTML = ` -
- ${escapeHtml(item.action_name)} - ${escapeHtml(item.status)} -
-
${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}
-
${escapeHtml(formatDate(item.created_at))}
- `; - wrap.appendChild(row); - } -} +import { escapeHtml, formatDate, statusClass } from "../utils.js"; + +export function renderRecentActions(items) { + const wrap = document.getElementById("recentActionList"); + wrap.innerHTML = ""; + for (const item of items) { + const row = document.createElement("div"); + row.className = "row-card"; + row.innerHTML = ` +
+ ${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 index 4486cd3..9e69cde 100644 --- a/src/biliup_next/app/static/app/components/retry-banner.js +++ b/src/biliup_next/app/static/app/components/retry-banner.js @@ -1,19 +1,19 @@ -import { escapeHtml, formatDate, formatDuration } from "../utils.js"; - -export function renderRetryPanel(task) { - const wrap = document.getElementById("taskRetryPanel"); - const retry = task.retry_state; - if (!retry || !retry.next_retry_at) { - wrap.className = "retry-banner"; - wrap.style.display = "none"; - wrap.textContent = ""; - return; - } - wrap.style.display = "block"; - wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`; - wrap.innerHTML = ` - ${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))}
- `; -} +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 index 195302d..dcfe8b2 100644 --- a/src/biliup_next/app/static/app/components/service-list.js +++ b/src/biliup_next/app/static/app/components/service-list.js @@ -1,27 +1,27 @@ -import { escapeHtml, statusClass } from "../utils.js"; - -export function renderServices(items, onServiceAction) { - const wrap = document.getElementById("serviceList"); - wrap.innerHTML = ""; - for (const item of items) { - const row = document.createElement("div"); - row.className = "service-card"; - row.innerHTML = ` -
- ${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); - }); -} +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/session-panel.js b/src/biliup_next/app/static/app/components/session-panel.js index 8a01323..16856cf 100644 --- a/src/biliup_next/app/static/app/components/session-panel.js +++ b/src/biliup_next/app/static/app/components/session-panel.js @@ -1,70 +1,70 @@ -import { escapeHtml, taskDisplayStatus } from "../utils.js"; - -export function renderSessionPanel(session, actions = {}) { - const wrap = document.getElementById("sessionPanel"); - const stateEl = document.getElementById("sessionWorkspaceState"); - if (!wrap || !stateEl) return; - if (!session) { - stateEl.className = "task-workspace-state show"; - stateEl.textContent = "当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。"; - wrap.innerHTML = ""; - return; - } - - stateEl.className = "task-workspace-state"; - const tasks = session.tasks || []; - wrap.innerHTML = ` -
-
-
Session Key
-
${escapeHtml(session.session_key || "-")}
-
-
- ${escapeHtml(`tasks ${session.task_count || tasks.length || 0}`)} - ${escapeHtml(`full BV ${session.full_video_bvid || "-"}`)} -
-
-
-
-
Session Rebind
- -
- - ${session.full_video_url ? `打开完整版` : ""} -
-
-
-
Merge Tasks
- -
- -
-
适用于同一场直播断流后产生的多个片段。
-
-
-
Session Tasks
-
- ${tasks.map((task) => ` -
-
- ${escapeHtml(task.title)} - ${escapeHtml(taskDisplayStatus(task))} -
-
${escapeHtml(task.session_context?.split_bvid || "-")} · ${escapeHtml(task.session_context?.full_video_bvid || "-")}
-
- `).join("")} -
- `; - - const rebindBtn = document.getElementById("sessionRebindBtn"); - if (rebindBtn) { - rebindBtn.onclick = () => actions.onRebind?.(session.session_key, document.getElementById("sessionRebindInput")?.value || ""); - } - const mergeBtn = document.getElementById("sessionMergeBtn"); - if (mergeBtn) { - mergeBtn.onclick = () => actions.onMerge?.(session.session_key, document.getElementById("sessionMergeInput")?.value || ""); - } - wrap.querySelectorAll("[data-session-task-id]").forEach((node) => { - node.onclick = () => actions.onSelectTask?.(node.dataset.sessionTaskId); - }); -} +import { escapeHtml, taskDisplayStatus } from "../utils.js"; + +export function renderSessionPanel(session, actions = {}) { + const wrap = document.getElementById("sessionPanel"); + const stateEl = document.getElementById("sessionWorkspaceState"); + if (!wrap || !stateEl) return; + if (!session) { + stateEl.className = "task-workspace-state show"; + stateEl.textContent = "当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。"; + wrap.innerHTML = ""; + return; + } + + stateEl.className = "task-workspace-state"; + const tasks = session.tasks || []; + wrap.innerHTML = ` +
+
+
Session Key
+
${escapeHtml(session.session_key || "-")}
+
+
+ ${escapeHtml(`tasks ${session.task_count || tasks.length || 0}`)} + ${escapeHtml(`full BV ${session.full_video_bvid || "-"}`)} +
+
+
+
+
Session Rebind
+ +
+ + ${session.full_video_url ? `打开完整版` : ""} +
+
+
+
Merge Tasks
+ +
+ +
+
适用于同一场直播断流后产生的多个片段。
+
+
+
Session Tasks
+
+ ${tasks.map((task) => ` +
+
+ ${escapeHtml(task.title)} + ${escapeHtml(taskDisplayStatus(task))} +
+
${escapeHtml(task.session_context?.split_bvid || "-")} · ${escapeHtml(task.session_context?.full_video_bvid || "-")}
+
+ `).join("")} +
+ `; + + const rebindBtn = document.getElementById("sessionRebindBtn"); + if (rebindBtn) { + rebindBtn.onclick = () => actions.onRebind?.(session.session_key, document.getElementById("sessionRebindInput")?.value || ""); + } + const mergeBtn = document.getElementById("sessionMergeBtn"); + if (mergeBtn) { + mergeBtn.onclick = () => actions.onMerge?.(session.session_key, document.getElementById("sessionMergeInput")?.value || ""); + } + wrap.querySelectorAll("[data-session-task-id]").forEach((node) => { + node.onclick = () => actions.onSelectTask?.(node.dataset.sessionTaskId); + }); +} diff --git a/src/biliup_next/app/static/app/components/step-list.js b/src/biliup_next/app/static/app/components/step-list.js index 6f1a621..4881e41 100644 --- a/src/biliup_next/app/static/app/components/step-list.js +++ b/src/biliup_next/app/static/app/components/step-list.js @@ -1,34 +1,34 @@ -import { state } from "../state.js"; -import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js"; - -export function renderStepList(steps, onStepSelect) { - const stepWrap = document.getElementById("stepList"); - stepWrap.innerHTML = ""; - steps.items.forEach((step) => { - const row = document.createElement("div"); - row.className = `row-card ${state.selectedStepName === step.step_name ? "active" : ""}`; - row.style.cursor = "pointer"; - const retryBlock = step.next_retry_at ? ` -
-
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); - }); -} +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 index 2e51b8e..38fdec3 100644 --- a/src/biliup_next/app/static/app/components/task-hero.js +++ b/src/biliup_next/app/static/app/components/task-hero.js @@ -1,41 +1,41 @@ -import { escapeHtml, statusClass } from "../utils.js"; - -function displayTaskStatus(task) { - if (task.status === "failed_manual") return "需人工处理"; - if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见"; - if (task.status === "failed_retryable") return "等待自动重试"; - return { - created: "已接收", - transcribed: "已转录", - songs_detected: "已识歌", - split_done: "已切片", - published: "已上传", - collection_synced: "已完成", - running: "处理中", - }[task.status] || task.status || "-"; -} - -export function renderTaskHero(task, steps) { - const wrap = document.getElementById("taskHero"); - const succeeded = steps.items.filter((step) => step.status === "succeeded").length; - const running = steps.items.filter((step) => step.status === "running").length; - const failed = steps.items.filter((step) => step.status.startsWith("failed")).length; - const delivery = task.delivery_state || {}; - const sessionContext = task.session_context || {}; - wrap.className = "task-hero"; - wrap.innerHTML = ` -
${escapeHtml(task.title)}
-
${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}
-
-
Task Status
${escapeHtml(displayTaskStatus(task))}
-
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"} -
-
- session=${escapeHtml(sessionContext.session_key || "-")} · split_bv=${escapeHtml(sessionContext.split_bvid || "-")} · full_bv=${escapeHtml(sessionContext.full_video_bvid || "-")} -
- `; -} +import { escapeHtml, statusClass } from "../utils.js"; + +function displayTaskStatus(task) { + if (task.status === "failed_manual") return "需人工处理"; + if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见"; + if (task.status === "failed_retryable") return "等待自动重试"; + return { + created: "已接收", + transcribed: "已转录", + songs_detected: "已识歌", + split_done: "已切片", + published: "已上传", + collection_synced: "已完成", + running: "处理中", + }[task.status] || task.status || "-"; +} + +export function renderTaskHero(task, steps) { + const wrap = document.getElementById("taskHero"); + const succeeded = steps.items.filter((step) => step.status === "succeeded").length; + const running = steps.items.filter((step) => step.status === "running").length; + const failed = steps.items.filter((step) => step.status.startsWith("failed")).length; + const delivery = task.delivery_state || {}; + const sessionContext = task.session_context || {}; + wrap.className = "task-hero"; + wrap.innerHTML = ` +
${escapeHtml(task.title)}
+
${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}
+
+
Task Status
${escapeHtml(displayTaskStatus(task))}
+
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"} +
+
+ session=${escapeHtml(sessionContext.session_key || "-")} · split_bv=${escapeHtml(sessionContext.split_bvid || "-")} · full_bv=${escapeHtml(sessionContext.full_video_bvid || "-")} +
+ `; +} diff --git a/src/biliup_next/app/static/app/components/timeline-list.js b/src/biliup_next/app/static/app/components/timeline-list.js index 0b4edbe..0eaa1e1 100644 --- a/src/biliup_next/app/static/app/components/timeline-list.js +++ b/src/biliup_next/app/static/app/components/timeline-list.js @@ -1,22 +1,22 @@ -import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js"; - -export function renderTimelineList(timeline) { - const timelineWrap = document.getElementById("timelineList"); - timelineWrap.innerHTML = ""; - timeline.items.forEach((item) => { - const retryNote = item.retry_state?.next_retry_at - ? `
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); - }); -} +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 index 8dce6ea..794b6d7 100644 --- a/src/biliup_next/app/static/app/main.js +++ b/src/biliup_next/app/static/app/main.js @@ -1,320 +1,320 @@ -import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } from "./api.js"; -import { bindActions } from "./actions.js"; -import { currentRoute, initRouter, navigate } from "./router.js"; -import { - clearSettingsFieldState, - markSettingsFieldDirty, - setOverviewData, - setSettingsFieldError, - setLogs, - setLogListLoading, - setSelectedLog, - setSelectedStep, - setSelectedTask, - setCurrentSession, - setTaskDetailStatus, - setTaskListLoading, - state, -} from "./state.js"; -import { settingsFieldKey, showBanner, withButtonBusy } from "./utils.js"; -import { - renderDoctor, - renderModules, - renderRecentActions, - renderSchedulerQueue, - renderServices, - renderShellStats, -} from "./views/overview.js"; -import { renderLogContent, renderLogsList } from "./views/logs.js"; -import { renderSettingsForm } from "./views/settings.js"; -import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js"; -import { renderSessionPanel } from "./components/session-panel.js"; - -async function refreshLog() { - const name = state.selectedLogName; - if (!name) return; - let url = `/logs?name=${encodeURIComponent(name)}&lines=200`; - if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) { - const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId); - if (currentTask?.title) { - url += `&contains=${encodeURIComponent(currentTask.title)}`; - } - } - const payload = await fetchJson(url); - renderLogContent(payload); -} - -async function selectLog(name) { - setSelectedLog(name); - renderLogsList(state.currentLogs, refreshLog, selectLog); - await refreshLog(); -} - -async function loadTaskDetail(taskId) { - setTaskDetailStatus("loading"); - renderTaskWorkspaceState("loading"); - try { - const payload = await loadTaskPayload(taskId); - renderTaskDetail(payload, async (stepName) => { - setSelectedStep(stepName); - await loadTaskDetail(taskId); - }, { - onBindFullVideo: async (currentTaskId, fullVideoBvid) => { - const button = document.getElementById("bindFullVideoBtn"); - const bvid = String(fullVideoBvid || "").trim(); - if (!/^BV[0-9A-Za-z]+$/.test(bvid)) { - showBanner("请输入合法的 BV 号", "warn"); - return; - } - await withButtonBusy(button, "绑定中…", async () => { - try { - await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ full_video_bvid: bvid }), - }); - await refreshSelectedTaskOnly(currentTaskId); - showBanner(`已绑定完整版 BV: ${bvid}`, "ok"); - } catch (err) { - showBanner(`绑定完整版失败: ${err}`, "err"); - } - }); - }, - onOpenSession: async (sessionKey) => { - if (!sessionKey) { - showBanner("当前任务没有可用的 session_key", "warn"); - return; - } - try { - await loadSessionDetail(sessionKey); - } catch (err) { - showBanner(`读取 Session 失败: ${err}`, "err"); - } - }, - }); - await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null); - setTaskDetailStatus("ready"); - renderTaskWorkspaceState("ready"); - } catch (err) { - const message = `任务详情加载失败: ${err}`; - setTaskDetailStatus("error", message); - renderTaskWorkspaceState("error", message); - throw err; - } -} - -async function loadSessionDetail(sessionKey) { - if (!sessionKey) { - setCurrentSession(null); - renderSessionPanel(null); - return; - } - const session = await loadSessionPayload(sessionKey); - setCurrentSession(session); - renderSessionPanel(session, { - onSelectTask: async (taskId) => { - if (!taskId) return; - taskSelectHandler(taskId); - }, - onRebind: async (currentSessionKey, fullVideoBvid) => { - const button = document.getElementById("sessionRebindBtn"); - const bvid = String(fullVideoBvid || "").trim(); - if (!/^BV[0-9A-Za-z]+$/.test(bvid)) { - showBanner("请输入合法的 BV 号", "warn"); - return; - } - await withButtonBusy(button, "重绑中…", async () => { - try { - await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ full_video_bvid: bvid }), - }); - await refreshSelectedTaskOnly(); - showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok"); - } catch (err) { - showBanner(`Session 重绑失败: ${err}`, "err"); - } - }); - }, - onMerge: async (currentSessionKey, rawTaskIds) => { - const button = document.getElementById("sessionMergeBtn"); - const taskIds = String(rawTaskIds || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - if (!taskIds.length) { - showBanner("请先输入至少一个 task id", "warn"); - return; - } - await withButtonBusy(button, "合并中…", async () => { - try { - await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ task_ids: taskIds }), - }); - await refreshSelectedTaskOnly(); - showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok"); - } catch (err) { - showBanner(`Session 合并失败: ${err}`, "err"); - } - }); - }, - }); -} - -async function refreshTaskListOnly() { - const payload = await loadTasksPayload(100); - state.currentTasks = payload.items || []; - renderTasks(taskSelectHandler, taskRowActionHandler); -} - -async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) { - if (!taskId) return; - await refreshTaskListOnly(); - await loadTaskDetail(taskId); -} - -function taskSelectHandler(taskId) { - setSelectedTask(taskId); - setSelectedStep(null); - navigate("tasks", taskId); - renderTasks(taskSelectHandler, taskRowActionHandler); - return loadTaskDetail(taskId); -} - -async function taskRowActionHandler(action, taskId) { - if (action !== "run") return; - try { - const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" }); - await refreshSelectedTaskOnly(taskId); - showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok"); - } catch (err) { - showBanner(`任务执行失败: ${err}`, "err"); - } -} - -function handleSettingsFieldChange(event) { - const input = event.target; - const group = input.dataset.group; - const field = input.dataset.field; - const fieldSchema = state.currentSettingsSchema.groups[group][field]; - const key = settingsFieldKey(group, field); - let value; - if (fieldSchema.type === "boolean") value = input.checked; - else if (fieldSchema.type === "integer") { - value = Number(input.value); - if (input.value === "" || Number.isNaN(value)) { - state.currentSettings[group] ??= {}; - state.currentSettings[group][field] = input.value; - markSettingsFieldDirty(key, true); - setSettingsFieldError(key, "必须填写整数"); - renderSettingsForm(handleSettingsFieldChange); - return; - } - } - else if (fieldSchema.type === "array") { - try { - value = JSON.parse(input.value || "[]"); - if (!Array.isArray(value)) throw new Error("not array"); - } catch { - markSettingsFieldDirty(key, true); - setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`); - renderSettingsForm(handleSettingsFieldChange); - return; - } - } else value = input.value; - if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) { - markSettingsFieldDirty(key, true); - setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`); - renderSettingsForm(handleSettingsFieldChange); - return; - } - markSettingsFieldDirty(key, true); - setSettingsFieldError(key, ""); - if (!state.currentSettings[group]) state.currentSettings[group] = {}; - state.currentSettings[group][field] = value; - document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2); - renderSettingsForm(handleSettingsFieldChange); -} - -async function loadOverview() { - setTaskListLoading(true); - setLogListLoading(true); - renderTasks(taskSelectHandler, taskRowActionHandler); - const payload = await loadOverviewPayload(); - setOverviewData({ - tasks: payload.tasks.items, - settings: payload.settings, - settingsSchema: payload.settingsSchema, - }); - clearSettingsFieldState(); - setTaskListLoading(false); - renderShellStats(payload); - renderSettingsForm(handleSettingsFieldChange); - renderTasks(taskSelectHandler, taskRowActionHandler); - renderModules(payload.modules.items); - renderDoctor(payload.doctor.checks); - renderSchedulerQueue(payload.scheduler); - renderServices(payload.services.items, async (serviceId, action) => { - if (["stop", "restart"].includes(action)) { - const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`); - if (!ok) return; - } - try { - const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" }); - await loadOverview(); - showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn"); - } catch (err) { - showBanner(`service 操作失败: ${err}`, "err"); - } - }); - setLogs(payload.logs.items); - setLogListLoading(false); - if (!state.selectedLogName && payload.logs.items.length) { - setSelectedLog(payload.logs.items[0].name); - } - renderLogsList(payload.logs.items, refreshLog, selectLog); - renderRecentActions(payload.history.items); - const route = currentRoute(); - const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId); - if (route.view === "tasks" && routeTaskExists) { - setSelectedTask(route.taskId); - } else if (!state.selectedTaskId && state.currentTasks.length) { - setSelectedTask(state.currentTasks[0].id); - } - if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId); - else { - setTaskDetailStatus("idle"); - renderTaskWorkspaceState("idle"); - } -} - -async function handleRouteChange(route) { - if (route.view !== "tasks") return; - if (!route.taskId) { - if (state.selectedTaskId) navigate("tasks", state.selectedTaskId); - return; - } - if (!state.currentTasks.length) return; - if (!state.currentTasks.some((item) => item.id === route.taskId)) return; - if (state.selectedTaskId !== route.taskId) { - setSelectedTask(route.taskId); - setSelectedStep(null); - renderTasks(taskSelectHandler, taskRowActionHandler); - await loadTaskDetail(route.taskId); - } -} - -bindActions({ - loadOverview, - loadTaskDetail, - refreshSelectedTaskOnly, - refreshLog, - handleSettingsFieldChange, -}); -initRouter((route) => { - handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err")); -}); -loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err")); +import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } from "./api.js"; +import { bindActions } from "./actions.js"; +import { currentRoute, initRouter, navigate } from "./router.js"; +import { + clearSettingsFieldState, + markSettingsFieldDirty, + setOverviewData, + setSettingsFieldError, + setLogs, + setLogListLoading, + setSelectedLog, + setSelectedStep, + setSelectedTask, + setCurrentSession, + setTaskDetailStatus, + setTaskListLoading, + state, +} from "./state.js"; +import { settingsFieldKey, showBanner, withButtonBusy } from "./utils.js"; +import { + renderDoctor, + renderModules, + renderRecentActions, + renderSchedulerQueue, + renderServices, + renderShellStats, +} from "./views/overview.js"; +import { renderLogContent, renderLogsList } from "./views/logs.js"; +import { renderSettingsForm } from "./views/settings.js"; +import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js"; +import { renderSessionPanel } from "./components/session-panel.js"; + +async function refreshLog() { + const name = state.selectedLogName; + if (!name) return; + let url = `/logs?name=${encodeURIComponent(name)}&lines=200`; + if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) { + const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId); + if (currentTask?.title) { + url += `&contains=${encodeURIComponent(currentTask.title)}`; + } + } + const payload = await fetchJson(url); + renderLogContent(payload); +} + +async function selectLog(name) { + setSelectedLog(name); + renderLogsList(state.currentLogs, refreshLog, selectLog); + await refreshLog(); +} + +async function loadTaskDetail(taskId) { + setTaskDetailStatus("loading"); + renderTaskWorkspaceState("loading"); + try { + const payload = await loadTaskPayload(taskId); + renderTaskDetail(payload, async (stepName) => { + setSelectedStep(stepName); + await loadTaskDetail(taskId); + }, { + onBindFullVideo: async (currentTaskId, fullVideoBvid) => { + const button = document.getElementById("bindFullVideoBtn"); + const bvid = String(fullVideoBvid || "").trim(); + if (!/^BV[0-9A-Za-z]+$/.test(bvid)) { + showBanner("请输入合法的 BV 号", "warn"); + return; + } + await withButtonBusy(button, "绑定中…", async () => { + try { + await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_video_bvid: bvid }), + }); + await refreshSelectedTaskOnly(currentTaskId); + showBanner(`已绑定完整版 BV: ${bvid}`, "ok"); + } catch (err) { + showBanner(`绑定完整版失败: ${err}`, "err"); + } + }); + }, + onOpenSession: async (sessionKey) => { + if (!sessionKey) { + showBanner("当前任务没有可用的 session_key", "warn"); + return; + } + try { + await loadSessionDetail(sessionKey); + } catch (err) { + showBanner(`读取 Session 失败: ${err}`, "err"); + } + }, + }); + await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null); + setTaskDetailStatus("ready"); + renderTaskWorkspaceState("ready"); + } catch (err) { + const message = `任务详情加载失败: ${err}`; + setTaskDetailStatus("error", message); + renderTaskWorkspaceState("error", message); + throw err; + } +} + +async function loadSessionDetail(sessionKey) { + if (!sessionKey) { + setCurrentSession(null); + renderSessionPanel(null); + return; + } + const session = await loadSessionPayload(sessionKey); + setCurrentSession(session); + renderSessionPanel(session, { + onSelectTask: async (taskId) => { + if (!taskId) return; + taskSelectHandler(taskId); + }, + onRebind: async (currentSessionKey, fullVideoBvid) => { + const button = document.getElementById("sessionRebindBtn"); + const bvid = String(fullVideoBvid || "").trim(); + if (!/^BV[0-9A-Za-z]+$/.test(bvid)) { + showBanner("请输入合法的 BV 号", "warn"); + return; + } + await withButtonBusy(button, "重绑中…", async () => { + try { + await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_video_bvid: bvid }), + }); + await refreshSelectedTaskOnly(); + showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok"); + } catch (err) { + showBanner(`Session 重绑失败: ${err}`, "err"); + } + }); + }, + onMerge: async (currentSessionKey, rawTaskIds) => { + const button = document.getElementById("sessionMergeBtn"); + const taskIds = String(rawTaskIds || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (!taskIds.length) { + showBanner("请先输入至少一个 task id", "warn"); + return; + } + await withButtonBusy(button, "合并中…", async () => { + try { + await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ task_ids: taskIds }), + }); + await refreshSelectedTaskOnly(); + showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok"); + } catch (err) { + showBanner(`Session 合并失败: ${err}`, "err"); + } + }); + }, + }); +} + +async function refreshTaskListOnly() { + const payload = await loadTasksPayload(100); + state.currentTasks = payload.items || []; + renderTasks(taskSelectHandler, taskRowActionHandler); +} + +async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) { + if (!taskId) return; + await refreshTaskListOnly(); + await loadTaskDetail(taskId); +} + +function taskSelectHandler(taskId) { + setSelectedTask(taskId); + setSelectedStep(null); + navigate("tasks", taskId); + renderTasks(taskSelectHandler, taskRowActionHandler); + return loadTaskDetail(taskId); +} + +async function taskRowActionHandler(action, taskId) { + if (action !== "run") return; + try { + const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" }); + await refreshSelectedTaskOnly(taskId); + showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok"); + } catch (err) { + showBanner(`任务执行失败: ${err}`, "err"); + } +} + +function handleSettingsFieldChange(event) { + const input = event.target; + const group = input.dataset.group; + const field = input.dataset.field; + const fieldSchema = state.currentSettingsSchema.groups[group][field]; + const key = settingsFieldKey(group, field); + let value; + if (fieldSchema.type === "boolean") value = input.checked; + else if (fieldSchema.type === "integer") { + value = Number(input.value); + if (input.value === "" || Number.isNaN(value)) { + state.currentSettings[group] ??= {}; + state.currentSettings[group][field] = input.value; + markSettingsFieldDirty(key, true); + setSettingsFieldError(key, "必须填写整数"); + renderSettingsForm(handleSettingsFieldChange); + return; + } + } + else if (fieldSchema.type === "array") { + try { + value = JSON.parse(input.value || "[]"); + if (!Array.isArray(value)) throw new Error("not array"); + } catch { + markSettingsFieldDirty(key, true); + setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`); + renderSettingsForm(handleSettingsFieldChange); + return; + } + } else value = input.value; + if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) { + markSettingsFieldDirty(key, true); + setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`); + renderSettingsForm(handleSettingsFieldChange); + return; + } + markSettingsFieldDirty(key, true); + setSettingsFieldError(key, ""); + if (!state.currentSettings[group]) state.currentSettings[group] = {}; + state.currentSettings[group][field] = value; + document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2); + renderSettingsForm(handleSettingsFieldChange); +} + +async function loadOverview() { + setTaskListLoading(true); + setLogListLoading(true); + renderTasks(taskSelectHandler, taskRowActionHandler); + const payload = await loadOverviewPayload(); + setOverviewData({ + tasks: payload.tasks.items, + settings: payload.settings, + settingsSchema: payload.settingsSchema, + }); + clearSettingsFieldState(); + setTaskListLoading(false); + renderShellStats(payload); + renderSettingsForm(handleSettingsFieldChange); + renderTasks(taskSelectHandler, taskRowActionHandler); + renderModules(payload.modules.items); + renderDoctor(payload.doctor.checks); + renderSchedulerQueue(payload.scheduler); + renderServices(payload.services.items, async (serviceId, action) => { + if (["stop", "restart"].includes(action)) { + const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`); + if (!ok) return; + } + try { + const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" }); + await loadOverview(); + showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn"); + } catch (err) { + showBanner(`service 操作失败: ${err}`, "err"); + } + }); + setLogs(payload.logs.items); + setLogListLoading(false); + if (!state.selectedLogName && payload.logs.items.length) { + setSelectedLog(payload.logs.items[0].name); + } + renderLogsList(payload.logs.items, refreshLog, selectLog); + renderRecentActions(payload.history.items); + const route = currentRoute(); + const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId); + if (route.view === "tasks" && routeTaskExists) { + setSelectedTask(route.taskId); + } else if (!state.selectedTaskId && state.currentTasks.length) { + setSelectedTask(state.currentTasks[0].id); + } + if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId); + else { + setTaskDetailStatus("idle"); + renderTaskWorkspaceState("idle"); + } +} + +async function handleRouteChange(route) { + if (route.view !== "tasks") return; + if (!route.taskId) { + if (state.selectedTaskId) navigate("tasks", state.selectedTaskId); + return; + } + if (!state.currentTasks.length) return; + if (!state.currentTasks.some((item) => item.id === route.taskId)) return; + if (state.selectedTaskId !== route.taskId) { + setSelectedTask(route.taskId); + setSelectedStep(null); + renderTasks(taskSelectHandler, taskRowActionHandler); + await loadTaskDetail(route.taskId); + } +} + +bindActions({ + loadOverview, + loadTaskDetail, + refreshSelectedTaskOnly, + refreshLog, + handleSettingsFieldChange, +}); +initRouter((route) => { + handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err")); +}); +loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err")); diff --git a/src/biliup_next/app/static/app/render.js b/src/biliup_next/app/static/app/render.js index 395e874..e01441e 100644 --- a/src/biliup_next/app/static/app/render.js +++ b/src/biliup_next/app/static/app/render.js @@ -1,18 +1,18 @@ -import { setView } from "./state.js"; - -export function renderView(view) { - setView(view); - document.querySelectorAll(".nav-btn").forEach((button) => { - button.classList.toggle("active", button.dataset.view === view); - }); - document.querySelectorAll(".view").forEach((section) => { - section.classList.toggle("active", section.dataset.view === view); - }); - const titleMap = { - overview: "Overview", - tasks: "Tasks", - settings: "Settings", - logs: "Logs", - }; - document.getElementById("viewTitle").textContent = titleMap[view] || "Control"; -} +import { setView } from "./state.js"; + +export function renderView(view) { + setView(view); + document.querySelectorAll(".nav-btn").forEach((button) => { + button.classList.toggle("active", button.dataset.view === view); + }); + document.querySelectorAll(".view").forEach((section) => { + section.classList.toggle("active", section.dataset.view === view); + }); + const titleMap = { + overview: "Overview", + tasks: "Tasks", + settings: "Settings", + logs: "Logs", + }; + document.getElementById("viewTitle").textContent = titleMap[view] || "Control"; +} diff --git a/src/biliup_next/app/static/app/router.js b/src/biliup_next/app/static/app/router.js index 2717f2e..56e077e 100644 --- a/src/biliup_next/app/static/app/router.js +++ b/src/biliup_next/app/static/app/router.js @@ -1,22 +1,22 @@ -import { renderView } from "./render.js"; - -export function currentRoute() { - const raw = window.location.hash.replace(/^#/, "") || "overview"; - const [view = "overview", ...rest] = raw.split("/"); - const taskId = rest.length ? decodeURIComponent(rest.join("/")) : null; - return { view: view || "overview", taskId }; -} - -export function navigate(view, taskId = null) { - window.location.hash = taskId ? `${view}/${encodeURIComponent(taskId)}` : view; -} - -export function initRouter(onRouteChange) { - const sync = () => { - const route = currentRoute(); - renderView(route.view); - if (onRouteChange) onRouteChange(route); - }; - window.addEventListener("hashchange", sync); - sync(); -} +import { renderView } from "./render.js"; + +export function currentRoute() { + const raw = window.location.hash.replace(/^#/, "") || "overview"; + const [view = "overview", ...rest] = raw.split("/"); + const taskId = rest.length ? decodeURIComponent(rest.join("/")) : null; + return { view: view || "overview", taskId }; +} + +export function navigate(view, taskId = null) { + window.location.hash = taskId ? `${view}/${encodeURIComponent(taskId)}` : view; +} + +export function initRouter(onRouteChange) { + const sync = () => { + const route = currentRoute(); + renderView(route.view); + if (onRouteChange) onRouteChange(route); + }; + window.addEventListener("hashchange", sync); + sync(); +} diff --git a/src/biliup_next/app/static/app/state.js b/src/biliup_next/app/static/app/state.js index 3272898..49dc069 100644 --- a/src/biliup_next/app/static/app/state.js +++ b/src/biliup_next/app/static/app/state.js @@ -1,96 +1,96 @@ -export const state = { - selectedTaskId: null, - selectedStepName: null, - currentTasks: [], - currentSettings: {}, - originalSettings: {}, - currentSettingsSchema: null, - settingsDirtyFields: {}, - settingsFieldErrors: {}, - currentView: "overview", - taskPage: 1, - taskPageSize: 24, - taskListLoading: true, - taskDetailStatus: "idle", - taskDetailError: "", - currentSession: null, - currentLogs: [], - selectedLogName: null, - logListLoading: true, - logAutoRefreshTimer: null, -}; - -export function setView(view) { - state.currentView = view; -} - -export function setSelectedTask(taskId) { - state.selectedTaskId = taskId; -} - -export function setSelectedStep(stepName) { - state.selectedStepName = stepName; -} - -export function setOverviewData({ tasks, settings, settingsSchema }) { - state.currentTasks = tasks; - state.currentSettings = settings; - state.originalSettings = JSON.parse(JSON.stringify(settings || {})); - state.currentSettingsSchema = settingsSchema; -} - -export function markSettingsFieldDirty(key, dirty = true) { - if (dirty) state.settingsDirtyFields[key] = true; - else delete state.settingsDirtyFields[key]; -} - -export function setSettingsFieldError(key, message = "") { - if (message) state.settingsFieldErrors[key] = message; - else delete state.settingsFieldErrors[key]; -} - -export function clearSettingsFieldState() { - state.settingsDirtyFields = {}; - state.settingsFieldErrors = {}; -} - -export function setTaskPage(page) { - state.taskPage = page; -} - -export function setTaskPageSize(size) { - state.taskPageSize = size; -} - -export function resetTaskPage() { - state.taskPage = 1; -} - -export function setTaskListLoading(loading) { - state.taskListLoading = loading; -} - -export function setTaskDetailStatus(status, error = "") { - state.taskDetailStatus = status; - state.taskDetailError = error; -} - -export function setCurrentSession(session) { - state.currentSession = session; -} - -export function setLogs(logs) { - state.currentLogs = logs; -} - -export function setSelectedLog(name) { - state.selectedLogName = name; -} - -export function setLogListLoading(loading) { - state.logListLoading = loading; -} - -export function setLogAutoRefreshTimer(timerId) { - state.logAutoRefreshTimer = timerId; -} +export const state = { + selectedTaskId: null, + selectedStepName: null, + currentTasks: [], + currentSettings: {}, + originalSettings: {}, + currentSettingsSchema: null, + settingsDirtyFields: {}, + settingsFieldErrors: {}, + currentView: "overview", + taskPage: 1, + taskPageSize: 24, + taskListLoading: true, + taskDetailStatus: "idle", + taskDetailError: "", + currentSession: null, + currentLogs: [], + selectedLogName: null, + logListLoading: true, + logAutoRefreshTimer: null, +}; + +export function setView(view) { + state.currentView = view; +} + +export function setSelectedTask(taskId) { + state.selectedTaskId = taskId; +} + +export function setSelectedStep(stepName) { + state.selectedStepName = stepName; +} + +export function setOverviewData({ tasks, settings, settingsSchema }) { + state.currentTasks = tasks; + state.currentSettings = settings; + state.originalSettings = JSON.parse(JSON.stringify(settings || {})); + state.currentSettingsSchema = settingsSchema; +} + +export function markSettingsFieldDirty(key, dirty = true) { + if (dirty) state.settingsDirtyFields[key] = true; + else delete state.settingsDirtyFields[key]; +} + +export function setSettingsFieldError(key, message = "") { + if (message) state.settingsFieldErrors[key] = message; + else delete state.settingsFieldErrors[key]; +} + +export function clearSettingsFieldState() { + state.settingsDirtyFields = {}; + state.settingsFieldErrors = {}; +} + +export function setTaskPage(page) { + state.taskPage = page; +} + +export function setTaskPageSize(size) { + state.taskPageSize = size; +} + +export function resetTaskPage() { + state.taskPage = 1; +} + +export function setTaskListLoading(loading) { + state.taskListLoading = loading; +} + +export function setTaskDetailStatus(status, error = "") { + state.taskDetailStatus = status; + state.taskDetailError = error; +} + +export function setCurrentSession(session) { + state.currentSession = session; +} + +export function setLogs(logs) { + state.currentLogs = logs; +} + +export function setSelectedLog(name) { + state.selectedLogName = name; +} + +export function setLogListLoading(loading) { + state.logListLoading = loading; +} + +export function setLogAutoRefreshTimer(timerId) { + state.logAutoRefreshTimer = timerId; +} diff --git a/src/biliup_next/app/static/app/utils.js b/src/biliup_next/app/static/app/utils.js index badbbb3..c7e7c48 100644 --- a/src/biliup_next/app/static/app/utils.js +++ b/src/biliup_next/app/static/app/utils.js @@ -1,157 +1,157 @@ -import { state } from "./state.js"; - -let bannerTimer = null; - -export function statusClass(status) { - if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good"; - if (["done", "resolved", "present"].includes(status)) return "good"; - if (["pending", "unresolved"].includes(status)) return "warn"; - if (["removed", "disabled"].includes(status)) return ""; - if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot"; - if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn"; - return ""; -} - -export function showBanner(message, kind) { - const el = document.getElementById("banner"); - el.textContent = message; - el.className = `banner show ${kind}`; - if (bannerTimer) window.clearTimeout(bannerTimer); - bannerTimer = window.setTimeout(() => { - el.className = "banner"; - el.textContent = ""; - }, kind === "err" ? 6000 : 3200); -} - -export function escapeHtml(text) { - return String(text) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -export function formatDate(value) { - if (!value) return "-"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString("zh-CN", { hour12: false }); -} - -export function formatDuration(seconds) { - if (seconds == null || Number.isNaN(Number(seconds))) return "-"; - const total = Math.max(0, Number(seconds)); - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = total % 60; - if (h > 0) return `${h}h ${m}m ${s}s`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; -} - -export function syncSettingsEditorFromState() { - document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2); -} - -export function getGroupOrder(groupName) { - return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999); -} - -export function compareFieldEntries(a, b) { - const orderA = Number(a[1].ui_order || 9999); - const orderB = Number(b[1].ui_order || 9999); - if (orderA !== orderB) return orderA - orderB; - return String(a[0]).localeCompare(String(b[0])); -} - -export function settingsFieldKey(group, field) { - return `${group}.${field}`; -} - -export function taskDisplayStatus(task) { - if (!task) return "-"; - if (task.status === "failed_manual") return "需人工处理"; - if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见"; - if (task.status === "failed_retryable") return "等待自动重试"; - return { - created: "已接收", - transcribed: "已转录", - songs_detected: "已识歌", - split_done: "已切片", - published: "已上传", - commented: "评论完成", - collection_synced: "已完成", - running: "处理中", - }[task.status] || task.status || "-"; -} - -export function taskPrimaryActionLabel(task) { - if (!task) return "执行"; - if (task.status === "failed_manual") return "人工重跑"; - if (task.retry_state?.retry_due) return "立即重试"; - if (task.status === "failed_retryable") return "继续等待"; - if (task.status === "collection_synced") return "查看结果"; - return "执行"; -} - -export function taskCurrentStep(task, steps = []) { - const running = steps.find((step) => step.status === "running"); - if (running) return stepLabel(running.step_name); - if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)}: ${taskDisplayStatus(task)}`; - const pending = steps.find((step) => step.status === "pending"); - if (pending) return stepLabel(pending.step_name); - return { - created: "转录字幕", - transcribed: "识别歌曲", - songs_detected: "切分分P", - split_done: "上传分P", - published: "评论与合集", - commented: "同步合集", - collection_synced: "链路完成", - }[task?.status] || "-"; -} - -export function stepLabel(stepName) { - return { - ingest: "接收视频", - transcribe: "转录字幕", - song_detect: "识别歌曲", - split: "切分分P", - publish: "上传分P", - comment: "发布评论", - collection_a: "加入完整版合集", - collection_b: "加入分P合集", - }[stepName] || stepName || "-"; -} - -export function actionAdvice(task) { - if (!task) return ""; - if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") { - return "B站通常需要一段时间完成转码和审核,系统会自动重试评论。"; - } - if (task.status === "failed_retryable") { - return "当前错误可自动恢复,等到重试时间或手工触发即可。"; - } - if (task.status === "failed_manual") { - return "这个任务需要人工判断,先看错误信息,再决定是重试当前步骤还是绑定完整版 BV。"; - } - if (task.status === "collection_synced") { - return "链路已完成,可以直接打开分P链接检查结果。"; - } - return "系统会继续推进后续步骤,必要时可在这里手工干预。"; -} - -export async function withButtonBusy(button, loadingText, fn) { - if (!button) return fn(); - const originalHtml = button.innerHTML; - const originalDisabled = button.disabled; - button.disabled = true; - button.classList.add("is-busy"); - if (loadingText) button.textContent = loadingText; - try { - return await fn(); - } finally { - button.disabled = originalDisabled; - button.classList.remove("is-busy"); - button.innerHTML = originalHtml; - } -} +import { state } from "./state.js"; + +let bannerTimer = null; + +export function statusClass(status) { + if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good"; + if (["done", "resolved", "present"].includes(status)) return "good"; + if (["pending", "unresolved"].includes(status)) return "warn"; + if (["removed", "disabled"].includes(status)) return ""; + if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot"; + if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn"; + return ""; +} + +export function showBanner(message, kind) { + const el = document.getElementById("banner"); + el.textContent = message; + el.className = `banner show ${kind}`; + if (bannerTimer) window.clearTimeout(bannerTimer); + bannerTimer = window.setTimeout(() => { + el.className = "banner"; + el.textContent = ""; + }, kind === "err" ? 6000 : 3200); +} + +export function escapeHtml(text) { + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +export function formatDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +export function formatDuration(seconds) { + if (seconds == null || Number.isNaN(Number(seconds))) return "-"; + const total = Math.max(0, Number(seconds)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +export function syncSettingsEditorFromState() { + document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2); +} + +export function getGroupOrder(groupName) { + return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999); +} + +export function compareFieldEntries(a, b) { + const orderA = Number(a[1].ui_order || 9999); + const orderB = Number(b[1].ui_order || 9999); + if (orderA !== orderB) return orderA - orderB; + return String(a[0]).localeCompare(String(b[0])); +} + +export function settingsFieldKey(group, field) { + return `${group}.${field}`; +} + +export function taskDisplayStatus(task) { + if (!task) return "-"; + if (task.status === "failed_manual") return "需人工处理"; + if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见"; + if (task.status === "failed_retryable") return "等待自动重试"; + return { + created: "已接收", + transcribed: "已转录", + songs_detected: "已识歌", + split_done: "已切片", + published: "已上传", + commented: "评论完成", + collection_synced: "已完成", + running: "处理中", + }[task.status] || task.status || "-"; +} + +export function taskPrimaryActionLabel(task) { + if (!task) return "执行"; + if (task.status === "failed_manual") return "人工重跑"; + if (task.retry_state?.retry_due) return "立即重试"; + if (task.status === "failed_retryable") return "继续等待"; + if (task.status === "collection_synced") return "查看结果"; + return "执行"; +} + +export function taskCurrentStep(task, steps = []) { + const running = steps.find((step) => step.status === "running"); + if (running) return stepLabel(running.step_name); + if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)}: ${taskDisplayStatus(task)}`; + const pending = steps.find((step) => step.status === "pending"); + if (pending) return stepLabel(pending.step_name); + return { + created: "转录字幕", + transcribed: "识别歌曲", + songs_detected: "切分分P", + split_done: "上传分P", + published: "评论与合集", + commented: "同步合集", + collection_synced: "链路完成", + }[task?.status] || "-"; +} + +export function stepLabel(stepName) { + return { + ingest: "接收视频", + transcribe: "转录字幕", + song_detect: "识别歌曲", + split: "切分分P", + publish: "上传分P", + comment: "发布评论", + collection_a: "加入完整版合集", + collection_b: "加入分P合集", + }[stepName] || stepName || "-"; +} + +export function actionAdvice(task) { + if (!task) return ""; + if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") { + return "B站通常需要一段时间完成转码和审核,系统会自动重试评论。"; + } + if (task.status === "failed_retryable") { + return "当前错误可自动恢复,等到重试时间或手工触发即可。"; + } + if (task.status === "failed_manual") { + return "这个任务需要人工判断,先看错误信息,再决定是重试当前步骤还是绑定完整版 BV。"; + } + if (task.status === "collection_synced") { + return "链路已完成,可以直接打开分P链接检查结果。"; + } + return "系统会继续推进后续步骤,必要时可在这里手工干预。"; +} + +export async function withButtonBusy(button, loadingText, fn) { + if (!button) return fn(); + const originalHtml = button.innerHTML; + const originalDisabled = button.disabled; + button.disabled = true; + button.classList.add("is-busy"); + if (loadingText) button.textContent = loadingText; + try { + return await fn(); + } finally { + button.disabled = originalDisabled; + button.classList.remove("is-busy"); + button.innerHTML = originalHtml; + } +} diff --git a/src/biliup_next/app/static/app/views/logs.js b/src/biliup_next/app/static/app/views/logs.js index e697046..7096ea0 100644 --- a/src/biliup_next/app/static/app/views/logs.js +++ b/src/biliup_next/app/static/app/views/logs.js @@ -1,52 +1,52 @@ -import { state } from "../state.js"; -import { escapeHtml, formatDate, showBanner } from "../utils.js"; - -export function filteredLogs() { - const search = (document.getElementById("logSearchInput")?.value || "").trim().toLowerCase(); - return state.currentLogs.filter((item) => !search || item.name.toLowerCase().includes(search)); -} - -export function renderLogsList(items, onRefreshLog, onSelectLog) { - const wrap = document.getElementById("logList"); - const stateEl = document.getElementById("logListState"); - wrap.innerHTML = ""; - const visible = filteredLogs(); - if (state.logListLoading) { - stateEl.textContent = "正在加载日志索引…"; - stateEl.classList.add("show"); - return; - } - if (!visible.length) { - stateEl.textContent = "没有匹配日志文件。"; - stateEl.classList.add("show"); - return; - } - stateEl.classList.remove("show"); - visible.forEach((item) => { - const row = document.createElement("div"); - row.className = `task-card log-card ${state.selectedLogName === item.name ? "active" : ""}`; - row.innerHTML = ` -
${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; -} +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 index cb85479..61b7403 100644 --- a/src/biliup_next/app/static/app/views/overview.js +++ b/src/biliup_next/app/static/app/views/overview.js @@ -1,98 +1,98 @@ -import { renderDoctor } from "../components/doctor-check-list.js"; -import { renderModules } from "../components/modules-list.js"; -import { renderRecentActions } from "../components/recent-actions-list.js"; -import { renderRuntimeSnapshot } from "../components/overview-runtime.js"; -import { - renderOverviewRetrySummary, - renderOverviewTaskSummary, -} from "../components/overview-task-summary.js"; -import { renderServices } from "../components/service-list.js"; -import { escapeHtml } from "../utils.js"; - -export function renderShellStats({ health, doctor, tasks }) { - renderRuntimeSnapshot({ health, doctor, tasks }); - renderOverviewTaskSummary(tasks.items); - renderOverviewRetrySummary(tasks.items); -} - -export function renderSchedulerQueue(scheduler) { - const summary = document.getElementById("schedulerSummary"); - const list = document.getElementById("schedulerList"); - const stageScan = document.getElementById("stageScanSummary"); - if (!summary || !list || !stageScan) return; - summary.innerHTML = ""; - list.innerHTML = ""; - stageScan.innerHTML = ""; - - const scheduledCount = scheduler?.scheduled?.length || 0; - const deferredCount = scheduler?.deferred?.length || 0; - const summaryData = scheduler?.summary || {}; - const strategy = scheduler?.strategy || {}; - [ - ["scheduled", scheduledCount, scheduledCount ? "warn" : ""], - ["deferred", deferredCount, deferredCount ? "hot" : ""], - ["scanned", summaryData.scanned_count || 0, ""], - ["truncated", summaryData.truncated_count || 0, (summaryData.truncated_count || 0) ? "warn" : ""], - ].forEach(([label, value, klass]) => { - const pill = document.createElement("div"); - pill.className = `pill ${klass}`.trim(); - pill.textContent = `${label} ${value}`; - summary.appendChild(pill); - }); - - const strategyRow = document.createElement("div"); - strategyRow.className = "row-card"; - strategyRow.innerHTML = ` - 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); - }); -} +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 index 480479d..96423c8 100644 --- a/src/biliup_next/app/static/app/views/settings.js +++ b/src/biliup_next/app/static/app/views/settings.js @@ -1,162 +1,162 @@ -import { state } from "../state.js"; -import { - compareFieldEntries, - escapeHtml, - getGroupOrder, - settingsFieldKey, - syncSettingsEditorFromState, -} from "../utils.js"; - -export function renderSettingsForm(onFieldChange) { - const wrap = document.getElementById("settingsForm"); - wrap.innerHTML = ""; - if (!state.currentSettingsSchema?.groups) return; - const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase(); - - const featuredContainer = document.createElement("div"); - featuredContainer.className = "settings-groups"; - const advancedDetails = document.createElement("details"); - advancedDetails.className = "settings-advanced"; - advancedDetails.innerHTML = "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(); -} +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 index 5b7a8c7..3dadc5d 100644 --- a/src/biliup_next/app/static/app/views/tasks.js +++ b/src/biliup_next/app/static/app/views/tasks.js @@ -1,531 +1,531 @@ -import { state, setTaskPage } from "../state.js"; -import { - actionAdvice, - escapeHtml, - formatDate, - formatDuration, - statusClass, - taskCurrentStep, - taskDisplayStatus, - taskPrimaryActionLabel, -} 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: "待处理", - resolved: "已定位", - unresolved: "未定位", - present: "保留", - removed: "已清理", -}; - -function displayTaskStatus(task) { - if (task.status === "failed_manual") return "需人工处理"; - if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见"; - if (task.status === "failed_retryable") return "等待自动重试"; - return taskDisplayStatus(task); -} - -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 === "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(taskCurrentStep(item))}
- - ${escapeHtml(displayTaskStatus(item))} - ${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, actions = {}) { - const { task, steps, artifacts, history, timeline } = payload; - renderTaskHero(task, steps); - renderRetryPanel(task); - - const detail = document.getElementById("taskDetail"); - detail.innerHTML = ""; - [ - ["Task ID", task.id], - ["Status", displayTaskStatus(task)], - ["Current Step", taskCurrentStep(task, steps.items)], - ["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 sessionContext = task.session_context || {}; - const splitVideoUrl = sessionContext.video_links?.split_video_url; - const fullVideoUrl = sessionContext.video_links?.full_video_url; - const summaryEl = document.getElementById("taskSummary"); - summaryEl.innerHTML = ` -
Recent Result
-
${escapeHtml(summaryText)}
-
Recommended Next Step
-
${escapeHtml(actionAdvice(task))}
-
Delivery Links
-
- ${renderDeliveryState("Split BV", sessionContext.split_bvid || "-", "")} - ${renderDeliveryState("Full BV", sessionContext.full_video_bvid || "-", "")} - ${renderLinkState("Split Video", splitVideoUrl)} - ${renderLinkState("Full Video", fullVideoUrl)} -
-
Session Context
-
- ${renderDeliveryState("Session Key", sessionContext.session_key || "-", "")} - ${renderDeliveryState("Streamer", sessionContext.streamer || "-", "")} - ${renderDeliveryState("Room ID", sessionContext.room_id || "-", "")} - ${renderDeliveryState("Context Source", sessionContext.context_source || "-", "")} - ${renderDeliveryState("Segment Start", sessionContext.segment_started_at ? formatDate(sessionContext.segment_started_at) : "-", "")} - ${renderDeliveryState("Segment Duration", sessionContext.segment_duration_seconds != null ? formatDuration(sessionContext.segment_duration_seconds) : "-", "")} -
-
Bind Full Video BV
-
- -
- - ${sessionContext.session_key ? `` : ""} -
-
用于修复评论 / 合集查不到完整版视频的问题。
-
-
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"}`, - "" - )} -
- `; - const bindBtn = document.getElementById("bindFullVideoBtn"); - if (bindBtn) { - bindBtn.onclick = () => actions.onBindFullVideo?.(task.id, document.getElementById("bindFullVideoInput")?.value || ""); - } - const openSessionBtn = document.getElementById("openSessionBtn"); - if (openSessionBtn) { - openSessionBtn.onclick = () => actions.onOpenSession?.(sessionContext.session_key); - } - - 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))}
-
- `; -} - -function renderLinkState(label, url) { - return ` -
-
${escapeHtml(label)}
-
- ${url ? `打开` : `-`} -
-
- `; -} - -export function renderTaskWorkspaceState(mode, message = "") { - const stateEl = document.getElementById("taskWorkspaceState"); - const sessionStateEl = document.getElementById("sessionWorkspaceState"); - const sessionPanel = document.getElementById("sessionPanel"); - 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 = ""; - if (sessionStateEl) { - sessionStateEl.className = "task-workspace-state show"; - sessionStateEl.textContent = mode === "error" - ? "Session 区域暂不可用。" - : "当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。"; - } - if (sessionPanel) sessionPanel.innerHTML = ""; -} +import { state, setTaskPage } from "../state.js"; +import { + actionAdvice, + escapeHtml, + formatDate, + formatDuration, + statusClass, + taskCurrentStep, + taskDisplayStatus, + taskPrimaryActionLabel, +} 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: "待处理", + resolved: "已定位", + unresolved: "未定位", + present: "保留", + removed: "已清理", +}; + +function displayTaskStatus(task) { + if (task.status === "failed_manual") return "需人工处理"; + if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见"; + if (task.status === "failed_retryable") return "等待自动重试"; + return taskDisplayStatus(task); +} + +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 === "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(taskCurrentStep(item))}
+ + ${escapeHtml(displayTaskStatus(item))} + ${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, actions = {}) { + const { task, steps, artifacts, history, timeline } = payload; + renderTaskHero(task, steps); + renderRetryPanel(task); + + const detail = document.getElementById("taskDetail"); + detail.innerHTML = ""; + [ + ["Task ID", task.id], + ["Status", displayTaskStatus(task)], + ["Current Step", taskCurrentStep(task, steps.items)], + ["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 sessionContext = task.session_context || {}; + const splitVideoUrl = sessionContext.video_links?.split_video_url; + const fullVideoUrl = sessionContext.video_links?.full_video_url; + const summaryEl = document.getElementById("taskSummary"); + summaryEl.innerHTML = ` +
Recent Result
+
${escapeHtml(summaryText)}
+
Recommended Next Step
+
${escapeHtml(actionAdvice(task))}
+
Delivery Links
+
+ ${renderDeliveryState("Split BV", sessionContext.split_bvid || "-", "")} + ${renderDeliveryState("Full BV", sessionContext.full_video_bvid || "-", "")} + ${renderLinkState("Split Video", splitVideoUrl)} + ${renderLinkState("Full Video", fullVideoUrl)} +
+
Session Context
+
+ ${renderDeliveryState("Session Key", sessionContext.session_key || "-", "")} + ${renderDeliveryState("Streamer", sessionContext.streamer || "-", "")} + ${renderDeliveryState("Room ID", sessionContext.room_id || "-", "")} + ${renderDeliveryState("Context Source", sessionContext.context_source || "-", "")} + ${renderDeliveryState("Segment Start", sessionContext.segment_started_at ? formatDate(sessionContext.segment_started_at) : "-", "")} + ${renderDeliveryState("Segment Duration", sessionContext.segment_duration_seconds != null ? formatDuration(sessionContext.segment_duration_seconds) : "-", "")} +
+
Bind Full Video BV
+
+ +
+ + ${sessionContext.session_key ? `` : ""} +
+
用于修复评论 / 合集查不到完整版视频的问题。
+
+
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"}`, + "" + )} +
+ `; + const bindBtn = document.getElementById("bindFullVideoBtn"); + if (bindBtn) { + bindBtn.onclick = () => actions.onBindFullVideo?.(task.id, document.getElementById("bindFullVideoInput")?.value || ""); + } + const openSessionBtn = document.getElementById("openSessionBtn"); + if (openSessionBtn) { + openSessionBtn.onclick = () => actions.onOpenSession?.(sessionContext.session_key); + } + + 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))}
+
+ `; +} + +function renderLinkState(label, url) { + return ` +
+
${escapeHtml(label)}
+
+ ${url ? `打开` : `-`} +
+
+ `; +} + +export function renderTaskWorkspaceState(mode, message = "") { + const stateEl = document.getElementById("taskWorkspaceState"); + const sessionStateEl = document.getElementById("sessionWorkspaceState"); + const sessionPanel = document.getElementById("sessionPanel"); + 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 = ""; + if (sessionStateEl) { + sessionStateEl.className = "task-workspace-state show"; + sessionStateEl.textContent = mode === "error" + ? "Session 区域暂不可用。" + : "当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。"; + } + if (sessionPanel) sessionPanel.innerHTML = ""; +} diff --git a/src/biliup_next/app/static/dashboard.css b/src/biliup_next/app/static/dashboard.css index 6f5a8fa..7181917 100644 --- a/src/biliup_next/app/static/dashboard.css +++ b/src/biliup_next/app/static/dashboard.css @@ -1,893 +1,893 @@ -: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; -} - -button.is-busy { - opacity: 0.72; - cursor: wait; -} - -.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; -} - -.task-cell-subtitle { - margin-top: 4px; - color: var(--muted); - font-size: 12px; -} - -.bind-form { - display: grid; - gap: 10px; - margin-top: 10px; -} - -.bind-form input { - width: 100%; -} - -.detail-link { - color: var(--accent-2); - text-decoration: none; - font-weight: 600; -} - -.detail-link:hover { - text-decoration: underline; -} - -.session-panel { - display: grid; - gap: 16px; -} - -.session-hero { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; -} - -.session-key { - margin-top: 6px; - font-size: 20px; - font-weight: 700; - letter-spacing: -0.02em; -} - -.session-meta-strip, -.session-actions-grid { - display: grid; - gap: 12px; -} - -.session-actions-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.session-task-card { - cursor: pointer; -} - -.session-task-card:hover { - border-color: var(--line-strong); -} - -.session-link-btn { - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--line); - border-radius: 12px; - padding: 8px 12px; - background: rgba(255,255,255,0.78); -} - -.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; - } -} +: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; +} + +button.is-busy { + opacity: 0.72; + cursor: wait; +} + +.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; +} + +.task-cell-subtitle { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.bind-form { + display: grid; + gap: 10px; + margin-top: 10px; +} + +.bind-form input { + width: 100%; +} + +.detail-link { + color: var(--accent-2); + text-decoration: none; + font-weight: 600; +} + +.detail-link:hover { + text-decoration: underline; +} + +.session-panel { + display: grid; + gap: 16px; +} + +.session-hero { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.session-key { + margin-top: 6px; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.session-meta-strip, +.session-actions-grid { + display: grid; + gap: 12px; +} + +.session-actions-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.session-task-card { + cursor: pointer; +} + +.session-task-card:hover { + border-color: var(--line-strong); +} + +.session-link-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 12px; + padding: 8px 12px; + background: rgba(255,255,255,0.78); +} + +.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 index eacaa69..2dfb61e 100644 --- a/src/biliup_next/app/static/dashboard.js +++ b/src/biliup_next/app/static/dashboard.js @@ -1,805 +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")); +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 index ecc7233..952a4ec 100644 --- a/src/biliup_next/app/task_actions.py +++ b/src/biliup_next/app/task_actions.py @@ -1,98 +1,98 @@ -from __future__ import annotations - -from biliup_next.app.bootstrap import ensure_initialized -from biliup_next.app.task_control_service import TaskControlService -from biliup_next.app.session_delivery_service import SessionDeliveryService -from biliup_next.app.task_audit import record_task_action - - -def run_task_action(task_id: str) -> dict[str, object]: - state = ensure_initialized() - result = TaskControlService(state).run_task(task_id) - record_task_action(state["repo"], task_id, "task_run", "ok", "task run invoked", result) - return result - - -def retry_step_action(task_id: str, step_name: str) -> dict[str, object]: - state = ensure_initialized() - result = TaskControlService(state).retry_step(task_id, step_name) - record_task_action(state["repo"], task_id, "retry_step", "ok", f"retry step invoked: {step_name}", result) - return result - - -def reset_to_step_action(task_id: str, step_name: str) -> dict[str, object]: - state = ensure_initialized() - payload = TaskControlService(state).reset_to_step(task_id, step_name) - record_task_action(state["repo"], task_id, "reset_to_step", "ok", f"reset to step invoked: {step_name}", payload) - return payload - - -def bind_full_video_action(task_id: str, full_video_bvid: str) -> dict[str, object]: - state = ensure_initialized() - payload = SessionDeliveryService(state).bind_task_full_video(task_id, full_video_bvid) - if "error" in payload: - return payload - record_task_action( - state["repo"], - task_id, - "bind_full_video", - "ok", - f"full video bvid bound: {payload['full_video_bvid']}", - payload, - ) - return payload - - -def rebind_session_full_video_action(session_key: str, full_video_bvid: str) -> dict[str, object]: - state = ensure_initialized() - payload = SessionDeliveryService(state).rebind_session_full_video(session_key, full_video_bvid) - if "error" in payload: - return payload - - for item in payload["tasks"]: - record_task_action( - state["repo"], - item["task_id"], - "rebind_session_full_video", - "ok", - f"session full video bvid rebound: {payload['full_video_bvid']}", - { - "session_key": session_key, - "full_video_bvid": payload["full_video_bvid"], - "path": item["path"], - }, - ) - return payload - - -def merge_session_action(session_key: str, task_ids: list[str]) -> dict[str, object]: - state = ensure_initialized() - payload = SessionDeliveryService(state).merge_session(session_key, task_ids) - if "error" in payload: - return payload - for item in payload["tasks"]: - record_task_action(state["repo"], item["task_id"], "merge_session", "ok", f"task merged into session: {session_key}", item) - return payload - - -def receive_full_video_webhook(payload: dict[str, object]) -> dict[str, object]: - state = ensure_initialized() - result = SessionDeliveryService(state).receive_full_video_webhook(payload) - if "error" in result: - return result - - for item in result["tasks"]: - record_task_action( - state["repo"], - item["task_id"], - "webhook_full_video_uploaded", - "ok", - f"full video bvid received via webhook: {result['full_video_bvid']}", - { - "session_key": result["session_key"], - "source_title": result["source_title"], - "full_video_bvid": result["full_video_bvid"], - "path": item["path"], - }, - ) - return result +from __future__ import annotations + +from biliup_next.app.bootstrap import ensure_initialized +from biliup_next.app.task_control_service import TaskControlService +from biliup_next.app.session_delivery_service import SessionDeliveryService +from biliup_next.app.task_audit import record_task_action + + +def run_task_action(task_id: str) -> dict[str, object]: + state = ensure_initialized() + result = TaskControlService(state).run_task(task_id) + record_task_action(state["repo"], task_id, "task_run", "ok", "task run invoked", result) + return result + + +def retry_step_action(task_id: str, step_name: str) -> dict[str, object]: + state = ensure_initialized() + result = TaskControlService(state).retry_step(task_id, step_name) + record_task_action(state["repo"], task_id, "retry_step", "ok", f"retry step invoked: {step_name}", result) + return result + + +def reset_to_step_action(task_id: str, step_name: str) -> dict[str, object]: + state = ensure_initialized() + payload = TaskControlService(state).reset_to_step(task_id, step_name) + record_task_action(state["repo"], task_id, "reset_to_step", "ok", f"reset to step invoked: {step_name}", payload) + return payload + + +def bind_full_video_action(task_id: str, full_video_bvid: str) -> dict[str, object]: + state = ensure_initialized() + payload = SessionDeliveryService(state).bind_task_full_video(task_id, full_video_bvid) + if "error" in payload: + return payload + record_task_action( + state["repo"], + task_id, + "bind_full_video", + "ok", + f"full video bvid bound: {payload['full_video_bvid']}", + payload, + ) + return payload + + +def rebind_session_full_video_action(session_key: str, full_video_bvid: str) -> dict[str, object]: + state = ensure_initialized() + payload = SessionDeliveryService(state).rebind_session_full_video(session_key, full_video_bvid) + if "error" in payload: + return payload + + for item in payload["tasks"]: + record_task_action( + state["repo"], + item["task_id"], + "rebind_session_full_video", + "ok", + f"session full video bvid rebound: {payload['full_video_bvid']}", + { + "session_key": session_key, + "full_video_bvid": payload["full_video_bvid"], + "path": item["path"], + }, + ) + return payload + + +def merge_session_action(session_key: str, task_ids: list[str]) -> dict[str, object]: + state = ensure_initialized() + payload = SessionDeliveryService(state).merge_session(session_key, task_ids) + if "error" in payload: + return payload + for item in payload["tasks"]: + record_task_action(state["repo"], item["task_id"], "merge_session", "ok", f"task merged into session: {session_key}", item) + return payload + + +def receive_full_video_webhook(payload: dict[str, object]) -> dict[str, object]: + state = ensure_initialized() + result = SessionDeliveryService(state).receive_full_video_webhook(payload) + if "error" in result: + return result + + for item in result["tasks"]: + record_task_action( + state["repo"], + item["task_id"], + "webhook_full_video_uploaded", + "ok", + f"full video bvid received via webhook: {result['full_video_bvid']}", + { + "session_key": result["session_key"], + "source_title": result["source_title"], + "full_video_bvid": result["full_video_bvid"], + "path": item["path"], + }, + ) + return result diff --git a/src/biliup_next/app/task_audit.py b/src/biliup_next/app/task_audit.py index 0a71069..3759c20 100644 --- a/src/biliup_next/app/task_audit.py +++ b/src/biliup_next/app/task_audit.py @@ -1,19 +1,19 @@ -from __future__ import annotations - -import json - -from biliup_next.core.models import ActionRecord, utc_now_iso - - -def record_task_action(repo, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None: # type: ignore[no-untyped-def] - repo.add_action_record( - ActionRecord( - id=None, - task_id=task_id, - action_name=action_name, - status=status, - summary=summary, - details_json=json.dumps(details, ensure_ascii=False), - created_at=utc_now_iso(), - ) - ) +from __future__ import annotations + +import json + +from biliup_next.core.models import ActionRecord, utc_now_iso + + +def record_task_action(repo, task_id: str | None, action_name: str, status: str, summary: str, details: dict[str, object]) -> None: # type: ignore[no-untyped-def] + repo.add_action_record( + ActionRecord( + id=None, + task_id=task_id, + action_name=action_name, + status=status, + summary=summary, + details_json=json.dumps(details, ensure_ascii=False), + created_at=utc_now_iso(), + ) + ) diff --git a/src/biliup_next/app/task_control_service.py b/src/biliup_next/app/task_control_service.py index 08e8005..d7082d2 100644 --- a/src/biliup_next/app/task_control_service.py +++ b/src/biliup_next/app/task_control_service.py @@ -1,25 +1,25 @@ -from __future__ import annotations - -from pathlib import Path - -from biliup_next.app.task_runner import process_task -from biliup_next.infra.task_reset import TaskResetService - - -class TaskControlService: - def __init__(self, state: dict[str, object]): - self.state = state - - def run_task(self, task_id: str) -> dict[str, object]: - return process_task(task_id) - - def retry_step(self, task_id: str, step_name: str) -> dict[str, object]: - return process_task(task_id, reset_step=step_name) - - def reset_to_step(self, task_id: str, step_name: str) -> dict[str, object]: - reset_result = TaskResetService( - self.state["repo"], - Path(str(self.state["settings"]["paths"]["session_dir"])), - ).reset_to_step(task_id, step_name) - process_result = process_task(task_id) - return {"reset": reset_result, "run": process_result} +from __future__ import annotations + +from pathlib import Path + +from biliup_next.app.task_runner import process_task +from biliup_next.infra.task_reset import TaskResetService + + +class TaskControlService: + def __init__(self, state: dict[str, object]): + self.state = state + + def run_task(self, task_id: str) -> dict[str, object]: + return process_task(task_id) + + def retry_step(self, task_id: str, step_name: str) -> dict[str, object]: + return process_task(task_id, reset_step=step_name) + + def reset_to_step(self, task_id: str, step_name: str) -> dict[str, object]: + reset_result = TaskResetService( + self.state["repo"], + Path(str(self.state["settings"]["paths"]["session_dir"])), + ).reset_to_step(task_id, step_name) + process_result = process_task(task_id) + return {"reset": reset_result, "run": process_result} diff --git a/src/biliup_next/app/task_engine.py b/src/biliup_next/app/task_engine.py index 54009ca..cffe95b 100644 --- a/src/biliup_next/app/task_engine.py +++ b/src/biliup_next/app/task_engine.py @@ -1,190 +1,199 @@ from __future__ import annotations from datetime import datetime - -from biliup_next.app.retry_meta import retry_meta_for_step - - -def settings_for(state: dict[str, object], group: str) -> dict[str, object]: - settings = dict(state["settings"][group]) - settings.update(state["settings"]["paths"]) - if group == "comment" and "collection" in state["settings"]: - collection_settings = state["settings"]["collection"] - if "allow_fuzzy_full_video_match" in collection_settings: - settings.setdefault("allow_fuzzy_full_video_match", collection_settings["allow_fuzzy_full_video_match"]) - if group == "collection" and "cleanup" in state["settings"]: - settings.update(state["settings"]["cleanup"]) - if "publish" in state["settings"]: - publish_settings = state["settings"]["publish"] - if "biliup_path" in publish_settings: - settings.setdefault("biliup_path", publish_settings["biliup_path"]) - if "cookie_file" in publish_settings: - settings.setdefault("cookie_file", publish_settings["cookie_file"]) - return settings - - -def infer_error_step_name(task, steps: dict[str, object]) -> str: # type: ignore[no-untyped-def] - running = next((step for step in steps.values() if step.status == "running"), None) - if running is not None: - return running.step_name - failed = next((step for step in steps.values() if step.status == "failed_retryable"), None) - if failed is not None: - return failed.step_name - if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}: - return "transcribe" - if task.status == "transcribed": - return "song_detect" - if task.status == "songs_detected": - return "split" - if task.status in {"published", "collection_synced"}: - if steps.get("comment") and steps["comment"].status in {"running", "pending", "failed_retryable"}: - return "comment" - if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}: - return "collection_a" - return "collection_b" - if task.status == "commented": - if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}: - return "collection_a" - return "collection_b" - return "publish" - - + +from biliup_next.app.retry_meta import retry_meta_for_step + + +def settings_for(state: dict[str, object], group: str) -> dict[str, object]: + settings = dict(state["settings"][group]) + settings.update(state["settings"]["paths"]) + if group == "comment" and "collection" in state["settings"]: + collection_settings = state["settings"]["collection"] + if "allow_fuzzy_full_video_match" in collection_settings: + settings.setdefault("allow_fuzzy_full_video_match", collection_settings["allow_fuzzy_full_video_match"]) + if group == "collection" and "cleanup" in state["settings"]: + settings.update(state["settings"]["cleanup"]) + if "publish" in state["settings"]: + publish_settings = state["settings"]["publish"] + if "biliup_path" in publish_settings: + settings.setdefault("biliup_path", publish_settings["biliup_path"]) + if "cookie_file" in publish_settings: + settings.setdefault("cookie_file", publish_settings["cookie_file"]) + return settings + + +def infer_error_step_name(task, steps: dict[str, object]) -> str: # type: ignore[no-untyped-def] + running = next((step for step in steps.values() if step.status == "running"), None) + if running is not None: + return running.step_name + failed = next((step for step in steps.values() if step.status == "failed_retryable"), None) + if failed is not None: + return failed.step_name + if task.status in {"created", "failed_retryable"} and steps.get("transcribe") and steps["transcribe"].status in {"pending", "failed_retryable", "running"}: + return "transcribe" + if task.status == "transcribed": + return "song_detect" + if task.status == "songs_detected": + return "split" + if task.status in {"published", "collection_synced"}: + if steps.get("comment") and steps["comment"].status in {"running", "pending", "failed_retryable"}: + return "comment" + if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}: + return "collection_a" + return "collection_b" + if task.status == "commented": + if steps.get("collection_a") and steps["collection_a"].status in {"running", "pending", "failed_retryable"}: + return "collection_a" + return "collection_b" + return "publish" + + def retry_wait_payload(task_id: str, step, state: dict[str, object]) -> dict[str, object] | None: # type: ignore[no-untyped-def] if step.status != "failed_retryable": return None - meta = retry_meta_for_step(step, {"publish": settings_for(state, "publish")}) + step_settings_group = { + "transcribe": "transcribe", + "song_detect": "song_detect", + "publish": "publish", + "comment": "comment", + }.get(step.step_name) + settings_by_group = {} + if step_settings_group is not None and step_settings_group in state["settings"]: + settings_by_group[step_settings_group] = settings_for(state, step_settings_group) + meta = retry_meta_for_step(step, settings_by_group) if meta is None or meta["retry_due"]: return None return { "task_id": task_id, - "step": step.step_name, - "waiting_for_retry": True, - "remaining_seconds": meta["retry_remaining_seconds"], - "retry_count": step.retry_count, - } - - -def next_runnable_step(task, steps: dict[str, object], state: dict[str, object]) -> tuple[str | None, dict[str, object] | None]: # type: ignore[no-untyped-def] - if any(step.status == "running" for step in steps.values()): - return None, None - - if task.status == "failed_retryable": - failed = next((step for step in steps.values() if step.status == "failed_retryable"), None) - if failed is None: - return None, None - wait_payload = retry_wait_payload(task.id, failed, state) - if wait_payload is not None: - return None, wait_payload - return failed.step_name, None - - if task.status == "created": - step = steps.get("transcribe") - if step and step.status in {"pending", "failed_retryable"}: - return "transcribe", None - if task.status == "transcribed": - step = steps.get("song_detect") - if step and step.status in {"pending", "failed_retryable"}: - return "song_detect", None - if task.status == "songs_detected": - step = steps.get("split") - if step and step.status in {"pending", "failed_retryable"}: - return "split", None - if task.status == "split_done": - step = steps.get("publish") - if step and step.status in {"pending", "failed_retryable"}: - session_publish = _session_publish_gate(task.id, state) - if session_publish is not None: - if session_publish["session_published"]: - return "publish", None - if not session_publish["is_anchor"]: - return None, None - if not session_publish["all_split_ready"]: - return None, None - return "publish", None - if task.status in {"published", "collection_synced"}: - if state["settings"]["comment"].get("enabled", True): - step = steps.get("comment") - if step and step.status in {"pending", "failed_retryable"}: - return "comment", None - if state["settings"]["collection"].get("enabled", True): - step = steps.get("collection_a") - if step and step.status in {"pending", "failed_retryable"}: - return "collection_a", None - step = steps.get("collection_b") - if step and step.status in {"pending", "failed_retryable"}: - return "collection_b", None - if task.status == "commented" and state["settings"]["collection"].get("enabled", True): - step = steps.get("collection_a") - if step and step.status in {"pending", "failed_retryable"}: - return "collection_a", None - step = steps.get("collection_b") - if step and step.status in {"pending", "failed_retryable"}: - return "collection_b", None - return None, None - - -def _session_publish_gate(task_id: str, state: dict[str, object]) -> dict[str, object] | None: - repo = state.get("repo") - if repo is None or not hasattr(repo, "get_task_context"): - return None - context = repo.get_task_context(task_id) - if context is None or not context.session_key or context.session_key.startswith("task:"): - return None - contexts = list(repo.list_task_contexts_by_session_key(context.session_key)) - if len(contexts) <= 1: - return None - ordered = sorted( - contexts, - key=lambda item: ( - _parse_dt(item.segment_started_at), - item.source_title or item.task_id, - ), - ) - anchor_id = ordered[0].task_id - sibling_tasks = [repo.get_task(item.task_id) for item in ordered] - session_published = any( - sibling is not None and sibling.status in {"published", "commented", "collection_synced"} - for sibling in sibling_tasks - ) - all_split_ready = all( - sibling is not None and sibling.status in {"split_done", "published", "commented", "collection_synced"} - for sibling in sibling_tasks - ) - return { - "is_anchor": task_id == anchor_id, - "session_published": session_published, - "all_split_ready": all_split_ready, - } - - -def _parse_dt(value: str | None) -> datetime: - if not value: - return datetime.max - try: - return datetime.fromisoformat(value) - except ValueError: - return datetime.max - - -def execute_step(state: dict[str, object], task_id: str, step_name: str) -> dict[str, object]: - if step_name == "transcribe": - artifact = state["transcribe_service"].run(task_id, settings_for(state, "transcribe")) - return {"task_id": task_id, "step": "transcribe", "artifact": artifact.path} + "step": step.step_name, + "waiting_for_retry": True, + "remaining_seconds": meta["retry_remaining_seconds"], + "retry_count": step.retry_count, + } + + +def next_runnable_step(task, steps: dict[str, object], state: dict[str, object]) -> tuple[str | None, dict[str, object] | None]: # type: ignore[no-untyped-def] + if any(step.status == "running" for step in steps.values()): + return None, None + + if task.status == "failed_retryable": + failed = next((step for step in steps.values() if step.status == "failed_retryable"), None) + if failed is None: + return None, None + wait_payload = retry_wait_payload(task.id, failed, state) + if wait_payload is not None: + return None, wait_payload + return failed.step_name, None + + if task.status == "created": + step = steps.get("transcribe") + if step and step.status in {"pending", "failed_retryable"}: + return "transcribe", None + if task.status == "transcribed": + step = steps.get("song_detect") + if step and step.status in {"pending", "failed_retryable"}: + return "song_detect", None + if task.status == "songs_detected": + step = steps.get("split") + if step and step.status in {"pending", "failed_retryable"}: + return "split", None + if task.status == "split_done": + step = steps.get("publish") + if step and step.status in {"pending", "failed_retryable"}: + session_publish = _session_publish_gate(task.id, state) + if session_publish is not None: + if session_publish["session_published"]: + return "publish", None + if not session_publish["is_anchor"]: + return None, None + if not session_publish["all_split_ready"]: + return None, None + return "publish", None + if task.status in {"published", "collection_synced"}: + if state["settings"]["comment"].get("enabled", True): + step = steps.get("comment") + if step and step.status in {"pending", "failed_retryable"}: + return "comment", None + if state["settings"]["collection"].get("enabled", True): + step = steps.get("collection_a") + if step and step.status in {"pending", "failed_retryable"}: + return "collection_a", None + step = steps.get("collection_b") + if step and step.status in {"pending", "failed_retryable"}: + return "collection_b", None + if task.status == "commented" and state["settings"]["collection"].get("enabled", True): + step = steps.get("collection_a") + if step and step.status in {"pending", "failed_retryable"}: + return "collection_a", None + step = steps.get("collection_b") + if step and step.status in {"pending", "failed_retryable"}: + return "collection_b", None + return None, None + + +def _session_publish_gate(task_id: str, state: dict[str, object]) -> dict[str, object] | None: + repo = state.get("repo") + if repo is None or not hasattr(repo, "get_task_context"): + return None + context = repo.get_task_context(task_id) + if context is None or not context.session_key or context.session_key.startswith("task:"): + return None + contexts = list(repo.list_task_contexts_by_session_key(context.session_key)) + if len(contexts) <= 1: + return None + ordered = sorted( + contexts, + key=lambda item: ( + _parse_dt(item.segment_started_at), + item.source_title or item.task_id, + ), + ) + anchor_id = ordered[0].task_id + sibling_tasks = [repo.get_task(item.task_id) for item in ordered] + session_published = any( + sibling is not None and sibling.status in {"published", "commented", "collection_synced"} + for sibling in sibling_tasks + ) + all_split_ready = all( + sibling is not None and sibling.status in {"split_done", "published", "commented", "collection_synced"} + for sibling in sibling_tasks + ) + return { + "is_anchor": task_id == anchor_id, + "session_published": session_published, + "all_split_ready": all_split_ready, + } + + +def _parse_dt(value: str | None) -> datetime: + if not value: + return datetime.max + try: + return datetime.fromisoformat(value) + except ValueError: + return datetime.max + + +def execute_step(state: dict[str, object], task_id: str, step_name: str) -> dict[str, object]: + if step_name == "transcribe": + artifact = state["transcribe_service"].run(task_id, settings_for(state, "transcribe")) + return {"task_id": task_id, "step": "transcribe", "artifact": artifact.path} if step_name == "song_detect": songs_json, songs_txt = state["song_detect_service"].run(task_id, settings_for(state, "song_detect")) return {"task_id": task_id, "step": "song_detect", "songs_json": songs_json.path, "songs_txt": songs_txt.path} - if step_name == "split": - clips = state["split_service"].run(task_id, settings_for(state, "split")) - return {"task_id": task_id, "step": "split", "clip_count": len(clips)} - if step_name == "publish": - publish_record = state["publish_service"].run(task_id, settings_for(state, "publish")) - return {"task_id": task_id, "step": "publish", "bvid": publish_record.bvid} - if step_name == "comment": - comment_result = state["comment_service"].run(task_id, settings_for(state, "comment")) - return {"task_id": task_id, "step": "comment", "result": comment_result} - if step_name == "collection_a": - collection_result = state["collection_service"].run(task_id, "a", settings_for(state, "collection")) - return {"task_id": task_id, "step": "collection_a", "result": collection_result} + if step_name == "split": + clips = state["split_service"].run(task_id, settings_for(state, "split")) + return {"task_id": task_id, "step": "split", "clip_count": len(clips)} + if step_name == "publish": + publish_record = state["publish_service"].run(task_id, settings_for(state, "publish")) + return {"task_id": task_id, "step": "publish", "bvid": publish_record.bvid} + if step_name == "comment": + comment_result = state["comment_service"].run(task_id, settings_for(state, "comment")) + return {"task_id": task_id, "step": "comment", "result": comment_result} + if step_name == "collection_a": + collection_result = state["collection_service"].run(task_id, "a", settings_for(state, "collection")) + return {"task_id": task_id, "step": "collection_a", "result": collection_result} if step_name == "collection_b": collection_result = state["collection_service"].run(task_id, "b", settings_for(state, "collection")) return {"task_id": task_id, "step": "collection_b", "result": collection_result} diff --git a/src/biliup_next/app/task_policies.py b/src/biliup_next/app/task_policies.py index 41296c2..40d023f 100644 --- a/src/biliup_next/app/task_policies.py +++ b/src/biliup_next/app/task_policies.py @@ -2,81 +2,95 @@ from __future__ import annotations from biliup_next.app.retry_meta import comment_retry_schedule_seconds from biliup_next.app.retry_meta import publish_retry_schedule_seconds +from biliup_next.app.retry_meta import song_detect_retry_schedule_seconds +from biliup_next.app.retry_meta import transcribe_retry_schedule_seconds from biliup_next.app.task_engine import infer_error_step_name, settings_for as task_engine_settings_for from biliup_next.core.models import utc_now_iso - - -def settings_for(state: dict[str, object], group: str) -> dict[str, object]: - return task_engine_settings_for(state, group) - - -def apply_disabled_step_fallbacks(state: dict[str, object], task, repo) -> bool: # type: ignore[no-untyped-def] - if task.status in {"published", "collection_synced"} and not state["settings"]["comment"].get("enabled", True): - repo.update_step_status(task.id, "comment", "succeeded", finished_at=utc_now_iso()) - return True - if task.status in {"published", "commented", "collection_synced"} and not state["settings"]["collection"].get("enabled", True): - now = utc_now_iso() - repo.update_step_status(task.id, "collection_a", "succeeded", finished_at=now) - repo.update_step_status(task.id, "collection_b", "succeeded", finished_at=now) - repo.update_task_status(task.id, "collection_synced", now) - return True - return False - - -def resolve_failure(task, repo, state: dict[str, object], exc) -> dict[str, object]: # type: ignore[no-untyped-def] - current_task = repo.get_task(task.id) or task - current_steps = {step.step_name: step for step in repo.list_steps(task.id)} - step_name = infer_error_step_name(current_task, current_steps) + + +def settings_for(state: dict[str, object], group: str) -> dict[str, object]: + return task_engine_settings_for(state, group) + + +def apply_disabled_step_fallbacks(state: dict[str, object], task, repo) -> bool: # type: ignore[no-untyped-def] + if task.status in {"published", "collection_synced"} and not state["settings"]["comment"].get("enabled", True): + repo.update_step_status(task.id, "comment", "succeeded", finished_at=utc_now_iso()) + return True + if task.status in {"published", "commented", "collection_synced"} and not state["settings"]["collection"].get("enabled", True): + now = utc_now_iso() + repo.update_step_status(task.id, "collection_a", "succeeded", finished_at=now) + repo.update_step_status(task.id, "collection_b", "succeeded", finished_at=now) + repo.update_task_status(task.id, "collection_synced", now) + return True + return False + + +def resolve_failure(task, repo, state: dict[str, object], exc) -> dict[str, object]: # type: ignore[no-untyped-def] + current_task = repo.get_task(task.id) or task + current_steps = {step.step_name: step for step in repo.list_steps(task.id)} + step_name = infer_error_step_name(current_task, current_steps) current_retry = 0 for step in repo.list_steps(task.id): if step.step_name == step_name: current_retry = step.retry_count break - next_retry_count = current_retry + 1 - next_status = "failed_retryable" if exc.retryable else "failed_manual" + next_retry_count = current_retry + 1 + next_status = "failed_retryable" if exc.retryable else "failed_manual" next_retry_delay_seconds: int | None = None + if exc.retryable and step_name == "transcribe": + schedule = transcribe_retry_schedule_seconds(settings_for(state, "transcribe")) + if next_retry_count > len(schedule): + next_status = "failed_manual" + else: + next_retry_delay_seconds = schedule[next_retry_count - 1] + if exc.retryable and step_name == "song_detect": + schedule = song_detect_retry_schedule_seconds(settings_for(state, "song_detect")) + if next_retry_count > len(schedule): + next_status = "failed_manual" + else: + next_retry_delay_seconds = schedule[next_retry_count - 1] if exc.retryable and step_name == "publish": publish_settings = settings_for(state, "publish") if exc.code == "PUBLISH_RATE_LIMITED": custom_schedule = publish_settings.get("rate_limit_retry_schedule_minutes") if isinstance(custom_schedule, list) and custom_schedule: - schedule = [max(0, int(minutes)) * 60 for minutes in custom_schedule] - else: - schedule = publish_retry_schedule_seconds(publish_settings) - else: - schedule = publish_retry_schedule_seconds(publish_settings) - if next_retry_count > len(schedule): - next_status = "failed_manual" - else: - next_retry_delay_seconds = schedule[next_retry_count - 1] - if exc.retryable and step_name == "comment": - schedule = comment_retry_schedule_seconds(settings_for(state, "comment")) - if next_retry_count > len(schedule): - next_status = "failed_manual" - else: - next_retry_delay_seconds = schedule[next_retry_count - 1] - failed_at = utc_now_iso() - repo.update_step_status( - task.id, - step_name, - next_status, - error_code=exc.code, - error_message=exc.message, - retry_count=next_retry_count, - finished_at=failed_at, - ) - repo.update_task_status(task.id, next_status, failed_at) - payload = { - "task_id": task.id, - "step": step_name, - "error": exc.to_dict(), - "retry_count": next_retry_count, - "retry_status": next_status, - } - if next_retry_delay_seconds is not None: - payload["next_retry_delay_seconds"] = next_retry_delay_seconds - return { - "step_name": step_name, - "payload": payload, - "summary": exc.message, - } + schedule = [max(0, int(minutes)) * 60 for minutes in custom_schedule] + else: + schedule = publish_retry_schedule_seconds(publish_settings) + else: + schedule = publish_retry_schedule_seconds(publish_settings) + if next_retry_count > len(schedule): + next_status = "failed_manual" + else: + next_retry_delay_seconds = schedule[next_retry_count - 1] + if exc.retryable and step_name == "comment": + schedule = comment_retry_schedule_seconds(settings_for(state, "comment")) + if next_retry_count > len(schedule): + next_status = "failed_manual" + else: + next_retry_delay_seconds = schedule[next_retry_count - 1] + failed_at = utc_now_iso() + repo.update_step_status( + task.id, + step_name, + next_status, + error_code=exc.code, + error_message=exc.message, + retry_count=next_retry_count, + finished_at=failed_at, + ) + repo.update_task_status(task.id, next_status, failed_at) + payload = { + "task_id": task.id, + "step": step_name, + "error": exc.to_dict(), + "retry_count": next_retry_count, + "retry_status": next_status, + } + if next_retry_delay_seconds is not None: + payload["next_retry_delay_seconds"] = next_retry_delay_seconds + return { + "step_name": step_name, + "payload": payload, + "summary": exc.message, + } diff --git a/src/biliup_next/app/task_runner.py b/src/biliup_next/app/task_runner.py index 5c6c0c0..b19cddf 100644 --- a/src/biliup_next/app/task_runner.py +++ b/src/biliup_next/app/task_runner.py @@ -1,98 +1,98 @@ -from __future__ import annotations - -from biliup_next.app.bootstrap import ensure_initialized -from biliup_next.app.task_audit import record_task_action -from biliup_next.app.task_engine import ( - execute_step, - next_runnable_step, -) -from biliup_next.app.task_policies import apply_disabled_step_fallbacks -from biliup_next.app.task_policies import resolve_failure -from biliup_next.core.errors import ModuleError -from biliup_next.core.models import utc_now_iso -from biliup_next.infra.task_reset import STATUS_BEFORE_STEP - - -def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]: - state = ensure_initialized() - repo = state["repo"] - task = repo.get_task(task_id) - if task is None: - return {"processed": [], "error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}} - - processed: list[dict[str, object]] = [] - - if include_stage_scan: - ingest_settings = dict(state["settings"]["ingest"]) - ingest_settings.update(state["settings"]["paths"]) - stage_scan = state["ingest_service"].scan_stage(ingest_settings) - processed.append({"stage_scan": stage_scan}) - record_task_action(repo, task_id, "stage_scan", "ok", "stage scan completed", stage_scan) - - if reset_step: - step_names = {step.step_name for step in repo.list_steps(task_id)} - if reset_step not in step_names: - return {"processed": processed, "error": {"code": "STEP_NOT_FOUND", "message": f"step not found: {reset_step}"}} - repo.update_step_status( - task_id, - reset_step, - "pending", - error_code=None, - error_message=None, - started_at=None, - finished_at=None, - ) - target_status = STATUS_BEFORE_STEP.get(reset_step, "created") - repo.update_task_status(task_id, target_status, utc_now_iso()) - processed.append({"task_id": task_id, "step": reset_step, "reset": True}) - record_task_action(repo, task_id, "retry_step", "ok", f"step reset to pending: {reset_step}", {"step_name": reset_step}) - - try: - while True: - current_task = repo.get_task(task.id) or task - current_steps = {step.step_name: step for step in repo.list_steps(task.id)} - - if apply_disabled_step_fallbacks(state, current_task, repo): - continue - - step_name, waiting_payload = next_runnable_step(current_task, current_steps, state) - if waiting_payload is not None: - processed.append(waiting_payload) - return {"processed": processed} - if step_name is None: - break - - claimed_at = utc_now_iso() - if not repo.claim_step_running(task.id, step_name, started_at=claimed_at): - processed.append( - { - "task_id": task.id, - "step": step_name, - "skipped": True, - "reason": "step_already_claimed", - } - ) - return {"processed": processed} - repo.update_task_status(task.id, "running", claimed_at) - - payload = execute_step(state, task.id, step_name) - if current_task.status == "failed_retryable": - payload["retry"] = True - record_task_action(repo, task_id, step_name, "ok", f"{step_name} retry succeeded", payload) - else: - record_task_action(repo, task_id, step_name, "ok", f"{step_name} succeeded", payload) - processed.append(payload) - except ModuleError as exc: - failure = resolve_failure(task, repo, state, exc) - processed.append(failure["payload"]) - record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"]) - except Exception as exc: - unexpected = ModuleError( - code="UNHANDLED_EXCEPTION", - message=f"unexpected error: {exc}", - retryable=False, - ) - failure = resolve_failure(task, repo, state, unexpected) - processed.append(failure["payload"]) - record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"]) - return {"processed": processed} +from __future__ import annotations + +from biliup_next.app.bootstrap import ensure_initialized +from biliup_next.app.task_audit import record_task_action +from biliup_next.app.task_engine import ( + execute_step, + next_runnable_step, +) +from biliup_next.app.task_policies import apply_disabled_step_fallbacks +from biliup_next.app.task_policies import resolve_failure +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import utc_now_iso +from biliup_next.infra.task_reset import STATUS_BEFORE_STEP + + +def process_task(task_id: str, *, reset_step: str | None = None, include_stage_scan: bool = False) -> dict[str, object]: + state = ensure_initialized() + repo = state["repo"] + task = repo.get_task(task_id) + if task is None: + return {"processed": [], "error": {"code": "TASK_NOT_FOUND", "message": f"task not found: {task_id}"}} + + processed: list[dict[str, object]] = [] + + if include_stage_scan: + ingest_settings = dict(state["settings"]["ingest"]) + ingest_settings.update(state["settings"]["paths"]) + stage_scan = state["ingest_service"].scan_stage(ingest_settings) + processed.append({"stage_scan": stage_scan}) + record_task_action(repo, task_id, "stage_scan", "ok", "stage scan completed", stage_scan) + + if reset_step: + step_names = {step.step_name for step in repo.list_steps(task_id)} + if reset_step not in step_names: + return {"processed": processed, "error": {"code": "STEP_NOT_FOUND", "message": f"step not found: {reset_step}"}} + repo.update_step_status( + task_id, + reset_step, + "pending", + error_code=None, + error_message=None, + started_at=None, + finished_at=None, + ) + target_status = STATUS_BEFORE_STEP.get(reset_step, "created") + repo.update_task_status(task_id, target_status, utc_now_iso()) + processed.append({"task_id": task_id, "step": reset_step, "reset": True}) + record_task_action(repo, task_id, "retry_step", "ok", f"step reset to pending: {reset_step}", {"step_name": reset_step}) + + try: + while True: + current_task = repo.get_task(task.id) or task + current_steps = {step.step_name: step for step in repo.list_steps(task.id)} + + if apply_disabled_step_fallbacks(state, current_task, repo): + continue + + step_name, waiting_payload = next_runnable_step(current_task, current_steps, state) + if waiting_payload is not None: + processed.append(waiting_payload) + return {"processed": processed} + if step_name is None: + break + + claimed_at = utc_now_iso() + if not repo.claim_step_running(task.id, step_name, started_at=claimed_at): + processed.append( + { + "task_id": task.id, + "step": step_name, + "skipped": True, + "reason": "step_already_claimed", + } + ) + return {"processed": processed} + repo.update_task_status(task.id, "running", claimed_at) + + payload = execute_step(state, task.id, step_name) + if current_task.status == "failed_retryable": + payload["retry"] = True + record_task_action(repo, task_id, step_name, "ok", f"{step_name} retry succeeded", payload) + else: + record_task_action(repo, task_id, step_name, "ok", f"{step_name} succeeded", payload) + processed.append(payload) + except ModuleError as exc: + failure = resolve_failure(task, repo, state, exc) + processed.append(failure["payload"]) + record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"]) + except Exception as exc: + unexpected = ModuleError( + code="UNHANDLED_EXCEPTION", + message=f"unexpected error: {exc}", + retryable=False, + ) + failure = resolve_failure(task, repo, state, unexpected) + processed.append(failure["payload"]) + record_task_action(repo, task_id, failure["step_name"], "error", failure["summary"], failure["payload"]) + return {"processed": processed} diff --git a/src/biliup_next/app/worker.py b/src/biliup_next/app/worker.py index 3417725..4f8f5bf 100644 --- a/src/biliup_next/app/worker.py +++ b/src/biliup_next/app/worker.py @@ -1,30 +1,30 @@ -from __future__ import annotations - -import time -from biliup_next.app.scheduler import ( - run_scheduler_cycle, - serialize_scheduled_task, -) -from biliup_next.app.task_runner import process_task - - -def run_once() -> dict[str, object]: - processed: list[dict[str, object]] = [] - cycle = run_scheduler_cycle(include_stage_scan=True, limit=200) - processed.append({"stage_scan": cycle.preview["stage_scan"]}) - processed.extend(cycle.deferred) - for scheduled_task in cycle.scheduled: - processed.extend(process_task(scheduled_task.task_id)["processed"]) - return { - "processed": processed, - "scheduler": { - **cycle.preview, - "scheduled": [serialize_scheduled_task(scheduled_task) for scheduled_task in cycle.scheduled], - }, - } - - -def run_forever(interval_seconds: int = 5) -> None: - while True: - run_once() - time.sleep(interval_seconds) +from __future__ import annotations + +import time +from biliup_next.app.scheduler import ( + run_scheduler_cycle, + serialize_scheduled_task, +) +from biliup_next.app.task_runner import process_task + + +def run_once() -> dict[str, object]: + processed: list[dict[str, object]] = [] + cycle = run_scheduler_cycle(include_stage_scan=True, limit=200) + processed.append({"stage_scan": cycle.preview["stage_scan"]}) + processed.extend(cycle.deferred) + for scheduled_task in cycle.scheduled: + processed.extend(process_task(scheduled_task.task_id)["processed"]) + return { + "processed": processed, + "scheduler": { + **cycle.preview, + "scheduled": [serialize_scheduled_task(scheduled_task) for scheduled_task in cycle.scheduled], + }, + } + + +def run_forever(interval_seconds: int = 5) -> None: + while True: + run_once() + time.sleep(interval_seconds) diff --git a/src/biliup_next/core/config.py b/src/biliup_next/core/config.py index d1a44ec..ca88d29 100644 --- a/src/biliup_next/core/config.py +++ b/src/biliup_next/core/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass from pathlib import Path from typing import Any @@ -32,6 +33,7 @@ class SettingsService: schema = self._read_json(self.schema_path) settings = self._read_json(self.settings_path) settings = self._apply_schema_defaults(settings, schema) + settings = self._apply_env_overrides(settings, schema) settings = self._normalize_paths(settings) self.validate(settings, schema) return SettingsBundle(schema=schema, settings=settings) @@ -125,6 +127,57 @@ class SettingsService: group_value[field_name] = self._clone_default(field_schema["default"]) return merged + def _apply_env_overrides(self, settings: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]: + merged = json.loads(json.dumps(settings)) + aliases = { + ("transcribe", "groq_api_key"): ["GROQ_API_KEY"], + ("transcribe", "groq_api_keys"): ["GROQ_API_KEYS"], + ("collection", "season_id_a"): ["COLLECTION_SEASON_ID_A"], + ("collection", "season_id_b"): ["COLLECTION_SEASON_ID_B"], + } + for group_name, fields in schema.get("groups", {}).items(): + group_value = merged.setdefault(group_name, {}) + if not isinstance(group_value, dict): + continue + for field_name, field_schema in fields.items(): + env_names = [ + f"BILIUP_NEXT__{group_name}__{field_name}".upper(), + f"BILIUP_NEXT_{group_name}_{field_name}".upper(), + *aliases.get((group_name, field_name), []), + ] + raw_value = self._first_env_value(env_names) + if raw_value is None: + continue + group_value[field_name] = self._parse_env_value(raw_value, field_schema) + return merged + + @staticmethod + def _first_env_value(names: list[str]) -> str | None: + for name in names: + value = os.environ.get(name) + if value: + return value + return None + + @staticmethod + def _parse_env_value(value: str, field_schema: dict[str, Any]) -> Any: + expected = field_schema.get("type") + if expected == "integer": + return int(value) + if expected == "boolean": + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigError(f"无法解析布尔环境变量值: {value}") + if expected == "array": + stripped = value.strip() + if stripped.startswith("["): + return json.loads(stripped) + return [item.strip() for item in value.split(",") if item.strip()] + return value + @staticmethod def _clone_default(value: Any) -> Any: return json.loads(json.dumps(value)) diff --git a/src/biliup_next/core/errors.py b/src/biliup_next/core/errors.py index 6fab874..69d72f5 100644 --- a/src/biliup_next/core/errors.py +++ b/src/biliup_next/core/errors.py @@ -1,15 +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) +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 index 172cfb5..8a4496c 100644 --- a/src/biliup_next/core/models.py +++ b/src/biliup_next/core/models.py @@ -1,113 +1,113 @@ -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) - - -@dataclass(slots=True) -class TaskContext: - id: int | None - task_id: str - session_key: str - streamer: str | None - room_id: str | None - source_title: str | None - segment_started_at: str | None - segment_duration_seconds: float | None - full_video_bvid: str | None - created_at: str - updated_at: str - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -@dataclass(slots=True) -class SessionBinding: - id: int | None - session_key: str | None - source_title: str | None - streamer: str | None - room_id: str | None - full_video_bvid: str - created_at: str - updated_at: str - - def to_dict(self) -> dict[str, Any]: - return asdict(self) +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) + + +@dataclass(slots=True) +class TaskContext: + id: int | None + task_id: str + session_key: str + streamer: str | None + room_id: str | None + source_title: str | None + segment_started_at: str | None + segment_duration_seconds: float | None + full_video_bvid: str | None + created_at: str + updated_at: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class SessionBinding: + id: int | None + session_key: str | None + source_title: str | None + streamer: str | None + room_id: str | None + full_video_bvid: str + created_at: str + updated_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 index 2faecbf..950a044 100644 --- a/src/biliup_next/core/providers.py +++ b/src/biliup_next/core/providers.py @@ -1,54 +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]: - ... +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 index ed55a13..4a754b0 100644 --- a/src/biliup_next/core/registry.py +++ b/src/biliup_next/core/registry.py @@ -1,39 +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 +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_api.py b/src/biliup_next/infra/adapters/bilibili_api.py index ff00a8a..73bd14f 100644 --- a/src/biliup_next/infra/adapters/bilibili_api.py +++ b/src/biliup_next/infra/adapters/bilibili_api.py @@ -1,113 +1,113 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import requests - -from biliup_next.core.errors import ModuleError - - -class BilibiliApiAdapter: - def load_cookies(self, path: Path) -> dict[str, str]: - with path.open("r", encoding="utf-8") as file_handle: - data = json.load(file_handle) - if "cookie_info" in data: - return {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])} - return data - - def build_session( - self, - *, - cookies: dict[str, str], - referer: str, - origin: str | None = None, - ) -> requests.Session: - session = requests.Session() - session.cookies.update(cookies) - headers = { - "User-Agent": "Mozilla/5.0", - "Referer": referer, - } - if origin: - headers["Origin"] = origin - session.headers.update(headers) - return session - - def get_video_view(self, session: requests.Session, bvid: str, *, error_code: str, error_message: str) -> dict[str, Any]: - 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=error_code, - message=f"{error_message}: {result.get('message')}", - retryable=True, - ) - return dict(result["data"]) - - def add_reply(self, session: requests.Session, *, csrf: str, aid: int, content: str, error_message: str) -> dict[str, Any]: - result = 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 result.get("code") != 0: - raise ModuleError( - code="COMMENT_POST_FAILED", - message=f"{error_message}: {result.get('message')}", - retryable=True, - ) - return dict(result["data"]) - - def top_reply(self, session: requests.Session, *, csrf: str, aid: int, rpid: int, error_message: str) -> None: - result = 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 result.get("code") != 0: - raise ModuleError( - code="COMMENT_TOP_FAILED", - message=f"{error_message}: {result.get('message')}", - retryable=True, - ) - - def list_seasons(self, session: requests.Session) -> dict[str, Any]: - result = session.get("https://member.bilibili.com/x2/creative/web/seasons", params={"pn": 1, "ps": 50}, timeout=15).json() - return dict(result) - - def add_section_episodes( - self, - session: requests.Session, - *, - csrf: str, - section_id: int, - episodes: list[dict[str, object]], - ) -> dict[str, Any]: - return dict( - 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() - ) - - def get_section_detail(self, session: requests.Session, *, section_id: int) -> dict[str, Any]: - return dict( - session.get( - "https://member.bilibili.com/x2/creative/web/season/section", - params={"id": section_id}, - timeout=20, - ).json() - ) - - def edit_section(self, session: requests.Session, *, csrf: str, payload: dict[str, object]) -> dict[str, Any]: - return dict( - session.post( - "https://member.bilibili.com/x2/creative/web/season/section/edit", - params={"csrf": csrf}, - json=payload, - timeout=20, - ).json() - ) +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import requests + +from biliup_next.core.errors import ModuleError + + +class BilibiliApiAdapter: + def load_cookies(self, path: Path) -> dict[str, str]: + with path.open("r", encoding="utf-8") as file_handle: + data = json.load(file_handle) + if "cookie_info" in data: + return {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])} + return data + + def build_session( + self, + *, + cookies: dict[str, str], + referer: str, + origin: str | None = None, + ) -> requests.Session: + session = requests.Session() + session.cookies.update(cookies) + headers = { + "User-Agent": "Mozilla/5.0", + "Referer": referer, + } + if origin: + headers["Origin"] = origin + session.headers.update(headers) + return session + + def get_video_view(self, session: requests.Session, bvid: str, *, error_code: str, error_message: str) -> dict[str, Any]: + 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=error_code, + message=f"{error_message}: {result.get('message')}", + retryable=True, + ) + return dict(result["data"]) + + def add_reply(self, session: requests.Session, *, csrf: str, aid: int, content: str, error_message: str) -> dict[str, Any]: + result = 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 result.get("code") != 0: + raise ModuleError( + code="COMMENT_POST_FAILED", + message=f"{error_message}: {result.get('message')}", + retryable=True, + ) + return dict(result["data"]) + + def top_reply(self, session: requests.Session, *, csrf: str, aid: int, rpid: int, error_message: str) -> None: + result = 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 result.get("code") != 0: + raise ModuleError( + code="COMMENT_TOP_FAILED", + message=f"{error_message}: {result.get('message')}", + retryable=True, + ) + + def list_seasons(self, session: requests.Session) -> dict[str, Any]: + result = session.get("https://member.bilibili.com/x2/creative/web/seasons", params={"pn": 1, "ps": 50}, timeout=15).json() + return dict(result) + + def add_section_episodes( + self, + session: requests.Session, + *, + csrf: str, + section_id: int, + episodes: list[dict[str, object]], + ) -> dict[str, Any]: + return dict( + 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() + ) + + def get_section_detail(self, session: requests.Session, *, section_id: int) -> dict[str, Any]: + return dict( + session.get( + "https://member.bilibili.com/x2/creative/web/season/section", + params={"id": section_id}, + timeout=20, + ).json() + ) + + def edit_section(self, session: requests.Session, *, csrf: str, payload: dict[str, object]) -> dict[str, Any]: + return dict( + session.post( + "https://member.bilibili.com/x2/creative/web/season/section/edit", + params={"csrf": csrf}, + json=payload, + timeout=20, + ).json() + ) diff --git a/src/biliup_next/infra/adapters/biliup_cli.py b/src/biliup_next/infra/adapters/biliup_cli.py index 941a79f..ab466aa 100644 --- a/src/biliup_next/infra/adapters/biliup_cli.py +++ b/src/biliup_next/infra/adapters/biliup_cli.py @@ -1,158 +1,158 @@ -from __future__ import annotations - -import subprocess -from datetime import datetime -from pathlib import Path -import shlex - -from biliup_next.core.errors import ModuleError - - -class BiliupCliAdapter: - def run( - self, - cmd: list[str], - *, - label: str, - timeout_seconds: int | None = None, - log_path: Path | None = None, - ) -> subprocess.CompletedProcess[str]: - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - timeout=timeout_seconds, - ) - self._append_log( - log_path, - label=label, - cmd=cmd, - returncode=result.returncode, - stdout=result.stdout, - stderr=result.stderr, - timeout_seconds=timeout_seconds, - ) - return result - except FileNotFoundError as exc: - raise ModuleError( - code="BILIUP_NOT_FOUND", - message=f"找不到 biliup 命令: {cmd[0]} ({label})", - retryable=False, - ) from exc - except subprocess.TimeoutExpired as exc: - stdout = self._stringify_output(exc.stdout) - stderr = self._stringify_output(exc.stderr) - self._append_log( - log_path, - label=label, - cmd=cmd, - returncode=None, - stdout=stdout, - stderr=stderr, - timeout_seconds=timeout_seconds, - ) - raise ModuleError( - code="BILIUP_TIMEOUT", - message=f"biliup 命令超时: {label}", - retryable=True, - details={ - "label": label, - "timeout_seconds": timeout_seconds, - "stdout": stdout[-2000:], - "stderr": stderr[-2000:], - }, - ) from exc - - def run_optional( - self, - cmd: list[str], - *, - label: str, - timeout_seconds: int | None = None, - log_path: Path | None = None, - ) -> None: - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=False, - timeout=timeout_seconds, - ) - self._append_log( - log_path, - label=label, - cmd=cmd, - returncode=result.returncode, - stdout=result.stdout, - stderr=result.stderr, - timeout_seconds=timeout_seconds, - ) - except FileNotFoundError as exc: - raise ModuleError( - code="BILIUP_NOT_FOUND", - message=f"找不到 biliup 命令: {cmd[0]}", - retryable=False, - ) from exc - except subprocess.TimeoutExpired as exc: - stdout = self._stringify_output(exc.stdout) - stderr = self._stringify_output(exc.stderr) - self._append_log( - log_path, - label=label, - cmd=cmd, - returncode=None, - stdout=stdout, - stderr=stderr, - timeout_seconds=timeout_seconds, - ) - raise ModuleError( - code="BILIUP_TIMEOUT", - message=f"biliup 命令超时: {label}", - retryable=True, - details={ - "label": label, - "timeout_seconds": timeout_seconds, - "stdout": stdout[-2000:], - "stderr": stderr[-2000:], - }, - ) from exc - - @staticmethod - def _append_log( - log_path: Path | None, - *, - label: str, - cmd: list[str], - returncode: int | None, - stdout: str, - stderr: str, - timeout_seconds: int | None = None, - ) -> None: - if log_path is None: - return - timestamp = datetime.now().isoformat(timespec="seconds") - log_path.parent.mkdir(parents=True, exist_ok=True) - lines = [ - f"[{timestamp}] {label}", - f"cmd: {shlex.join(cmd)}", - ] - if timeout_seconds is not None: - lines.append(f"timeout_seconds: {timeout_seconds}") - lines.append(f"exit: {returncode if returncode is not None else 'timeout'}") - if stdout: - lines.extend(["stdout:", stdout.rstrip()]) - if stderr: - lines.extend(["stderr:", stderr.rstrip()]) - lines.append("") - log_path.write_text(log_path.read_text(encoding="utf-8") + "\n".join(lines), encoding="utf-8") if log_path.exists() else log_path.write_text("\n".join(lines), encoding="utf-8") - - @staticmethod - def _stringify_output(value: str | bytes | None) -> str: - if value is None: - return "" - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace") - return value +from __future__ import annotations + +import subprocess +from datetime import datetime +from pathlib import Path +import shlex + +from biliup_next.core.errors import ModuleError + + +class BiliupCliAdapter: + def run( + self, + cmd: list[str], + *, + label: str, + timeout_seconds: int | None = None, + log_path: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + ) + self._append_log( + log_path, + label=label, + cmd=cmd, + returncode=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + timeout_seconds=timeout_seconds, + ) + return result + except FileNotFoundError as exc: + raise ModuleError( + code="BILIUP_NOT_FOUND", + message=f"找不到 biliup 命令: {cmd[0]} ({label})", + retryable=False, + ) from exc + except subprocess.TimeoutExpired as exc: + stdout = self._stringify_output(exc.stdout) + stderr = self._stringify_output(exc.stderr) + self._append_log( + log_path, + label=label, + cmd=cmd, + returncode=None, + stdout=stdout, + stderr=stderr, + timeout_seconds=timeout_seconds, + ) + raise ModuleError( + code="BILIUP_TIMEOUT", + message=f"biliup 命令超时: {label}", + retryable=True, + details={ + "label": label, + "timeout_seconds": timeout_seconds, + "stdout": stdout[-2000:], + "stderr": stderr[-2000:], + }, + ) from exc + + def run_optional( + self, + cmd: list[str], + *, + label: str, + timeout_seconds: int | None = None, + log_path: Path | None = None, + ) -> None: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + ) + self._append_log( + log_path, + label=label, + cmd=cmd, + returncode=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + timeout_seconds=timeout_seconds, + ) + except FileNotFoundError as exc: + raise ModuleError( + code="BILIUP_NOT_FOUND", + message=f"找不到 biliup 命令: {cmd[0]}", + retryable=False, + ) from exc + except subprocess.TimeoutExpired as exc: + stdout = self._stringify_output(exc.stdout) + stderr = self._stringify_output(exc.stderr) + self._append_log( + log_path, + label=label, + cmd=cmd, + returncode=None, + stdout=stdout, + stderr=stderr, + timeout_seconds=timeout_seconds, + ) + raise ModuleError( + code="BILIUP_TIMEOUT", + message=f"biliup 命令超时: {label}", + retryable=True, + details={ + "label": label, + "timeout_seconds": timeout_seconds, + "stdout": stdout[-2000:], + "stderr": stderr[-2000:], + }, + ) from exc + + @staticmethod + def _append_log( + log_path: Path | None, + *, + label: str, + cmd: list[str], + returncode: int | None, + stdout: str, + stderr: str, + timeout_seconds: int | None = None, + ) -> None: + if log_path is None: + return + timestamp = datetime.now().isoformat(timespec="seconds") + log_path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + f"[{timestamp}] {label}", + f"cmd: {shlex.join(cmd)}", + ] + if timeout_seconds is not None: + lines.append(f"timeout_seconds: {timeout_seconds}") + lines.append(f"exit: {returncode if returncode is not None else 'timeout'}") + if stdout: + lines.extend(["stdout:", stdout.rstrip()]) + if stderr: + lines.extend(["stderr:", stderr.rstrip()]) + lines.append("") + log_path.write_text(log_path.read_text(encoding="utf-8") + "\n".join(lines), encoding="utf-8") if log_path.exists() else log_path.write_text("\n".join(lines), encoding="utf-8") + + @staticmethod + def _stringify_output(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value diff --git a/src/biliup_next/infra/adapters/codex_cli.py b/src/biliup_next/infra/adapters/codex_cli.py index 0fef46a..be1334c 100644 --- a/src/biliup_next/infra/adapters/codex_cli.py +++ b/src/biliup_next/infra/adapters/codex_cli.py @@ -1,31 +1,30 @@ from __future__ import annotations +import os import subprocess from pathlib import Path - -from biliup_next.core.errors import ModuleError - - -class CodexCliAdapter: - def run_song_detect( - self, - *, - codex_cmd: str, - work_dir: Path, - prompt: str, - ) -> subprocess.CompletedProcess[str]: + +from biliup_next.core.errors import ModuleError + + +class CodexCliAdapter: + def run_song_detect( + self, + *, + codex_cmd: str, + work_dir: Path, + prompt: str, + ) -> subprocess.CompletedProcess[str]: cmd = [ codex_cmd, "exec", prompt.replace("\n", " "), - "--full-auto", - "--sandbox", - "workspace-write", + "--dangerously-bypass-approvals-and-sandbox", "--output-schema", "./song_schema.json", "-o", - "songs.json", - "--skip-git-repo-check", + "songs.json", + "--skip-git-repo-check", "--json", ] try: @@ -35,6 +34,7 @@ class CodexCliAdapter: capture_output=True, text=True, check=False, + env=self._subprocess_env(), ) except FileNotFoundError as exc: raise ModuleError( @@ -42,3 +42,12 @@ class CodexCliAdapter: message=f"找不到 codex 命令: {codex_cmd}", retryable=False, ) from exc + + @staticmethod + def _subprocess_env() -> dict[str, str]: + env = os.environ.copy() + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + value = env.get(key) + if value and "://" not in value: + env[key] = f"http://{value}" + return env diff --git a/src/biliup_next/infra/adapters/full_video_locator.py b/src/biliup_next/infra/adapters/full_video_locator.py index 40197e9..5765870 100644 --- a/src/biliup_next/infra/adapters/full_video_locator.py +++ b/src/biliup_next/infra/adapters/full_video_locator.py @@ -8,6 +8,9 @@ from typing import Any from biliup_next.core.errors import ModuleError +VISIBLE_BILIUP_LIST_STATES = {"开放浏览", "审核中"} + + def normalize_title(text: str) -> str: return re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text).lower() @@ -38,7 +41,7 @@ def fetch_biliup_list(settings: dict[str, Any], *, max_pages: int = 5) -> list[d if not line.startswith("BV"): continue parts = line.split("\t") - if len(parts) >= 3 and "开放浏览" not in parts[2]: + if len(parts) >= 3 and not any(state in parts[2] for state in VISIBLE_BILIUP_LIST_STATES): continue if len(parts) >= 2: videos.append({"bvid": parts[0].strip(), "title": parts[1].strip()}) diff --git a/src/biliup_next/infra/adapters/qwen_cli.py b/src/biliup_next/infra/adapters/qwen_cli.py index b965ed9..d6af7d1 100644 --- a/src/biliup_next/infra/adapters/qwen_cli.py +++ b/src/biliup_next/infra/adapters/qwen_cli.py @@ -1,36 +1,36 @@ -from __future__ import annotations - -import subprocess -from pathlib import Path - -from biliup_next.core.errors import ModuleError - - -class QwenCliAdapter: - def run_song_detect( - self, - *, - qwen_cmd: str, - work_dir: Path, - prompt: str, - ) -> subprocess.CompletedProcess[str]: - cmd = [ - qwen_cmd, - "--yolo", - "-p", - prompt, - ] - try: - return subprocess.run( - cmd, - cwd=str(work_dir), - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError as exc: - raise ModuleError( - code="QWEN_NOT_FOUND", - message=f"找不到 qwen 命令: {qwen_cmd}", - retryable=False, - ) from exc +from __future__ import annotations + +import subprocess +from pathlib import Path + +from biliup_next.core.errors import ModuleError + + +class QwenCliAdapter: + def run_song_detect( + self, + *, + qwen_cmd: str, + work_dir: Path, + prompt: str, + ) -> subprocess.CompletedProcess[str]: + cmd = [ + qwen_cmd, + "--yolo", + "-p", + prompt, + ] + try: + return subprocess.run( + cmd, + cwd=str(work_dir), + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise ModuleError( + code="QWEN_NOT_FOUND", + message=f"找不到 qwen 命令: {qwen_cmd}", + retryable=False, + ) from exc diff --git a/src/biliup_next/infra/adapters/yt_dlp.py b/src/biliup_next/infra/adapters/yt_dlp.py index 09c5e14..79ff526 100644 --- a/src/biliup_next/infra/adapters/yt_dlp.py +++ b/src/biliup_next/infra/adapters/yt_dlp.py @@ -1,76 +1,76 @@ -from __future__ import annotations - -import json -import subprocess -from typing import Any - -from biliup_next.core.errors import ModuleError - - -class YtDlpAdapter: - def probe(self, *, yt_dlp_cmd: str, source_url: str) -> dict[str, Any]: - cmd = [ - yt_dlp_cmd, - "--dump-single-json", - "--no-warnings", - "--no-playlist", - source_url, - ] - result = self._run(cmd) - if result.returncode != 0: - raise ModuleError( - code="YT_DLP_PROBE_FAILED", - message="yt-dlp 获取视频信息失败", - retryable=True, - details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, - ) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as exc: - raise ModuleError( - code="YT_DLP_PROBE_INVALID_JSON", - message="yt-dlp 返回了非法 JSON", - retryable=True, - details={"stdout": result.stdout[-2000:]}, - ) from exc - if not isinstance(payload, dict): - raise ModuleError( - code="YT_DLP_PROBE_INVALID_JSON", - message="yt-dlp 返回结果不是对象", - retryable=True, - ) - return payload - - def download( - self, - *, - yt_dlp_cmd: str, - source_url: str, - output_template: str, - format_selector: str | None = None, - ) -> subprocess.CompletedProcess[str]: - cmd = [ - yt_dlp_cmd, - "--no-warnings", - "--no-playlist", - ] - if format_selector: - cmd.extend(["-f", format_selector]) - cmd.extend([ - "--merge-output-format", - "mp4", - "-o", - output_template, - source_url, - ]) - return self._run(cmd) - - def _run(self, cmd: list[str]) -> subprocess.CompletedProcess[str]: - try: - return subprocess.run(cmd, capture_output=True, text=True, check=False) - except FileNotFoundError as exc: - raise ModuleError( - code="YT_DLP_NOT_FOUND", - message=f"找不到 yt-dlp 命令: {cmd[0]}", - retryable=False, - ) from exc +from __future__ import annotations + +import json +import subprocess +from typing import Any + +from biliup_next.core.errors import ModuleError + + +class YtDlpAdapter: + def probe(self, *, yt_dlp_cmd: str, source_url: str) -> dict[str, Any]: + cmd = [ + yt_dlp_cmd, + "--dump-single-json", + "--no-warnings", + "--no-playlist", + source_url, + ] + result = self._run(cmd) + if result.returncode != 0: + raise ModuleError( + code="YT_DLP_PROBE_FAILED", + message="yt-dlp 获取视频信息失败", + retryable=True, + details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise ModuleError( + code="YT_DLP_PROBE_INVALID_JSON", + message="yt-dlp 返回了非法 JSON", + retryable=True, + details={"stdout": result.stdout[-2000:]}, + ) from exc + if not isinstance(payload, dict): + raise ModuleError( + code="YT_DLP_PROBE_INVALID_JSON", + message="yt-dlp 返回结果不是对象", + retryable=True, + ) + return payload + + def download( + self, + *, + yt_dlp_cmd: str, + source_url: str, + output_template: str, + format_selector: str | None = None, + ) -> subprocess.CompletedProcess[str]: + cmd = [ + yt_dlp_cmd, + "--no-warnings", + "--no-playlist", + ] + if format_selector: + cmd.extend(["-f", format_selector]) + cmd.extend([ + "--merge-output-format", + "mp4", + "-o", + output_template, + source_url, + ]) + return self._run(cmd) + + def _run(self, cmd: list[str]) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + except FileNotFoundError as exc: + raise ModuleError( + code="YT_DLP_NOT_FOUND", + message=f"找不到 yt-dlp 命令: {cmd[0]}", + retryable=False, + ) from exc diff --git a/src/biliup_next/infra/db.py b/src/biliup_next/infra/db.py index 113ee47..684a968 100644 --- a/src/biliup_next/infra/db.py +++ b/src/biliup_next/infra/db.py @@ -1,113 +1,113 @@ -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) -); - -CREATE TABLE IF NOT EXISTS task_contexts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL UNIQUE, - session_key TEXT NOT NULL, - streamer TEXT, - room_id TEXT, - source_title TEXT, - segment_started_at TEXT, - segment_duration_seconds REAL, - full_video_bvid TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(task_id) REFERENCES tasks(id) -); - -CREATE TABLE IF NOT EXISTS session_bindings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_key TEXT UNIQUE, - source_title TEXT, - streamer TEXT, - room_id TEXT, - full_video_bvid TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_task_contexts_session_key ON task_contexts(session_key); -CREATE INDEX IF NOT EXISTS idx_task_contexts_streamer_started_at ON task_contexts(streamer, segment_started_at); -CREATE INDEX IF NOT EXISTS idx_session_bindings_source_title ON session_bindings(source_title); -CREATE INDEX IF NOT EXISTS idx_session_bindings_streamer_room_id ON session_bindings(streamer, room_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 - conn.execute("PRAGMA foreign_keys = ON") - conn.execute("PRAGMA busy_timeout = 5000") - conn.execute("PRAGMA journal_mode = WAL") - conn.execute("PRAGMA synchronous = NORMAL") - return conn - - def initialize(self) -> None: - with self.connect() as conn: - conn.executescript(SCHEMA_SQL) - conn.commit() +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) +); + +CREATE TABLE IF NOT EXISTS task_contexts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL UNIQUE, + session_key TEXT NOT NULL, + streamer TEXT, + room_id TEXT, + source_title TEXT, + segment_started_at TEXT, + segment_duration_seconds REAL, + full_video_bvid TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) +); + +CREATE TABLE IF NOT EXISTS session_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_key TEXT UNIQUE, + source_title TEXT, + streamer TEXT, + room_id TEXT, + full_video_bvid TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_task_contexts_session_key ON task_contexts(session_key); +CREATE INDEX IF NOT EXISTS idx_task_contexts_streamer_started_at ON task_contexts(streamer, segment_started_at); +CREATE INDEX IF NOT EXISTS idx_session_bindings_source_title ON session_bindings(source_title); +CREATE INDEX IF NOT EXISTS idx_session_bindings_streamer_room_id ON session_bindings(streamer, room_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 + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + 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 index 5f60760..8ae34d9 100644 --- a/src/biliup_next/infra/legacy_asset_sync.py +++ b/src/biliup_next/infra/legacy_asset_sync.py @@ -1,68 +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, - } +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/log_reader.py b/src/biliup_next/infra/log_reader.py index 52f9b45..7b69fc7 100644 --- a/src/biliup_next/infra/log_reader.py +++ b/src/biliup_next/infra/log_reader.py @@ -1,52 +1,52 @@ -from __future__ import annotations - -from pathlib import Path - - -class LogReader: - def __init__(self, root_dir: Path | None = None): - self.root_dir = (root_dir or Path(__file__).resolve().parents[3]).resolve() - self.log_dirs = [ - self.root_dir / "logs", - self.root_dir / "runtime" / "logs", - self.root_dir / "data" / "workspace" / "logs", - ] - - def _allowed_log_files(self) -> dict[str, Path]: - items: dict[str, Path] = {} - for log_dir in self.log_dirs: - if not log_dir.exists(): - continue - for path in sorted(p for p in log_dir.rglob("*.log") if p.is_file()): - items.setdefault(path.name, path.resolve()) - return items - - def list_logs(self) -> dict[str, object]: - allowed_log_files = self._allowed_log_files() - 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]: - allowed_log_files = self._allowed_log_files() - 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)):]), - } +from __future__ import annotations + +from pathlib import Path + + +class LogReader: + def __init__(self, root_dir: Path | None = None): + self.root_dir = (root_dir or Path(__file__).resolve().parents[3]).resolve() + self.log_dirs = [ + self.root_dir / "logs", + self.root_dir / "runtime" / "logs", + self.root_dir / "data" / "workspace" / "logs", + ] + + def _allowed_log_files(self) -> dict[str, Path]: + items: dict[str, Path] = {} + for log_dir in self.log_dirs: + if not log_dir.exists(): + continue + for path in sorted(p for p in log_dir.rglob("*.log") if p.is_file()): + items.setdefault(path.name, path.resolve()) + return items + + def list_logs(self) -> dict[str, object]: + allowed_log_files = self._allowed_log_files() + 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]: + allowed_log_files = self._allowed_log_files() + 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 index fb79c71..e532fad 100644 --- a/src/biliup_next/infra/plugin_loader.py +++ b/src/biliup_next/infra/plugin_loader.py @@ -1,61 +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 +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 index 6143ed9..35e1258 100644 --- a/src/biliup_next/infra/runtime_doctor.py +++ b/src/biliup_next/infra/runtime_doctor.py @@ -1,86 +1,86 @@ -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.resolve() - 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 = Path(str(settings[group][name])).resolve() - checks.append( - { - "name": f"{group}.{name}", - "ok": path.exists() and self._is_internal_path(path), - "detail": self._internal_path_detail(path), - } - ) - - for group, name in ( - ("ingest", "ffprobe_bin"), - ("transcribe", "ffmpeg_bin"), - ): - 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) - checks.append({"name": f"{group}.{name}", "ok": ok, "detail": str(found or value)}) - - if str(settings.get("ingest", {}).get("provider", "local_file")) == "bilibili_url": - value = str(settings["ingest"].get("yt_dlp_cmd", "yt-dlp")) - 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) - checks.append({"name": "ingest.yt_dlp_cmd", "ok": ok, "detail": str(found or value)}) - - song_detect_provider = str(settings.get("song_detect", {}).get("provider", "codex")) - song_detect_cmd_name = "qwen_cmd" if song_detect_provider == "qwen_cli" else "codex_cmd" - song_detect_cmd = str(settings["song_detect"].get(song_detect_cmd_name, "")) - found = shutil.which(song_detect_cmd) if "/" not in song_detect_cmd else str((self.root_dir / song_detect_cmd).resolve()) - ok = bool(found) and (Path(found).exists() if "/" in str(found) else True) - checks.append({"name": f"song_detect.{song_detect_cmd_name}", "ok": ok, "detail": str(found or song_detect_cmd)}) - - publish_biliup_path = Path(str(settings["publish"]["biliup_path"])).resolve() - checks.append( - { - "name": "publish.biliup_path", - "ok": publish_biliup_path.exists() and self._is_internal_path(publish_biliup_path), - "detail": self._internal_path_detail(publish_biliup_path), - } - ) - - return { - "ok": all(item["ok"] for item in checks), - "checks": checks, - } - - def _is_internal_path(self, path: Path) -> bool: - try: - path.relative_to(self.root_dir) - return True - except ValueError: - return False - - def _internal_path_detail(self, path: Path) -> str: - if self._is_internal_path(path): - return str(path) - return f"{path} (must live under {self.root_dir})" +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.resolve() + 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 = Path(str(settings[group][name])).resolve() + checks.append( + { + "name": f"{group}.{name}", + "ok": path.exists() and self._is_internal_path(path), + "detail": self._internal_path_detail(path), + } + ) + + for group, name in ( + ("ingest", "ffprobe_bin"), + ("transcribe", "ffmpeg_bin"), + ): + 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) + checks.append({"name": f"{group}.{name}", "ok": ok, "detail": str(found or value)}) + + if str(settings.get("ingest", {}).get("provider", "local_file")) == "bilibili_url": + value = str(settings["ingest"].get("yt_dlp_cmd", "yt-dlp")) + 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) + checks.append({"name": "ingest.yt_dlp_cmd", "ok": ok, "detail": str(found or value)}) + + song_detect_provider = str(settings.get("song_detect", {}).get("provider", "codex")) + song_detect_cmd_name = "qwen_cmd" if song_detect_provider == "qwen_cli" else "codex_cmd" + song_detect_cmd = str(settings["song_detect"].get(song_detect_cmd_name, "")) + found = shutil.which(song_detect_cmd) if "/" not in song_detect_cmd else str((self.root_dir / song_detect_cmd).resolve()) + ok = bool(found) and (Path(found).exists() if "/" in str(found) else True) + checks.append({"name": f"song_detect.{song_detect_cmd_name}", "ok": ok, "detail": str(found or song_detect_cmd)}) + + publish_biliup_path = Path(str(settings["publish"]["biliup_path"])).resolve() + checks.append( + { + "name": "publish.biliup_path", + "ok": publish_biliup_path.exists() and self._is_internal_path(publish_biliup_path), + "detail": self._internal_path_detail(publish_biliup_path), + } + ) + + return { + "ok": all(item["ok"] for item in checks), + "checks": checks, + } + + def _is_internal_path(self, path: Path) -> bool: + try: + path.relative_to(self.root_dir) + return True + except ValueError: + return False + + def _internal_path_detail(self, path: Path) -> str: + if self._is_internal_path(path): + return str(path) + return f"{path} (must live under {self.root_dir})" diff --git a/src/biliup_next/infra/stage_importer.py b/src/biliup_next/infra/stage_importer.py index 7943fe7..9a21f7a 100644 --- a/src/biliup_next/infra/stage_importer.py +++ b/src/biliup_next/infra/stage_importer.py @@ -1,93 +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" +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 index b6499a7..d3633d4 100644 --- a/src/biliup_next/infra/storage_guard.py +++ b/src/biliup_next/infra/storage_guard.py @@ -1,41 +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) +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 index df96395..5e068de 100644 --- a/src/biliup_next/infra/systemd_runtime.py +++ b/src/biliup_next/infra/systemd_runtime.py @@ -1,82 +1,82 @@ -from __future__ import annotations - -import subprocess - -ALLOWED_SERVICES = { - "biliup-next-worker.service", - "biliup-next-api.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 +from __future__ import annotations + +import subprocess + +ALLOWED_SERVICES = { + "biliup-next-worker.service", + "biliup-next-api.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 index e66605f..9c2fec0 100644 --- a/src/biliup_next/infra/task_repository.py +++ b/src/biliup_next/infra/task_repository.py @@ -1,594 +1,594 @@ -from __future__ import annotations - -from biliup_next.core.models import ActionRecord, Artifact, PublishRecord, SessionBinding, Task, TaskContext, TaskStep -from biliup_next.infra.db import Database - - -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 _build_task_query( - self, - *, - status: str | None = None, - search: str | None = None, - ) -> tuple[str, list[object]]: - conditions: list[str] = [] - params: list[object] = [] - if status: - conditions.append("status = ?") - params.append(status) - if search: - conditions.append("(id LIKE ? OR title LIKE ?)") - needle = f"%{search}%" - params.extend([needle, needle]) - where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" - return where_clause, params - - 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 query_tasks( - self, - *, - limit: int = 100, - offset: int = 0, - status: str | None = None, - search: str | None = None, - sort: str = "updated_desc", - ) -> tuple[list[Task], int]: - sort_sql = { - "updated_desc": "updated_at DESC", - "updated_asc": "updated_at ASC", - "title_asc": "title COLLATE NOCASE ASC", - "title_desc": "title COLLATE NOCASE DESC", - "created_desc": "created_at DESC", - "created_asc": "created_at ASC", - "status_asc": "status ASC, updated_at DESC", - }.get(sort, "updated_at DESC") - where_clause, params = self._build_task_query(status=status, search=search) - with self.db.connect() as conn: - total = conn.execute( - f"SELECT COUNT(*) AS count FROM tasks {where_clause}", - params, - ).fetchone()["count"] - rows = conn.execute( - f""" - SELECT id, source_type, source_path, title, status, created_at, updated_at - FROM tasks - {where_clause} - ORDER BY {sort_sql} - LIMIT ? OFFSET ? - """, - [*params, limit, offset], - ).fetchall() - return [Task(**dict(row)) for row in rows], int(total) - - 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_contexts 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 claim_step_running(self, task_id: str, step_name: str, *, started_at: str) -> bool: - with self.db.connect() as conn: - result = conn.execute( - """ - UPDATE task_steps - SET status = ?, started_at = ?, finished_at = NULL, error_code = NULL, error_message = NULL - WHERE task_id = ? AND step_name = ? AND status IN (?, ?) - """, - ("running", started_at, task_id, step_name, "pending", "failed_retryable"), - ) - conn.commit() - return result.rowcount == 1 - - 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 upsert_task_context(self, context: TaskContext) -> None: - with self.db.connect() as conn: - conn.execute( - """ - INSERT INTO task_contexts ( - task_id, session_key, streamer, room_id, source_title, - segment_started_at, segment_duration_seconds, full_video_bvid, - created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(task_id) DO UPDATE SET - session_key=excluded.session_key, - streamer=excluded.streamer, - room_id=excluded.room_id, - source_title=excluded.source_title, - segment_started_at=excluded.segment_started_at, - segment_duration_seconds=excluded.segment_duration_seconds, - full_video_bvid=excluded.full_video_bvid, - updated_at=excluded.updated_at - """, - ( - context.task_id, - context.session_key, - context.streamer, - context.room_id, - context.source_title, - context.segment_started_at, - context.segment_duration_seconds, - context.full_video_bvid, - context.created_at, - context.updated_at, - ), - ) - conn.commit() - - def get_task_context(self, task_id: str) -> TaskContext | None: - with self.db.connect() as conn: - row = conn.execute( - """ - SELECT id, task_id, session_key, streamer, room_id, source_title, - segment_started_at, segment_duration_seconds, full_video_bvid, - created_at, updated_at - FROM task_contexts - WHERE task_id = ? - """, - (task_id,), - ).fetchone() - return TaskContext(**dict(row)) if row else None - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - with self.db.connect() as conn: - rows = conn.execute( - """ - SELECT id, task_id, session_key, streamer, room_id, source_title, - segment_started_at, segment_duration_seconds, full_video_bvid, - created_at, updated_at - FROM task_contexts - WHERE session_key = ? - ORDER BY segment_started_at ASC, id ASC - """, - (session_key,), - ).fetchall() - return [TaskContext(**dict(row)) for row in rows] - - def list_task_contexts_by_source_title(self, source_title: str) -> list[TaskContext]: - with self.db.connect() as conn: - rows = conn.execute( - """ - SELECT id, task_id, session_key, streamer, room_id, source_title, - segment_started_at, segment_duration_seconds, full_video_bvid, - created_at, updated_at - FROM task_contexts - WHERE source_title = ? - ORDER BY COALESCE(segment_started_at, updated_at) ASC, id ASC - """, - (source_title,), - ).fetchall() - return [TaskContext(**dict(row)) for row in rows] - - def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: - if not task_ids: - return {} - placeholders = ", ".join("?" for _ in task_ids) - with self.db.connect() as conn: - rows = conn.execute( - f""" - SELECT id, task_id, session_key, streamer, room_id, source_title, - segment_started_at, segment_duration_seconds, full_video_bvid, - created_at, updated_at - FROM task_contexts - WHERE task_id IN ({placeholders}) - """, - task_ids, - ).fetchall() - return {row["task_id"]: TaskContext(**dict(row)) for row in rows} - - def find_recent_task_contexts(self, streamer: str, limit: int = 20) -> list[TaskContext]: - with self.db.connect() as conn: - rows = conn.execute( - """ - SELECT id, task_id, session_key, streamer, room_id, source_title, - segment_started_at, segment_duration_seconds, full_video_bvid, - created_at, updated_at - FROM task_contexts - WHERE streamer = ? - ORDER BY COALESCE(segment_started_at, updated_at) DESC, id DESC - LIMIT ? - """, - (streamer, limit), - ).fetchall() - return [TaskContext(**dict(row)) for row in rows] - - def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: - if not task_ids: - return {} - placeholders = ", ".join("?" for _ in task_ids) - with self.db.connect() as conn: - rows = conn.execute( - f""" - SELECT id, task_id, step_name, status, error_code, error_message, - retry_count, started_at, finished_at - FROM task_steps - WHERE task_id IN ({placeholders}) - ORDER BY id ASC - """, - task_ids, - ).fetchall() - result: dict[str, list[TaskStep]] = {} - for row in rows: - step = TaskStep(**dict(row)) - result.setdefault(step.task_id, []).append(step) - return result - - def update_session_full_video_bvid(self, session_key: str, full_video_bvid: str, updated_at: str) -> int: - with self.db.connect() as conn: - result = conn.execute( - """ - UPDATE task_contexts - SET full_video_bvid = ?, updated_at = ? - WHERE session_key = ? - """, - (full_video_bvid, updated_at, session_key), - ) - conn.commit() - return result.rowcount - - def upsert_session_binding(self, binding: SessionBinding) -> None: - with self.db.connect() as conn: - if binding.session_key: - conn.execute( - """ - INSERT INTO session_bindings ( - session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(session_key) DO UPDATE SET - source_title=excluded.source_title, - streamer=excluded.streamer, - room_id=excluded.room_id, - full_video_bvid=excluded.full_video_bvid, - updated_at=excluded.updated_at - """, - ( - binding.session_key, - binding.source_title, - binding.streamer, - binding.room_id, - binding.full_video_bvid, - binding.created_at, - binding.updated_at, - ), - ) - else: - existing = conn.execute( - """ - SELECT id - FROM session_bindings - WHERE source_title = ? - ORDER BY id DESC - LIMIT 1 - """, - (binding.source_title,), - ).fetchone() - if existing: - conn.execute( - """ - UPDATE session_bindings - SET streamer = ?, room_id = ?, full_video_bvid = ?, updated_at = ? - WHERE id = ? - """, - ( - binding.streamer, - binding.room_id, - binding.full_video_bvid, - binding.updated_at, - existing["id"], - ), - ) - else: - conn.execute( - """ - INSERT INTO session_bindings ( - session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - binding.session_key, - binding.source_title, - binding.streamer, - binding.room_id, - binding.full_video_bvid, - binding.created_at, - binding.updated_at, - ), - ) - conn.commit() - - def get_session_binding(self, *, session_key: str | None = None, source_title: str | None = None) -> SessionBinding | None: - with self.db.connect() as conn: - row = None - if session_key: - row = conn.execute( - """ - SELECT id, session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at - FROM session_bindings - WHERE session_key = ? - LIMIT 1 - """, - (session_key,), - ).fetchone() - if row is None and source_title: - row = conn.execute( - """ - SELECT id, session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at - FROM session_bindings - WHERE source_title = ? - ORDER BY id DESC - LIMIT 1 - """, - (source_title,), - ).fetchone() - return SessionBinding(**dict(row)) if row else None - - 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] +from __future__ import annotations + +from biliup_next.core.models import ActionRecord, Artifact, PublishRecord, SessionBinding, Task, TaskContext, TaskStep +from biliup_next.infra.db import Database + + +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 _build_task_query( + self, + *, + status: str | None = None, + search: str | None = None, + ) -> tuple[str, list[object]]: + conditions: list[str] = [] + params: list[object] = [] + if status: + conditions.append("status = ?") + params.append(status) + if search: + conditions.append("(id LIKE ? OR title LIKE ?)") + needle = f"%{search}%" + params.extend([needle, needle]) + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" + return where_clause, params + + 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 query_tasks( + self, + *, + limit: int = 100, + offset: int = 0, + status: str | None = None, + search: str | None = None, + sort: str = "updated_desc", + ) -> tuple[list[Task], int]: + sort_sql = { + "updated_desc": "updated_at DESC", + "updated_asc": "updated_at ASC", + "title_asc": "title COLLATE NOCASE ASC", + "title_desc": "title COLLATE NOCASE DESC", + "created_desc": "created_at DESC", + "created_asc": "created_at ASC", + "status_asc": "status ASC, updated_at DESC", + }.get(sort, "updated_at DESC") + where_clause, params = self._build_task_query(status=status, search=search) + with self.db.connect() as conn: + total = conn.execute( + f"SELECT COUNT(*) AS count FROM tasks {where_clause}", + params, + ).fetchone()["count"] + rows = conn.execute( + f""" + SELECT id, source_type, source_path, title, status, created_at, updated_at + FROM tasks + {where_clause} + ORDER BY {sort_sql} + LIMIT ? OFFSET ? + """, + [*params, limit, offset], + ).fetchall() + return [Task(**dict(row)) for row in rows], int(total) + + 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_contexts 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 claim_step_running(self, task_id: str, step_name: str, *, started_at: str) -> bool: + with self.db.connect() as conn: + result = conn.execute( + """ + UPDATE task_steps + SET status = ?, started_at = ?, finished_at = NULL, error_code = NULL, error_message = NULL + WHERE task_id = ? AND step_name = ? AND status IN (?, ?) + """, + ("running", started_at, task_id, step_name, "pending", "failed_retryable"), + ) + conn.commit() + return result.rowcount == 1 + + 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 upsert_task_context(self, context: TaskContext) -> None: + with self.db.connect() as conn: + conn.execute( + """ + INSERT INTO task_contexts ( + task_id, session_key, streamer, room_id, source_title, + segment_started_at, segment_duration_seconds, full_video_bvid, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + session_key=excluded.session_key, + streamer=excluded.streamer, + room_id=excluded.room_id, + source_title=excluded.source_title, + segment_started_at=excluded.segment_started_at, + segment_duration_seconds=excluded.segment_duration_seconds, + full_video_bvid=excluded.full_video_bvid, + updated_at=excluded.updated_at + """, + ( + context.task_id, + context.session_key, + context.streamer, + context.room_id, + context.source_title, + context.segment_started_at, + context.segment_duration_seconds, + context.full_video_bvid, + context.created_at, + context.updated_at, + ), + ) + conn.commit() + + def get_task_context(self, task_id: str) -> TaskContext | None: + with self.db.connect() as conn: + row = conn.execute( + """ + SELECT id, task_id, session_key, streamer, room_id, source_title, + segment_started_at, segment_duration_seconds, full_video_bvid, + created_at, updated_at + FROM task_contexts + WHERE task_id = ? + """, + (task_id,), + ).fetchone() + return TaskContext(**dict(row)) if row else None + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT id, task_id, session_key, streamer, room_id, source_title, + segment_started_at, segment_duration_seconds, full_video_bvid, + created_at, updated_at + FROM task_contexts + WHERE session_key = ? + ORDER BY segment_started_at ASC, id ASC + """, + (session_key,), + ).fetchall() + return [TaskContext(**dict(row)) for row in rows] + + def list_task_contexts_by_source_title(self, source_title: str) -> list[TaskContext]: + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT id, task_id, session_key, streamer, room_id, source_title, + segment_started_at, segment_duration_seconds, full_video_bvid, + created_at, updated_at + FROM task_contexts + WHERE source_title = ? + ORDER BY COALESCE(segment_started_at, updated_at) ASC, id ASC + """, + (source_title,), + ).fetchall() + return [TaskContext(**dict(row)) for row in rows] + + def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: + if not task_ids: + return {} + placeholders = ", ".join("?" for _ in task_ids) + with self.db.connect() as conn: + rows = conn.execute( + f""" + SELECT id, task_id, session_key, streamer, room_id, source_title, + segment_started_at, segment_duration_seconds, full_video_bvid, + created_at, updated_at + FROM task_contexts + WHERE task_id IN ({placeholders}) + """, + task_ids, + ).fetchall() + return {row["task_id"]: TaskContext(**dict(row)) for row in rows} + + def find_recent_task_contexts(self, streamer: str, limit: int = 20) -> list[TaskContext]: + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT id, task_id, session_key, streamer, room_id, source_title, + segment_started_at, segment_duration_seconds, full_video_bvid, + created_at, updated_at + FROM task_contexts + WHERE streamer = ? + ORDER BY COALESCE(segment_started_at, updated_at) DESC, id DESC + LIMIT ? + """, + (streamer, limit), + ).fetchall() + return [TaskContext(**dict(row)) for row in rows] + + def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: + if not task_ids: + return {} + placeholders = ", ".join("?" for _ in task_ids) + with self.db.connect() as conn: + rows = conn.execute( + f""" + SELECT id, task_id, step_name, status, error_code, error_message, + retry_count, started_at, finished_at + FROM task_steps + WHERE task_id IN ({placeholders}) + ORDER BY id ASC + """, + task_ids, + ).fetchall() + result: dict[str, list[TaskStep]] = {} + for row in rows: + step = TaskStep(**dict(row)) + result.setdefault(step.task_id, []).append(step) + return result + + def update_session_full_video_bvid(self, session_key: str, full_video_bvid: str, updated_at: str) -> int: + with self.db.connect() as conn: + result = conn.execute( + """ + UPDATE task_contexts + SET full_video_bvid = ?, updated_at = ? + WHERE session_key = ? + """, + (full_video_bvid, updated_at, session_key), + ) + conn.commit() + return result.rowcount + + def upsert_session_binding(self, binding: SessionBinding) -> None: + with self.db.connect() as conn: + if binding.session_key: + conn.execute( + """ + INSERT INTO session_bindings ( + session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_key) DO UPDATE SET + source_title=excluded.source_title, + streamer=excluded.streamer, + room_id=excluded.room_id, + full_video_bvid=excluded.full_video_bvid, + updated_at=excluded.updated_at + """, + ( + binding.session_key, + binding.source_title, + binding.streamer, + binding.room_id, + binding.full_video_bvid, + binding.created_at, + binding.updated_at, + ), + ) + else: + existing = conn.execute( + """ + SELECT id + FROM session_bindings + WHERE source_title = ? + ORDER BY id DESC + LIMIT 1 + """, + (binding.source_title,), + ).fetchone() + if existing: + conn.execute( + """ + UPDATE session_bindings + SET streamer = ?, room_id = ?, full_video_bvid = ?, updated_at = ? + WHERE id = ? + """, + ( + binding.streamer, + binding.room_id, + binding.full_video_bvid, + binding.updated_at, + existing["id"], + ), + ) + else: + conn.execute( + """ + INSERT INTO session_bindings ( + session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + binding.session_key, + binding.source_title, + binding.streamer, + binding.room_id, + binding.full_video_bvid, + binding.created_at, + binding.updated_at, + ), + ) + conn.commit() + + def get_session_binding(self, *, session_key: str | None = None, source_title: str | None = None) -> SessionBinding | None: + with self.db.connect() as conn: + row = None + if session_key: + row = conn.execute( + """ + SELECT id, session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at + FROM session_bindings + WHERE session_key = ? + LIMIT 1 + """, + (session_key,), + ).fetchone() + if row is None and source_title: + row = conn.execute( + """ + SELECT id, session_key, source_title, streamer, room_id, full_video_bvid, created_at, updated_at + FROM session_bindings + WHERE source_title = ? + ORDER BY id DESC + LIMIT 1 + """, + (source_title,), + ).fetchone() + return SessionBinding(**dict(row)) if row else None + + 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] diff --git a/src/biliup_next/infra/task_reset.py b/src/biliup_next/infra/task_reset.py index 8b4ae30..47a096f 100644 --- a/src/biliup_next/infra/task_reset.py +++ b/src/biliup_next/infra/task_reset.py @@ -115,7 +115,6 @@ class TaskResetService: 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", diff --git a/src/biliup_next/infra/video_links.py b/src/biliup_next/infra/video_links.py new file mode 100644 index 0000000..8c7955c --- /dev/null +++ b/src/biliup_next/infra/video_links.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import re +from typing import Any + +from biliup_next.infra.adapters.full_video_locator import fetch_biliup_list, resolve_full_video_bvid +from biliup_next.infra.workspace_paths import resolve_task_work_dir + + +def bilibili_video_url(bvid: str | None) -> str: + bvid = (bvid or "").strip() + return f"https://www.bilibili.com/video/{bvid}" if bvid.startswith("BV") else "" + + +def read_task_split_bvid(task: Any) -> str: + path = resolve_task_work_dir(task) / "bvid.txt" + return _read_bvid(path) + + +def read_task_full_bvid(task: Any, context: Any | None = None) -> str: + if context is not None and getattr(context, "full_video_bvid", None): + return str(context.full_video_bvid).strip() + path = resolve_task_work_dir(task) / "full_video_bvid.txt" + return _read_bvid(path) + + +def link_context_for_task(task: Any, repo: Any | None, settings: dict[str, Any] | None = None) -> dict[str, str]: + context = _get_context(repo, task.id) + full_bvid = read_task_full_bvid(task, context) + if not full_bvid: + full_bvid = resolve_current_full_video_bvid(task, settings) + split_bvid = read_task_split_bvid(task) + previous = previous_live_links(task, repo, context, settings) + return { + "current_full_video_bvid": full_bvid, + "current_full_video_link": bilibili_video_url(full_bvid), + "current_pure_video_bvid": split_bvid, + "current_pure_video_link": bilibili_video_url(split_bvid), + "previous_full_video_bvid": previous.get("previous_full_video_bvid", ""), + "previous_full_video_link": previous.get("previous_full_video_link", ""), + "previous_pure_video_bvid": previous.get("previous_pure_video_bvid", ""), + "previous_pure_video_link": previous.get("previous_pure_video_link", ""), + } + + +def resolve_current_full_video_bvid(task: Any, settings: dict[str, Any] | None = None) -> str: + if not settings or not settings.get("biliup_path") or not settings.get("cookie_file"): + return "" + try: + return resolve_full_video_bvid(task.title, resolve_task_work_dir(task), settings) or "" + except Exception: + return "" + + +def previous_live_links( + task: Any, + repo: Any | None, + context: Any | None = None, + settings: dict[str, Any] | None = None, +) -> dict[str, str]: + context = context or _get_context(repo, task.id) + streamer = _context_streamer(context) or _parse_streamer_from_title(task.title) + if not streamer: + return {} + + current_started = _parse_datetime(getattr(context, "segment_started_at", None)) if context is not None else None + if current_started is None: + current_started = _parse_title_datetime(task.title) + current_session_key = getattr(context, "session_key", None) if context is not None else None + + previous: dict[str, str] = {} + if repo is not None and hasattr(repo, "find_recent_task_contexts") and hasattr(repo, "get_task"): + for candidate in repo.find_recent_task_contexts(streamer, limit=50): + if candidate.task_id == task.id: + continue + if current_session_key and getattr(candidate, "session_key", None) == current_session_key: + continue + candidate_started = _parse_datetime(getattr(candidate, "segment_started_at", None)) + if current_started is not None and candidate_started is not None and candidate_started >= current_started: + continue + candidate_task = repo.get_task(candidate.task_id) + if candidate_task is None: + continue + full_bvid = read_task_full_bvid(candidate_task, candidate) + split_bvid = read_task_split_bvid(candidate_task) + if full_bvid or split_bvid: + previous = { + "previous_full_video_bvid": full_bvid, + "previous_full_video_link": bilibili_video_url(full_bvid), + "previous_pure_video_bvid": split_bvid, + "previous_pure_video_link": bilibili_video_url(split_bvid), + } + break + if not previous.get("previous_full_video_bvid") or not previous.get("previous_pure_video_bvid"): + listed_previous = _previous_live_from_biliup_list(streamer, current_started, settings) + for key, value in listed_previous.items(): + if value and not previous.get(key): + previous[key] = value + return previous + + +def _get_context(repo: Any | None, task_id: str) -> Any | None: + if repo is None or not hasattr(repo, "get_task_context"): + return None + return repo.get_task_context(task_id) + + +def _context_streamer(context: Any | None) -> str: + if context is None: + return "" + return str(getattr(context, "streamer", "") or "").strip() + + +def _read_bvid(path: Path) -> str: + if not path.exists(): + return "" + bvid = path.read_text(encoding="utf-8").strip() + return bvid if bvid.startswith("BV") else "" + + +def _parse_datetime(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def _parse_title_datetime(title: str) -> datetime | None: + patterns = ( + r"(?P\d{4})年(?P\d{1,2})月(?P\d{1,2})日\s+(?P\d{1,2})[时点](?P\d{1,2})分", + r"(?P\d{1,2})月(?P\d{1,2})日\s+(?P\d{1,2})[时点](?P\d{1,2})分", + ) + for pattern in patterns: + match = re.search(pattern, title) + if not match: + continue + data = match.groupdict() + year = int(data.get("year") or datetime.now().year) + try: + return datetime(year, int(data["month"]), int(data["day"]), int(data["hour"]), int(data["minute"])) + except ValueError: + return None + return None + + +def _parse_streamer_from_title(title: str) -> str: + marker = "唱歌录播" + if marker in title: + return title.split(marker, 1)[0].strip() + return "" + + +def _previous_live_from_biliup_list( + streamer: str, + current_started: datetime | None, + settings: dict[str, Any] | None, +) -> dict[str, str]: + if current_started is None or not settings or not settings.get("biliup_path") or not settings.get("cookie_file"): + return {} + try: + videos = fetch_biliup_list(settings) + except Exception: + return {} + + current_compare = current_started.replace(tzinfo=None) + full_candidates: list[tuple[datetime, str]] = [] + pure_candidates: list[tuple[datetime, str]] = [] + for video in videos: + title = video.get("title", "") + bvid = video.get("bvid", "") + if not bvid.startswith("BV"): + continue + if streamer not in title: + continue + started = _parse_title_datetime(title) + if started is not None and started > current_compare and "年" not in title: + started = started.replace(year=started.year - 1) + if started is None or started >= current_compare: + continue + if "纯享" in title: + pure_candidates.append((started, bvid)) + elif "唱歌录播" in title: + full_candidates.append((started, bvid)) + + if not full_candidates and not pure_candidates: + return {} + + full_bvid = max(full_candidates, key=lambda item: item[0])[1] if full_candidates else "" + pure_bvid = max(pure_candidates, key=lambda item: item[0])[1] if pure_candidates else "" + return { + "previous_full_video_bvid": full_bvid, + "previous_full_video_link": bilibili_video_url(full_bvid), + "previous_pure_video_bvid": pure_bvid, + "previous_pure_video_link": bilibili_video_url(pure_bvid), + } diff --git a/src/biliup_next/infra/workspace_cleanup.py b/src/biliup_next/infra/workspace_cleanup.py index 207ff47..ba998a4 100644 --- a/src/biliup_next/infra/workspace_cleanup.py +++ b/src/biliup_next/infra/workspace_cleanup.py @@ -1,6 +1,8 @@ from __future__ import annotations import shutil +from pathlib import Path +from typing import Any from biliup_next.infra.task_repository import TaskRepository from biliup_next.infra.workspace_paths import resolve_task_work_dir @@ -11,35 +13,59 @@ class WorkspaceCleanupService: 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: + cleanup_tasks = self._cleanup_tasks(task_id) + if not cleanup_tasks: raise RuntimeError(f"task not found: {task_id}") - session_dir = resolve_task_work_dir(task) removed: list[str] = [] skipped: list[str] = [] + cleaned_task_ids: list[str] = [] - if settings.get("delete_source_video_after_collection_synced", False): - source_path = Path(task.source_path).resolve() - try: - source_path.relative_to(session_dir) - source_managed = True - except ValueError: - source_managed = False - if source_path.exists() and source_managed: - 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)) + for task in cleanup_tasks: + session_dir = resolve_task_work_dir(task) + cleaned_task_ids.append(task.id) - 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)) + if settings.get("delete_source_video_after_collection_synced", False): + source_path = Path(task.source_path).resolve() + try: + source_path.relative_to(session_dir) + source_managed = True + except ValueError: + source_managed = False + if source_path.exists() and source_managed: + 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)) - return {"removed": removed, "skipped": skipped} + if settings.get("delete_split_videos_after_collection_synced", False): + for video_dir_name in ("split_video", "publish_video"): + video_dir = session_dir / video_dir_name + if video_dir.exists(): + shutil.rmtree(video_dir, ignore_errors=True) + removed.append(str(video_dir)) + else: + skipped.append(str(video_dir)) + self.repo.delete_artifacts(task.id, "clip_video") + + return {"removed": removed, "skipped": skipped, "task_ids": cleaned_task_ids} + + def _cleanup_tasks(self, task_id: str) -> list[Any]: + task = self.repo.get_task(task_id) + if task is None: + return [] + + if not hasattr(self.repo, "get_task_context") or not hasattr(self.repo, "list_task_contexts_by_session_key"): + return [task] + + context = self.repo.get_task_context(task_id) + if context is None or not context.session_key or context.session_key.startswith("task:"): + return [task] + + tasks = [] + for session_context in self.repo.list_task_contexts_by_session_key(context.session_key): + session_task = self.repo.get_task(session_context.task_id) + if session_task is not None: + tasks.append(session_task) + return tasks or [task] diff --git a/src/biliup_next/infra/workspace_paths.py b/src/biliup_next/infra/workspace_paths.py index 8fa2922..b6ad062 100644 --- a/src/biliup_next/infra/workspace_paths.py +++ b/src/biliup_next/infra/workspace_paths.py @@ -1,10 +1,10 @@ -from __future__ import annotations - -from pathlib import Path - - -def resolve_task_work_dir(task) -> Path: # type: ignore[no-untyped-def] - source = Path(task.source_path).resolve() - if source.is_file() or source.suffix: - return source.parent - return source +from __future__ import annotations + +from pathlib import Path + + +def resolve_task_work_dir(task) -> Path: # type: ignore[no-untyped-def] + source = Path(task.source_path).resolve() + if source.is_file() or source.suffix: + return source.parent + return source diff --git a/src/biliup_next/modules/collection/providers/bilibili_collection.py b/src/biliup_next/modules/collection/providers/bilibili_collection.py index ce90fc2..d1cee80 100644 --- a/src/biliup_next/modules/collection/providers/bilibili_collection.py +++ b/src/biliup_next/modules/collection/providers/bilibili_collection.py @@ -1,152 +1,152 @@ -from __future__ import annotations - -import json -import random -import time -from pathlib import Path -from typing import Any - -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.bilibili_api import BilibiliApiAdapter -from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid -from biliup_next.infra.workspace_paths import resolve_task_work_dir - - -class BilibiliCollectionProvider: - def __init__(self, bilibili_api: BilibiliApiAdapter | None = None) -> None: - self.bilibili_api = bilibili_api or BilibiliApiAdapter() - self._section_cache: dict[int, int | None] = {} - - manifest = ProviderManifest( - id="bilibili_collection", - name="Bilibili Collection Provider", - version="0.1.0", - provider_type="collection_provider", - entrypoint="biliup_next.modules.collection.providers.bilibili_collection:BilibiliCollectionProvider", - capabilities=["collection"], - enabled_by_default=True, - ) - - def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]: - session_dir = resolve_task_work_dir(task) - cookies = self.bilibili_api.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 = self.bilibili_api.build_session( - cookies=cookies, - 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) - add_result = self._add_videos_batch(session, csrf, section_id, [info]) - if add_result["status"] == "failed": - raise ModuleError( - code="COLLECTION_ADD_FAILED", - message=str(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, [int(info["aid"])]) - return {"status": add_result["status"], "target": target, "bvid": bvid, "season_id": season_id} - - def _resolve_section_id(self, session, season_id: int) -> int | None: # type: ignore[no-untyped-def] - if season_id in self._section_cache: - return self._section_cache[season_id] - result = self.bilibili_api.list_seasons(session) - 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 - - def _get_video_info(self, session, bvid: str) -> dict[str, object]: # type: ignore[no-untyped-def] - data = self.bilibili_api.get_video_view( - session, - bvid, - error_code="COLLECTION_VIDEO_INFO_FAILED", - error_message="获取视频信息失败", - ) - return {"aid": data["aid"], "cid": data["cid"], "title": data["title"], "charging_pay": 0} - - def _add_videos_batch(self, session, csrf: str, section_id: int, episodes: list[dict[str, object]]) -> dict[str, object]: # type: ignore[no-untyped-def] - time.sleep(random.uniform(5.0, 10.0)) - result = self.bilibili_api.add_section_episodes( - session, - csrf=csrf, - section_id=section_id, - episodes=episodes, - ) - 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")} - - def _move_videos_to_section_end(self, session, csrf: str, section_id: int, added_aids: list[int]) -> bool: # type: ignore[no-untyped-def] - detail = self.bilibili_api.get_section_detail(session, section_id=section_id) - 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": index + 1} for index, item in enumerate(ordered)], - } - result = self.bilibili_api.edit_section(session, csrf=csrf, payload=payload) - return result.get("code") == 0 +from __future__ import annotations + +import json +import random +import time +from pathlib import Path +from typing import Any + +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.bilibili_api import BilibiliApiAdapter +from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid +from biliup_next.infra.workspace_paths import resolve_task_work_dir + + +class BilibiliCollectionProvider: + def __init__(self, bilibili_api: BilibiliApiAdapter | None = None) -> None: + self.bilibili_api = bilibili_api or BilibiliApiAdapter() + self._section_cache: dict[int, int | None] = {} + + manifest = ProviderManifest( + id="bilibili_collection", + name="Bilibili Collection Provider", + version="0.1.0", + provider_type="collection_provider", + entrypoint="biliup_next.modules.collection.providers.bilibili_collection:BilibiliCollectionProvider", + capabilities=["collection"], + enabled_by_default=True, + ) + + def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]: + session_dir = resolve_task_work_dir(task) + cookies = self.bilibili_api.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 = self.bilibili_api.build_session( + cookies=cookies, + 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) + add_result = self._add_videos_batch(session, csrf, section_id, [info]) + if add_result["status"] == "failed": + raise ModuleError( + code="COLLECTION_ADD_FAILED", + message=str(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, [int(info["aid"])]) + return {"status": add_result["status"], "target": target, "bvid": bvid, "season_id": season_id} + + def _resolve_section_id(self, session, season_id: int) -> int | None: # type: ignore[no-untyped-def] + if season_id in self._section_cache: + return self._section_cache[season_id] + result = self.bilibili_api.list_seasons(session) + 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 + + def _get_video_info(self, session, bvid: str) -> dict[str, object]: # type: ignore[no-untyped-def] + data = self.bilibili_api.get_video_view( + session, + bvid, + error_code="COLLECTION_VIDEO_INFO_FAILED", + error_message="获取视频信息失败", + ) + return {"aid": data["aid"], "cid": data["cid"], "title": data["title"], "charging_pay": 0} + + def _add_videos_batch(self, session, csrf: str, section_id: int, episodes: list[dict[str, object]]) -> dict[str, object]: # type: ignore[no-untyped-def] + time.sleep(random.uniform(5.0, 10.0)) + result = self.bilibili_api.add_section_episodes( + session, + csrf=csrf, + section_id=section_id, + episodes=episodes, + ) + 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")} + + def _move_videos_to_section_end(self, session, csrf: str, section_id: int, added_aids: list[int]) -> bool: # type: ignore[no-untyped-def] + detail = self.bilibili_api.get_section_detail(session, section_id=section_id) + 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": index + 1} for index, item in enumerate(ordered)], + } + result = self.bilibili_api.edit_section(session, csrf=csrf, payload=payload) + return result.get("code") == 0 diff --git a/src/biliup_next/modules/collection/service.py b/src/biliup_next/modules/collection/service.py index fe2750f..417d687 100644 --- a/src/biliup_next/modules/collection/service.py +++ b/src/biliup_next/modules/collection/service.py @@ -31,4 +31,5 @@ class CollectionService: 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} + self.repo.update_task_status(task_id, "commented", finished_at) return result diff --git a/src/biliup_next/modules/comment/providers/bilibili_top_comment.py b/src/biliup_next/modules/comment/providers/bilibili_top_comment.py index 499e963..57ace2b 100644 --- a/src/biliup_next/modules/comment/providers/bilibili_top_comment.py +++ b/src/biliup_next/modules/comment/providers/bilibili_top_comment.py @@ -11,9 +11,34 @@ from biliup_next.core.models import Task from biliup_next.core.providers import ProviderManifest from biliup_next.infra.adapters.bilibili_api import BilibiliApiAdapter from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid +from biliup_next.infra.video_links import bilibili_video_url, link_context_for_task from biliup_next.infra.workspace_paths import resolve_task_work_dir +DEFAULT_COMMENT_TEMPLATE = { + "split_header": ( + "当前视频:歌曲纯享版:只保留本场直播中的歌曲片段,歌单见下方。\n" + "直播完整版:{current_full_video_link} (完整录播,含聊天/互动/完整流程)\n" + "上次纯享:{previous_pure_video_link} (上一场歌曲纯享版)" + ), + "full_header": ( + "当前视频:直播完整版:保留本场完整录播内容,歌曲时间轴见下方。\n" + "歌曲纯享版:{current_pure_video_link} (只听歌曲看这里)\n" + "上次完整版:{previous_full_video_link} (上一场完整录播)" + ), + "split_part_header": "P{part_index}:", + "full_part_header": "P{part_index}:", + "split_song_line": "{song_index}. {title}{artist_suffix}", + "split_text_song_line": "{song_index}. {song_text}", + "full_timeline_line": "{song_index}. {line_text}", +} + + +class _SafeFormatDict(dict): + def __missing__(self, key: str) -> str: + return "" + + class BilibiliTopCommentProvider: def __init__(self, bilibili_api: BilibiliApiAdapter | None = None) -> None: self.bilibili_api = bilibili_api or BilibiliApiAdapter() @@ -41,7 +66,8 @@ class BilibiliTopCommentProvider: ) timeline_content = songs_path.read_text(encoding="utf-8").strip() - split_content, split_reason = self._build_split_comment(task, settings) + comment_template = self._load_comment_template(settings) + split_content, split_reason = self._build_split_comment(task, settings, comment_template) 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"} @@ -78,7 +104,7 @@ class BilibiliTopCommentProvider: if settings.get("post_full_video_timeline_comment", True) and not full_done: full_bvid = resolve_full_video_bvid(task.title, session_dir, settings) - full_content, full_reason = self._build_full_comment_content(task, settings) + full_content, full_reason = self._build_full_comment_content(task, settings, comment_template) if full_reason is not None: full_result = {"status": "skipped", "reason": full_reason} elif full_bvid and full_content: @@ -135,44 +161,116 @@ class BilibiliTopCommentProvider: return {"status": "ok", "bvid": bvid, "aid": aid, "rpid": rpid} @staticmethod - def _build_split_comment_content(songs_json_path: Path, songs_txt_path: Path) -> str: + def _build_split_comment_content( + songs_json_path: Path, + songs_txt_path: Path, + *, + start_index: int = 1, + comment_template: dict[str, str] | None = None, + ) -> tuple[str, int]: + comment_template = comment_template or DEFAULT_COMMENT_TEMPLATE + next_index = start_index 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): + for song in data.get("songs", []): 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}") + lines.append( + BilibiliTopCommentProvider._format_template( + comment_template.get("split_song_line", DEFAULT_COMMENT_TEMPLATE["split_song_line"]), + { + "song_index": str(next_index), + "title": title, + "artist": artist, + "artist_suffix": suffix, + }, + ) + ) + next_index += 1 if lines: - return "\n".join(lines) + return "\n".join(lines), next_index 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): + for raw in songs_txt_path.read_text(encoding="utf-8").splitlines(): 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 "" + lines.append( + BilibiliTopCommentProvider._format_template( + comment_template.get("split_text_song_line", DEFAULT_COMMENT_TEMPLATE["split_text_song_line"]), + { + "song_index": str(next_index), + "song_text": song_text, + "line_text": text, + }, + ) + ) + next_index += 1 + return "\n".join(lines), next_index + return "", next_index - def _build_split_comment(self, task: Task, settings: dict[str, Any]) -> tuple[str, str | None]: + @staticmethod + def _build_full_timeline_content( + songs_txt_path: Path, + *, + start_index: int = 1, + comment_template: dict[str, str] | None = None, + ) -> tuple[str, int]: + if not songs_txt_path.exists(): + return "", start_index + comment_template = comment_template or DEFAULT_COMMENT_TEMPLATE + next_index = start_index + lines = [] + for raw in songs_txt_path.read_text(encoding="utf-8").splitlines(): + text = raw.strip() + if not text: + continue + lines.append( + BilibiliTopCommentProvider._format_template( + comment_template.get("full_timeline_line", DEFAULT_COMMENT_TEMPLATE["full_timeline_line"]), + { + "song_index": str(next_index), + "line_text": text, + }, + ) + ) + next_index += 1 + return "\n".join(lines), next_index + + def _build_split_comment( + self, + task: Task, + settings: dict[str, Any], + comment_template: dict[str, str], + ) -> tuple[str, str | None]: repo = settings.get("__repo") if repo is None or not hasattr(repo, "get_task_context") or not hasattr(repo, "list_task_contexts_by_session_key"): session_dir = resolve_task_work_dir(task) - return self._build_split_comment_content(session_dir / "songs.json", session_dir / "songs.txt"), None + content, _ = self._build_split_comment_content( + session_dir / "songs.json", + session_dir / "songs.txt", + comment_template=comment_template, + ) + return self._with_split_footer(content, task, settings, comment_template), None context = repo.get_task_context(task.id) if context is None or not context.session_key or context.session_key.startswith("task:"): session_dir = resolve_task_work_dir(task) - return self._build_split_comment_content(session_dir / "songs.json", session_dir / "songs.txt"), None + content, _ = self._build_split_comment_content( + session_dir / "songs.json", + session_dir / "songs.txt", + comment_template=comment_template, + ) + return self._with_split_footer(content, task, settings, comment_template), None ordered_contexts = self._ordered_session_contexts(repo, context.session_key) if not ordered_contexts: @@ -182,31 +280,42 @@ class BilibiliTopCommentProvider: return "", "session_split_comment_owned_by_anchor" blocks: list[str] = [] + next_song_index = 1 for index, session_context in enumerate(ordered_contexts, start=1): session_task = repo.get_task(session_context.task_id) if session_task is None: continue task_dir = resolve_task_work_dir(session_task) - content = self._build_split_comment_content(task_dir / "songs.json", task_dir / "songs.txt") + content, next_song_index = self._build_split_comment_content( + task_dir / "songs.json", + task_dir / "songs.txt", + start_index=next_song_index, + comment_template=comment_template, + ) if not content: continue - blocks.append(f"P{index}:\n{content}") + blocks.append(f"{self._part_header(comment_template, 'split_part_header', index)}\n{content}") if not blocks: return "", "split_comment_empty" - return "\n\n".join(blocks), None + return self._with_split_footer("\n\n".join(blocks), task, settings, comment_template), None - def _build_full_comment_content(self, task: Task, settings: dict[str, Any]) -> tuple[str, str | None]: + def _build_full_comment_content( + self, + task: Task, + settings: dict[str, Any], + comment_template: dict[str, str], + ) -> tuple[str, str | None]: repo = settings.get("__repo") if repo is None or not hasattr(repo, "get_task_context") or not hasattr(repo, "list_task_contexts_by_session_key"): session_dir = resolve_task_work_dir(task) - content = session_dir.joinpath("songs.txt").read_text(encoding="utf-8").strip() - return content, None if content else "timeline_comment_empty" + content, _ = self._build_full_timeline_content(session_dir / "songs.txt", comment_template=comment_template) + return self._with_full_footer(content, task, settings, comment_template), None if content else "timeline_comment_empty" context = repo.get_task_context(task.id) if context is None or not context.session_key or context.session_key.startswith("task:"): session_dir = resolve_task_work_dir(task) - content = session_dir.joinpath("songs.txt").read_text(encoding="utf-8").strip() - return content, None if content else "timeline_comment_empty" + content, _ = self._build_full_timeline_content(session_dir / "songs.txt", comment_template=comment_template) + return self._with_full_footer(content, task, settings, comment_template), None if content else "timeline_comment_empty" ordered_contexts = self._ordered_session_contexts(repo, context.session_key) if not ordered_contexts: @@ -216,21 +325,109 @@ class BilibiliTopCommentProvider: return "", "session_full_comment_owned_by_anchor" blocks: list[str] = [] + next_song_index = 1 for index, session_context in enumerate(ordered_contexts, start=1): session_task = repo.get_task(session_context.task_id) if session_task is None: continue task_dir = resolve_task_work_dir(session_task) songs_path = task_dir / "songs.txt" - if not songs_path.exists(): - continue - content = songs_path.read_text(encoding="utf-8").strip() + content, next_song_index = self._build_full_timeline_content( + songs_path, + start_index=next_song_index, + comment_template=comment_template, + ) if not content: continue - blocks.append(f"P{index}:\n{content}") + blocks.append(f"{self._part_header(comment_template, 'full_part_header', index)}\n{content}") if not blocks: return "", "timeline_comment_empty" - return "\n\n".join(blocks), None + return self._with_full_footer("\n\n".join(blocks), task, settings, comment_template), None + + def _with_split_footer( + self, + content: str, + task: Task, + settings: dict[str, Any], + comment_template: dict[str, str], + ) -> str: + links = link_context_for_task(task, settings.get("__repo"), settings) + current_full_link = links.get("current_full_video_link", "") + if not current_full_link and settings.get("biliup_path") and settings.get("cookie_file"): + full_bvid = resolve_full_video_bvid(task.title, resolve_task_work_dir(task), settings) + current_full_link = bilibili_video_url(full_bvid) + header_vars = dict(links) + header_vars["current_full_video_link"] = current_full_link + header = self._format_header_template( + comment_template.get("split_header", DEFAULT_COMMENT_TEMPLATE["split_header"]), + header_vars, + ) + return self._prepend_header(content, header) + + def _with_full_footer( + self, + content: str, + task: Task, + settings: dict[str, Any], + comment_template: dict[str, str], + ) -> str: + links = link_context_for_task(task, settings.get("__repo"), settings) + header = self._format_header_template( + comment_template.get("full_header", DEFAULT_COMMENT_TEMPLATE["full_header"]), + links, + ) + return self._prepend_header(content, header) + + @staticmethod + def _prepend_header(content: str, header: str) -> str: + content = content.strip() + lines = [line.rstrip() for line in header.splitlines() if line.strip()] + if not content: + return "\n".join(lines) + if not lines: + return content + return "\n".join(lines) + f"\n\n{content}" + + @staticmethod + def _part_header(comment_template: dict[str, str], key: str, part_index: int) -> str: + return BilibiliTopCommentProvider._format_template( + comment_template.get(key, DEFAULT_COMMENT_TEMPLATE[key]), + {"part_index": str(part_index)}, + ) + + @staticmethod + def _format_template(template: str, values: dict[str, str]) -> str: + return template.format_map(_SafeFormatDict(values)).strip() + + @staticmethod + def _format_header_template(template: str, values: dict[str, str]) -> str: + lines = [] + for raw_line in template.splitlines(): + if any(f"{{{key}}}" in raw_line and not value for key, value in values.items()): + continue + lines.append(BilibiliTopCommentProvider._format_template(raw_line, values)) + return "\n".join(line for line in lines if line.strip()).strip() + + @staticmethod + def _load_comment_template(settings: dict[str, Any]) -> dict[str, str]: + merged = dict(DEFAULT_COMMENT_TEMPLATE) + path_value = settings.get("upload_config_file") + if not path_value: + return merged + path = Path(str(path_value)) + if not path.exists(): + return merged + try: + config = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return merged + template = config.get("comment_template", {}) + if not isinstance(template, dict): + return merged + for key, value in template.items(): + if key in merged and isinstance(value, str): + merged[key] = value + return merged def _ordered_session_contexts(self, repo, session_key: str) -> list[object]: # type: ignore[no-untyped-def] contexts = list(repo.list_task_contexts_by_session_key(session_key)) diff --git a/src/biliup_next/modules/comment/service.py b/src/biliup_next/modules/comment/service.py index 0195862..c1ad763 100644 --- a/src/biliup_next/modules/comment/service.py +++ b/src/biliup_next/modules/comment/service.py @@ -1,26 +1,26 @@ -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"))) - provider_settings = dict(settings) - provider_settings["__repo"] = self.repo - started_at = utc_now_iso() - self.repo.update_step_status(task_id, "comment", "running", started_at=started_at) - result = provider.comment(task, provider_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 +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"))) + provider_settings = dict(settings) + provider_settings["__repo"] = self.repo + started_at = utc_now_iso() + self.repo.update_step_status(task_id, "comment", "running", started_at=started_at) + result = provider.comment(task, provider_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/bilibili_url.py b/src/biliup_next/modules/ingest/providers/bilibili_url.py index 4a64c41..9a92d7a 100644 --- a/src/biliup_next/modules/ingest/providers/bilibili_url.py +++ b/src/biliup_next/modules/ingest/providers/bilibili_url.py @@ -1,128 +1,128 @@ -from __future__ import annotations - -import re -from pathlib import Path -from typing import Any - -from biliup_next.core.errors import ModuleError -from biliup_next.core.providers import ProviderManifest -from biliup_next.infra.adapters.yt_dlp import YtDlpAdapter - -FORBIDDEN_PATH_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') -URL_PREFIXES = ( - "https://www.bilibili.com/video/", - "http://www.bilibili.com/video/", - "https://b23.tv/", - "http://b23.tv/", -) - - -class BilibiliUrlIngestProvider: - def __init__(self, yt_dlp: YtDlpAdapter | None = None) -> None: - self.yt_dlp = yt_dlp or YtDlpAdapter() - - manifest = ProviderManifest( - id="bilibili_url", - name="Bilibili URL Ingest", - version="0.1.0", - provider_type="ingest_provider", - entrypoint="biliup_next.modules.ingest.providers.bilibili_url:BilibiliUrlIngestProvider", - capabilities=["ingest"], - enabled_by_default=True, - ) - - def validate_source(self, source_url: str, settings: dict[str, Any]) -> None: - if not isinstance(source_url, str) or not source_url.strip(): - raise ModuleError( - code="SOURCE_URL_MISSING", - message="缺少 source_url", - retryable=False, - ) - source_url = source_url.strip() - if not source_url.startswith(URL_PREFIXES): - raise ModuleError( - code="SOURCE_URL_NOT_SUPPORTED", - message=f"当前仅支持 B 站视频链接: {source_url}", - retryable=False, - ) - - def resolve_source(self, source_url: str, settings: dict[str, Any]) -> dict[str, Any]: - yt_dlp_cmd = str(settings.get("yt_dlp_cmd", "yt-dlp")) - info = self.yt_dlp.probe(yt_dlp_cmd=yt_dlp_cmd, source_url=source_url) - video_id = str(info.get("id") or "").strip() - title = str(info.get("title") or video_id or "bilibili-video").strip() - if not video_id: - raise ModuleError( - code="YT_DLP_METADATA_MISSING_ID", - message="yt-dlp 未返回视频 ID", - retryable=True, - ) - return { - "task_id": self._safe_name(f"{title} [{video_id}]"), - "title": title, - "video_id": video_id, - "streamer": self._clean_text(info.get("uploader") or info.get("channel")), - "segment_duration_seconds": self._coerce_float(info.get("duration")), - "source_url": source_url, - } - - def download_source( - self, - source_url: str, - task_dir: Path, - settings: dict[str, Any], - *, - task_id: str, - ) -> Path: - yt_dlp_cmd = str(settings.get("yt_dlp_cmd", "yt-dlp")) - format_selector = str(settings.get("yt_dlp_format", "")).strip() or None - task_dir.mkdir(parents=True, exist_ok=True) - output_template = str((task_dir / f"{task_id}.%(ext)s").resolve()) - result = self.yt_dlp.download( - yt_dlp_cmd=yt_dlp_cmd, - source_url=source_url, - output_template=output_template, - format_selector=format_selector, - ) - if result.returncode != 0: - raise ModuleError( - code="YT_DLP_DOWNLOAD_FAILED", - message="yt-dlp 下载视频失败", - retryable=True, - details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, - ) - candidates = [ - path - for path in sorted(task_dir.iterdir()) - if path.is_file() and path.stem == task_id and path.suffix.lower() in {".mp4", ".mkv", ".flv", ".mov"} - ] - if not candidates: - raise ModuleError( - code="YT_DLP_OUTPUT_MISSING", - message=f"下载完成但未找到目标视频文件: {task_dir}", - retryable=True, - details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, - ) - return candidates[0].resolve() - - @staticmethod - def _safe_name(value: str) -> str: - cleaned = FORBIDDEN_PATH_CHARS.sub("_", value).strip().rstrip(".") - cleaned = re.sub(r"\s+", " ", cleaned) - return cleaned[:180] or "bilibili-video" - - @staticmethod - def _clean_text(value: object) -> str | None: - if value is None: - return None - text = str(value).strip() - return text or None - - @staticmethod - def _coerce_float(value: object) -> float | None: - if value in {None, ""}: - return None - try: - return float(value) - except (TypeError, ValueError): - return None +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from biliup_next.core.errors import ModuleError +from biliup_next.core.providers import ProviderManifest +from biliup_next.infra.adapters.yt_dlp import YtDlpAdapter + +FORBIDDEN_PATH_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +URL_PREFIXES = ( + "https://www.bilibili.com/video/", + "http://www.bilibili.com/video/", + "https://b23.tv/", + "http://b23.tv/", +) + + +class BilibiliUrlIngestProvider: + def __init__(self, yt_dlp: YtDlpAdapter | None = None) -> None: + self.yt_dlp = yt_dlp or YtDlpAdapter() + + manifest = ProviderManifest( + id="bilibili_url", + name="Bilibili URL Ingest", + version="0.1.0", + provider_type="ingest_provider", + entrypoint="biliup_next.modules.ingest.providers.bilibili_url:BilibiliUrlIngestProvider", + capabilities=["ingest"], + enabled_by_default=True, + ) + + def validate_source(self, source_url: str, settings: dict[str, Any]) -> None: + if not isinstance(source_url, str) or not source_url.strip(): + raise ModuleError( + code="SOURCE_URL_MISSING", + message="缺少 source_url", + retryable=False, + ) + source_url = source_url.strip() + if not source_url.startswith(URL_PREFIXES): + raise ModuleError( + code="SOURCE_URL_NOT_SUPPORTED", + message=f"当前仅支持 B 站视频链接: {source_url}", + retryable=False, + ) + + def resolve_source(self, source_url: str, settings: dict[str, Any]) -> dict[str, Any]: + yt_dlp_cmd = str(settings.get("yt_dlp_cmd", "yt-dlp")) + info = self.yt_dlp.probe(yt_dlp_cmd=yt_dlp_cmd, source_url=source_url) + video_id = str(info.get("id") or "").strip() + title = str(info.get("title") or video_id or "bilibili-video").strip() + if not video_id: + raise ModuleError( + code="YT_DLP_METADATA_MISSING_ID", + message="yt-dlp 未返回视频 ID", + retryable=True, + ) + return { + "task_id": self._safe_name(f"{title} [{video_id}]"), + "title": title, + "video_id": video_id, + "streamer": self._clean_text(info.get("uploader") or info.get("channel")), + "segment_duration_seconds": self._coerce_float(info.get("duration")), + "source_url": source_url, + } + + def download_source( + self, + source_url: str, + task_dir: Path, + settings: dict[str, Any], + *, + task_id: str, + ) -> Path: + yt_dlp_cmd = str(settings.get("yt_dlp_cmd", "yt-dlp")) + format_selector = str(settings.get("yt_dlp_format", "")).strip() or None + task_dir.mkdir(parents=True, exist_ok=True) + output_template = str((task_dir / f"{task_id}.%(ext)s").resolve()) + result = self.yt_dlp.download( + yt_dlp_cmd=yt_dlp_cmd, + source_url=source_url, + output_template=output_template, + format_selector=format_selector, + ) + if result.returncode != 0: + raise ModuleError( + code="YT_DLP_DOWNLOAD_FAILED", + message="yt-dlp 下载视频失败", + retryable=True, + details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, + ) + candidates = [ + path + for path in sorted(task_dir.iterdir()) + if path.is_file() and path.stem == task_id and path.suffix.lower() in {".mp4", ".mkv", ".flv", ".mov"} + ] + if not candidates: + raise ModuleError( + code="YT_DLP_OUTPUT_MISSING", + message=f"下载完成但未找到目标视频文件: {task_dir}", + retryable=True, + details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, + ) + return candidates[0].resolve() + + @staticmethod + def _safe_name(value: str) -> str: + cleaned = FORBIDDEN_PATH_CHARS.sub("_", value).strip().rstrip(".") + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned[:180] or "bilibili-video" + + @staticmethod + def _clean_text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + @staticmethod + def _coerce_float(value: object) -> float | None: + if value in {None, ""}: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/src/biliup_next/modules/ingest/providers/local_file.py b/src/biliup_next/modules/ingest/providers/local_file.py index 8cc915b..02c6aad 100644 --- a/src/biliup_next/modules/ingest/providers/local_file.py +++ b/src/biliup_next/modules/ingest/providers/local_file.py @@ -1,42 +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}, - ) +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 index 438e2b9..27fbd80 100644 --- a/src/biliup_next/modules/ingest/service.py +++ b/src/biliup_next/modules/ingest/service.py @@ -1,214 +1,214 @@ -from __future__ import annotations - -import json -import re -import shutil -import subprocess -import time -from datetime import datetime, timedelta -from pathlib import Path -from zoneinfo import ZoneInfo - -from biliup_next.core.errors import ModuleError -from biliup_next.core.models import Artifact, Task, TaskContext, TaskStep, utc_now_iso -from biliup_next.core.registry import Registry -from biliup_next.infra.task_repository import TaskRepository - -SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") -TITLE_PATTERN = re.compile( - r"^(?P.+?)\s+(?P\d{2})月(?P\d{2})日\s+(?P\d{2})时(?P\d{2})分" -) - - -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], - *, - context_payload: dict[str, object] | None = None, - provider_id: str | None = None, - source_type: str = "local_file", - title_override: str | None = None, - source_ref: str | None = None, - ) -> Task: - provider_id = provider_id or str(settings.get("provider", "local_file")) - provider = self.registry.get("ingest_provider", provider_id) - provider.validate_source(source_path, settings) - source_path = source_path.resolve() - session_dir = Path(str(settings["session_dir"])).resolve() - try: - source_path.relative_to(session_dir) - except ValueError as exc: - raise ModuleError( - code="SOURCE_OUTSIDE_WORKSPACE", - message=f"源文件不在 session 工作区内: {source_path}", - retryable=False, - details={"session_dir": str(session_dir), "hint": "请先使用 stage/import 或 stage/upload 导入文件"}, - ) from exc - - 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() - context_payload = context_payload or {} - task = Task( - id=task_id, - source_type=source_type, - source_path=str(source_path), - title=title_override or 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), - metadata_json=json.dumps({"provider": provider_id, "source_ref": source_ref}), - created_at=now, - ) - ) - context = self._build_task_context( - task, - context_payload, - created_at=now, - updated_at=now, - session_gap_minutes=int(settings.get("session_gap_minutes", 60)), - ) - self.repo.upsert_task_context(context) - full_video_bvid = (context.full_video_bvid or "").strip() - if full_video_bvid.startswith("BV"): - (source_path.parent / "full_video_bvid.txt").write_text(full_video_bvid, encoding="utf-8") - return task - - def create_task_from_url(self, source_url: str, settings: dict[str, object]) -> Task: - provider_id = str(settings.get("provider", "bilibili_url")) - provider = self.registry.get("ingest_provider", provider_id) - provider.validate_source(source_url, settings) - - session_dir = Path(str(settings["session_dir"])).resolve() - session_dir.mkdir(parents=True, exist_ok=True) - - resolved = provider.resolve_source(source_url, settings) - task_id = str(resolved["task_id"]) - if self.repo.get_task(task_id): - raise ModuleError( - code="TASK_ALREADY_EXISTS", - message=f"任务已存在: {task_id}", - retryable=False, - ) - - task_dir = session_dir / task_id - downloaded_path = provider.download_source(source_url, task_dir, settings, task_id=task_id) - context_payload = { - "source_title": resolved.get("title"), - "streamer": resolved.get("streamer"), - "segment_duration_seconds": resolved.get("segment_duration_seconds"), - "reference_timestamp": time.time(), - } - return self.create_task_from_file( - downloaded_path, - settings, - context_payload=context_payload, - provider_id=provider_id, - source_type="bilibili_url", - title_override=str(resolved.get("title") or task_id), - source_ref=source_url, - ) - - 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 - - sidecar_meta = self._load_sidecar_metadata( - source_path, - enabled=bool(settings.get("meta_sidecar_enabled", True)), - suffix=str(settings.get("meta_sidecar_suffix", ".meta.json")), - ) - task_dir = session_dir / task_id - task_dir.mkdir(parents=True, exist_ok=True) - target_source = self._move_to_directory(source_path, task_dir) - if sidecar_meta["meta_path"] is not None: - self._move_optional_metadata_file(sidecar_meta["meta_path"], task_dir) +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import time +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import Artifact, Task, TaskContext, TaskStep, utc_now_iso +from biliup_next.core.registry import Registry +from biliup_next.infra.task_repository import TaskRepository + +SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") +TITLE_PATTERN = re.compile( + r"^(?P.+?)\s+(?P\d{2})月(?P\d{2})日\s+(?P\d{2})时(?P\d{2})分" +) + + +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], + *, + context_payload: dict[str, object] | None = None, + provider_id: str | None = None, + source_type: str = "local_file", + title_override: str | None = None, + source_ref: str | None = None, + ) -> Task: + provider_id = provider_id or str(settings.get("provider", "local_file")) + provider = self.registry.get("ingest_provider", provider_id) + provider.validate_source(source_path, settings) + source_path = source_path.resolve() + session_dir = Path(str(settings["session_dir"])).resolve() + try: + source_path.relative_to(session_dir) + except ValueError as exc: + raise ModuleError( + code="SOURCE_OUTSIDE_WORKSPACE", + message=f"源文件不在 session 工作区内: {source_path}", + retryable=False, + details={"session_dir": str(session_dir), "hint": "请先使用 stage/import 或 stage/upload 导入文件"}, + ) from exc + + 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() + context_payload = context_payload or {} + task = Task( + id=task_id, + source_type=source_type, + source_path=str(source_path), + title=title_override or 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), + metadata_json=json.dumps({"provider": provider_id, "source_ref": source_ref}), + created_at=now, + ) + ) + context = self._build_task_context( + task, + context_payload, + created_at=now, + updated_at=now, + session_gap_minutes=int(settings.get("session_gap_minutes", 60)), + ) + self.repo.upsert_task_context(context) + full_video_bvid = (context.full_video_bvid or "").strip() + if full_video_bvid.startswith("BV"): + (source_path.parent / "full_video_bvid.txt").write_text(full_video_bvid, encoding="utf-8") + return task + + def create_task_from_url(self, source_url: str, settings: dict[str, object]) -> Task: + provider_id = str(settings.get("provider", "bilibili_url")) + provider = self.registry.get("ingest_provider", provider_id) + provider.validate_source(source_url, settings) + + session_dir = Path(str(settings["session_dir"])).resolve() + session_dir.mkdir(parents=True, exist_ok=True) + + resolved = provider.resolve_source(source_url, settings) + task_id = str(resolved["task_id"]) + if self.repo.get_task(task_id): + raise ModuleError( + code="TASK_ALREADY_EXISTS", + message=f"任务已存在: {task_id}", + retryable=False, + ) + + task_dir = session_dir / task_id + downloaded_path = provider.download_source(source_url, task_dir, settings, task_id=task_id) + context_payload = { + "source_title": resolved.get("title"), + "streamer": resolved.get("streamer"), + "segment_duration_seconds": resolved.get("segment_duration_seconds"), + "reference_timestamp": time.time(), + } + return self.create_task_from_file( + downloaded_path, + settings, + context_payload=context_payload, + provider_id=provider_id, + source_type="bilibili_url", + title_override=str(resolved.get("title") or task_id), + source_ref=source_url, + ) + + 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 + + sidecar_meta = self._load_sidecar_metadata( + source_path, + enabled=bool(settings.get("meta_sidecar_enabled", True)), + suffix=str(settings.get("meta_sidecar_suffix", ".meta.json")), + ) + task_dir = session_dir / task_id + task_dir.mkdir(parents=True, exist_ok=True) + target_source = self._move_to_directory(source_path, task_dir) + if sidecar_meta["meta_path"] is not None: + self._move_optional_metadata_file(sidecar_meta["meta_path"], task_dir) context_payload = { "source_title": source_path.stem, "segment_duration_seconds": duration_seconds, @@ -217,283 +217,283 @@ class IngestService: "room_id": sidecar_meta["payload"].get("room_id"), "session_key": sidecar_meta["payload"].get("session_key"), "full_video_bvid": sidecar_meta["payload"].get("full_video_bvid"), - "reference_timestamp": sidecar_meta["payload"].get("reference_timestamp") or source_path.stat().st_mtime, + "reference_timestamp": sidecar_meta["payload"].get("reference_timestamp") or target_source.stat().st_mtime, } - task = self.create_task_from_file(target_source, settings, context_payload=context_payload) - 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 - - @staticmethod - def _load_sidecar_metadata(source_path: Path, *, enabled: bool, suffix: str) -> dict[str, object]: - if not enabled: - return {"meta_path": None, "payload": {}} - suffix = suffix.strip() or ".meta.json" - meta_path = source_path.with_name(f"{source_path.stem}{suffix}") - payload: dict[str, object] = {} - if meta_path.exists(): - try: - payload = json.loads(meta_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise ModuleError( - code="STAGE_META_INVALID", - message=f"元数据文件不是合法 JSON: {meta_path.name}", - retryable=False, - ) from exc - if not isinstance(payload, dict): - raise ModuleError( - code="STAGE_META_INVALID", - message=f"元数据文件必须是对象: {meta_path.name}", - retryable=False, - ) - return {"meta_path": meta_path if meta_path.exists() else None, "payload": payload} - - def _move_optional_metadata_file(self, meta_path: Path, task_dir: Path) -> None: - if not meta_path.exists(): - return - self._move_to_directory(meta_path, task_dir) - - def _build_task_context( - self, - task: Task, - context_payload: dict[str, object], - *, - created_at: str, - updated_at: str, - session_gap_minutes: int, - ) -> TaskContext: - source_title = self._clean_text(context_payload.get("source_title")) or task.title - streamer = self._clean_text(context_payload.get("streamer")) - room_id = self._clean_text(context_payload.get("room_id")) - session_key = self._clean_text(context_payload.get("session_key")) - full_video_bvid = self._clean_bvid(context_payload.get("full_video_bvid")) - segment_duration = self._coerce_float(context_payload.get("segment_duration_seconds")) - segment_started_at = self._coerce_iso_datetime(context_payload.get("segment_started_at")) - - if streamer is None or segment_started_at is None: - inferred = self._infer_from_title( - source_title, - reference_timestamp=context_payload.get("reference_timestamp"), - ) - if streamer is None: - streamer = inferred.get("streamer") - if segment_started_at is None: - segment_started_at = inferred.get("segment_started_at") - - if session_key is None: - session_key, inherited_bvid = self._infer_session_key( - streamer=streamer, - room_id=room_id, - segment_started_at=segment_started_at, - source_title=source_title, - gap_minutes=session_gap_minutes, - ) - if full_video_bvid is None: - full_video_bvid = inherited_bvid - elif full_video_bvid is None: - full_video_bvid = self._find_full_video_bvid_by_session_key(session_key) - - if full_video_bvid is None: - binding = self.repo.get_session_binding(session_key=session_key, source_title=source_title) - if binding is not None: - if session_key is None and binding.session_key: - session_key = binding.session_key - full_video_bvid = self._clean_bvid(binding.full_video_bvid) - - if session_key is None: - session_key = f"task:{task.id}" - - return TaskContext( - id=None, - task_id=task.id, - session_key=session_key, - streamer=streamer, - room_id=room_id, - source_title=source_title, - segment_started_at=segment_started_at, - segment_duration_seconds=segment_duration, - full_video_bvid=full_video_bvid, - created_at=created_at, - updated_at=updated_at, - ) - - @staticmethod - def _clean_text(value: object) -> str | None: - if value is None: - return None - text = str(value).strip() - return text or None - - @staticmethod - def _clean_bvid(value: object) -> str | None: - text = IngestService._clean_text(value) - if text and text.startswith("BV"): - return text - return None - - @staticmethod - def _coerce_float(value: object) -> float | None: - if value is None or value == "": - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - @staticmethod - def _coerce_iso_datetime(value: object) -> str | None: - if value is None: - return None - text = str(value).strip() - if not text: - return None - try: - return datetime.fromisoformat(text).astimezone(SHANGHAI_TZ).isoformat() - except ValueError: - return None - - def _infer_from_title(self, title: str, *, reference_timestamp: object) -> dict[str, str | None]: - match = TITLE_PATTERN.match(title) - if not match: - return {"streamer": None, "segment_started_at": None} - reference_dt = self._reference_datetime(reference_timestamp) - month = int(match.group("month")) - day = int(match.group("day")) - hour = int(match.group("hour")) - minute = int(match.group("minute")) - year = reference_dt.year - if (month, day) > (reference_dt.month, reference_dt.day): - year -= 1 - started_at = datetime(year, month, day, hour, minute, tzinfo=SHANGHAI_TZ) - return { - "streamer": match.group("streamer").strip(), - "segment_started_at": started_at.isoformat(), - } - - @staticmethod - def _reference_datetime(reference_timestamp: object) -> datetime: - if isinstance(reference_timestamp, (int, float)): - return datetime.fromtimestamp(float(reference_timestamp), tz=SHANGHAI_TZ) - return datetime.now(tz=SHANGHAI_TZ) - - def _infer_session_key( - self, - *, - streamer: str | None, - room_id: str | None, - segment_started_at: str | None, - source_title: str, - gap_minutes: int, - ) -> tuple[str | None, str | None]: - if not streamer or not segment_started_at: - return None, None - try: - segment_start = datetime.fromisoformat(segment_started_at) - except ValueError: - return None, None - - tolerance = timedelta(minutes=max(gap_minutes, 180)) - matched_contexts: list[TaskContext] = [] - for context in self.repo.find_recent_task_contexts(streamer): - if room_id and context.room_id and room_id != context.room_id: - continue - candidate_start = self._context_start_time(context) - if candidate_start is None: - continue - if abs(segment_start - candidate_start) <= tolerance: - matched_contexts.append(context) - if matched_contexts: - anchor = min( - matched_contexts, - key=lambda context: ( - self._context_start_time(context) or segment_start, - context.source_title or context.session_key, - ), - ) - return anchor.session_key, anchor.full_video_bvid - return source_title, None - - @staticmethod - def _context_start_time(context: TaskContext) -> datetime | None: - if not context.segment_started_at: - return None - try: - return datetime.fromisoformat(context.segment_started_at) - except ValueError: - return None - - def _find_full_video_bvid_by_session_key(self, session_key: str) -> str | None: - for context in self.repo.list_task_contexts_by_session_key(session_key): - bvid = self._clean_bvid(context.full_video_bvid) - if bvid: - return bvid - return None + task = self.create_task_from_file(target_source, settings, context_payload=context_payload) + 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 + + @staticmethod + def _load_sidecar_metadata(source_path: Path, *, enabled: bool, suffix: str) -> dict[str, object]: + if not enabled: + return {"meta_path": None, "payload": {}} + suffix = suffix.strip() or ".meta.json" + meta_path = source_path.with_name(f"{source_path.stem}{suffix}") + payload: dict[str, object] = {} + if meta_path.exists(): + try: + payload = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ModuleError( + code="STAGE_META_INVALID", + message=f"元数据文件不是合法 JSON: {meta_path.name}", + retryable=False, + ) from exc + if not isinstance(payload, dict): + raise ModuleError( + code="STAGE_META_INVALID", + message=f"元数据文件必须是对象: {meta_path.name}", + retryable=False, + ) + return {"meta_path": meta_path if meta_path.exists() else None, "payload": payload} + + def _move_optional_metadata_file(self, meta_path: Path, task_dir: Path) -> None: + if not meta_path.exists(): + return + self._move_to_directory(meta_path, task_dir) + + def _build_task_context( + self, + task: Task, + context_payload: dict[str, object], + *, + created_at: str, + updated_at: str, + session_gap_minutes: int, + ) -> TaskContext: + source_title = self._clean_text(context_payload.get("source_title")) or task.title + streamer = self._clean_text(context_payload.get("streamer")) + room_id = self._clean_text(context_payload.get("room_id")) + session_key = self._clean_text(context_payload.get("session_key")) + full_video_bvid = self._clean_bvid(context_payload.get("full_video_bvid")) + segment_duration = self._coerce_float(context_payload.get("segment_duration_seconds")) + segment_started_at = self._coerce_iso_datetime(context_payload.get("segment_started_at")) + + if streamer is None or segment_started_at is None: + inferred = self._infer_from_title( + source_title, + reference_timestamp=context_payload.get("reference_timestamp"), + ) + if streamer is None: + streamer = inferred.get("streamer") + if segment_started_at is None: + segment_started_at = inferred.get("segment_started_at") + + if session_key is None: + session_key, inherited_bvid = self._infer_session_key( + streamer=streamer, + room_id=room_id, + segment_started_at=segment_started_at, + source_title=source_title, + gap_minutes=session_gap_minutes, + ) + if full_video_bvid is None: + full_video_bvid = inherited_bvid + elif full_video_bvid is None: + full_video_bvid = self._find_full_video_bvid_by_session_key(session_key) + + if full_video_bvid is None: + binding = self.repo.get_session_binding(session_key=session_key, source_title=source_title) + if binding is not None: + if session_key is None and binding.session_key: + session_key = binding.session_key + full_video_bvid = self._clean_bvid(binding.full_video_bvid) + + if session_key is None: + session_key = f"task:{task.id}" + + return TaskContext( + id=None, + task_id=task.id, + session_key=session_key, + streamer=streamer, + room_id=room_id, + source_title=source_title, + segment_started_at=segment_started_at, + segment_duration_seconds=segment_duration, + full_video_bvid=full_video_bvid, + created_at=created_at, + updated_at=updated_at, + ) + + @staticmethod + def _clean_text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + @staticmethod + def _clean_bvid(value: object) -> str | None: + text = IngestService._clean_text(value) + if text and text.startswith("BV"): + return text + return None + + @staticmethod + def _coerce_float(value: object) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _coerce_iso_datetime(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + return datetime.fromisoformat(text).astimezone(SHANGHAI_TZ).isoformat() + except ValueError: + return None + + def _infer_from_title(self, title: str, *, reference_timestamp: object) -> dict[str, str | None]: + match = TITLE_PATTERN.match(title) + if not match: + return {"streamer": None, "segment_started_at": None} + reference_dt = self._reference_datetime(reference_timestamp) + month = int(match.group("month")) + day = int(match.group("day")) + hour = int(match.group("hour")) + minute = int(match.group("minute")) + year = reference_dt.year + if (month, day) > (reference_dt.month, reference_dt.day): + year -= 1 + started_at = datetime(year, month, day, hour, minute, tzinfo=SHANGHAI_TZ) + return { + "streamer": match.group("streamer").strip(), + "segment_started_at": started_at.isoformat(), + } + + @staticmethod + def _reference_datetime(reference_timestamp: object) -> datetime: + if isinstance(reference_timestamp, (int, float)): + return datetime.fromtimestamp(float(reference_timestamp), tz=SHANGHAI_TZ) + return datetime.now(tz=SHANGHAI_TZ) + + def _infer_session_key( + self, + *, + streamer: str | None, + room_id: str | None, + segment_started_at: str | None, + source_title: str, + gap_minutes: int, + ) -> tuple[str | None, str | None]: + if not streamer or not segment_started_at: + return None, None + try: + segment_start = datetime.fromisoformat(segment_started_at) + except ValueError: + return None, None + + tolerance = timedelta(minutes=max(gap_minutes, 180)) + matched_contexts: list[TaskContext] = [] + for context in self.repo.find_recent_task_contexts(streamer): + if room_id and context.room_id and room_id != context.room_id: + continue + candidate_start = self._context_start_time(context) + if candidate_start is None: + continue + if abs(segment_start - candidate_start) <= tolerance: + matched_contexts.append(context) + if matched_contexts: + anchor = min( + matched_contexts, + key=lambda context: ( + self._context_start_time(context) or segment_start, + context.source_title or context.session_key, + ), + ) + return anchor.session_key, anchor.full_video_bvid + return source_title, None + + @staticmethod + def _context_start_time(context: TaskContext) -> datetime | None: + if not context.segment_started_at: + return None + try: + return datetime.fromisoformat(context.segment_started_at) + except ValueError: + return None + + def _find_full_video_bvid_by_session_key(self, session_key: str) -> str | None: + for context in self.repo.list_task_contexts_by_session_key(session_key): + bvid = self._clean_bvid(context.full_video_bvid) + if bvid: + return bvid + return None diff --git a/src/biliup_next/modules/publish/providers/biliup_cli.py b/src/biliup_next/modules/publish/providers/biliup_cli.py index 84608a2..a5ed20b 100644 --- a/src/biliup_next/modules/publish/providers/biliup_cli.py +++ b/src/biliup_next/modules/publish/providers/biliup_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import random import re +import shutil import time from pathlib import Path from typing import Any @@ -11,9 +12,13 @@ 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.adapters.biliup_cli import BiliupCliAdapter +from biliup_next.infra.video_links import link_context_for_task from biliup_next.infra.workspace_paths import resolve_task_work_dir +DESC_MAX_CHARS = 1900 + + class BiliupCliPublishProvider: def __init__(self, adapter: BiliupCliAdapter | None = None) -> None: self.adapter = adapter or BiliupCliAdapter() @@ -36,7 +41,7 @@ class BiliupCliPublishProvider: publish_progress = work_dir / "publish_progress.json" config = self._load_upload_config(Path(str(settings["upload_config_file"]))) - video_files = [artifact.path for artifact in clip_videos] + video_files = self._prepare_publish_video_files(work_dir, [artifact.path for artifact in clip_videos]) if not video_files: raise ModuleError( code="PUBLISH_NO_CLIPS", @@ -64,10 +69,13 @@ class BiliupCliPublishProvider: "daily_quote": quote.get("text", ""), "quote_author": quote.get("author", ""), } + template_vars.update(link_context_for_task(task, settings.get("__repo"), settings)) 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) + description = self._fit_bilibili_desc( + self._drop_empty_link_lines(template.get("description", "{songs_list}").format(**template_vars)) + ) + dynamic = self._drop_empty_link_lines(template.get("dynamic", "").format(**template_vars)) tags = template.get("tag", "翻唱,唱歌,音乐").format(**template_vars) streamer_cfg = config.get("streamers", {}) if streamer in streamer_cfg: @@ -90,8 +98,12 @@ class BiliupCliPublishProvider: first_batch = video_files[:5] remaining_batches = [video_files[i:i + 5] for i in range(5, len(video_files), 5)] - existing_bvid = bvid_file.read_text(encoding="utf-8").strip() if bvid_file.exists() else "" progress = self._load_publish_progress(publish_progress) + existing_bvid = bvid_file.read_text(encoding="utf-8").strip() if bvid_file.exists() else "" + progress_bvid = str(progress.get("bvid", "")).strip() + if not existing_bvid.startswith("BV") and progress_bvid.startswith("BV"): + existing_bvid = progress_bvid + bvid_file.write_text(existing_bvid, encoding="utf-8") if upload_done.exists() and existing_bvid.startswith("BV"): return PublishRecord( id=None, @@ -201,6 +213,7 @@ class BiliupCliPublishProvider: upload_cmd.extend(["--cover", cover]) for attempt in range(1, retry_count + 1): + self._append_description_summary(publish_log, description) result = self.adapter.run( upload_cmd, label=f"首批上传[{attempt}/{retry_count}]", @@ -253,6 +266,29 @@ class BiliupCliPublishProvider: def _wait_seconds(retry_index: int) -> int: return min(300 * (2**retry_index), 3600) + @staticmethod + def _prepare_publish_video_files(work_dir: Path, video_files: list[str]) -> list[str]: + publish_dir = work_dir / "publish_video" + if publish_dir.exists(): + shutil.rmtree(publish_dir) + publish_dir.mkdir(parents=True, exist_ok=True) + + prepared: list[str] = [] + for index, video_file in enumerate(video_files, start=1): + source = Path(video_file) + name = BiliupCliPublishProvider._strip_clip_number_prefix(source.name) + target = publish_dir / f"{index:02d}_{name}" + try: + target.hardlink_to(source) + except OSError: + shutil.copy2(source, target) + prepared.append(str(target)) + return prepared + + @staticmethod + def _strip_clip_number_prefix(filename: str) -> str: + return re.sub(r"^\d+[_-]+", "", filename, count=1) + @staticmethod def _load_upload_config(path: Path) -> dict[str, Any]: if not path.exists(): @@ -262,6 +298,9 @@ class BiliupCliPublishProvider: @staticmethod def _parse_filename(filename: str, config: dict[str, Any] | None = None) -> dict[str, str]: config = config or {} + builtin = BiliupCliPublishProvider._parse_builtin_filename(filename) + if builtin: + return builtin patterns = config.get("filename_patterns", {}).get("patterns", []) for pattern_config in patterns: regex = pattern_config.get("regex") @@ -278,6 +317,48 @@ class BiliupCliPublishProvider: return data return {"streamer": filename, "date": ""} + @staticmethod + def _parse_builtin_filename(filename: str) -> dict[str, str]: + patterns = ( + r"^(?P.+?)唱歌录播\s+(?P\d{2})月(?P\d{2})日\s+(?P\d{2})时(?P\d{2})分", + r"^(?P.+?)唱歌录播[::]\s*(?P\d{4})年(?P\d{2})月(?P\d{2})日\s+(?P\d{2})时(?P\d{2})分", + ) + for pattern in patterns: + match = re.match(pattern, filename) + if not match: + continue + data = match.groupdict() + data["date"] = f"{data['month']}月{data['day']}日 {data['hour']}时{data['minute']}分" + return data + return {} + + @staticmethod + def _drop_empty_link_lines(text: str) -> str: + lines = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped in {"直播完整版:", "歌曲纯享版:", "上次直播:", "上次纯享:", "上次完整版:"}: + continue + lines.append(line.rstrip()) + return "\n".join(lines).strip() + + @staticmethod + def _fit_bilibili_desc(text: str, max_chars: int = DESC_MAX_CHARS) -> str: + text = text.strip() + if len(text) <= max_chars: + return text + suffix = "\n\n完整歌单见置顶评论。" + return text[: max(0, max_chars - len(suffix))].rstrip() + suffix + + @staticmethod + def _append_description_summary(log_path: Path, description: str) -> None: + log_path.parent.mkdir(parents=True, exist_ok=True) + line = f"description_chars: {len(description)}\n" + if log_path.exists(): + log_path.write_text(log_path.read_text(encoding="utf-8") + line, encoding="utf-8") + else: + log_path.write_text(line, encoding="utf-8") + @staticmethod def _get_random_quote(config: dict[str, Any]) -> dict[str, str]: quotes = config.get("quotes", []) diff --git a/src/biliup_next/modules/publish/service.py b/src/biliup_next/modules/publish/service.py index 62bf2b3..c52dcbf 100644 --- a/src/biliup_next/modules/publish/service.py +++ b/src/biliup_next/modules/publish/service.py @@ -26,7 +26,9 @@ class PublishService: session_contexts = self._session_contexts(task_id) if len(session_contexts) <= 1: clip_videos = self._clip_videos_for_task(task_id) - record = provider.publish(task, clip_videos, settings) + provider_settings = dict(settings) + provider_settings["__repo"] = self.repo + record = provider.publish(task, clip_videos, provider_settings) self._persist_publish_success(task, record) return record @@ -50,6 +52,7 @@ class PublishService: if anchor_task is None: raise RuntimeError(f"anchor task not found: {anchor_context.task_id}") session_settings = dict(settings) + session_settings["__repo"] = self.repo session_settings.update(self._session_publish_metadata(anchor_task, session_contexts, settings)) record = provider.publish(anchor_task, clip_videos, session_settings) for context in session_contexts: diff --git a/src/biliup_next/modules/song_detect/providers/codex.py b/src/biliup_next/modules/song_detect/providers/codex.py index 494b08e..1d405e9 100644 --- a/src/biliup_next/modules/song_detect/providers/codex.py +++ b/src/biliup_next/modules/song_detect/providers/codex.py @@ -1,56 +1,60 @@ -from __future__ import annotations - -import json -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.adapters.codex_cli import CodexCliAdapter -from biliup_next.modules.song_detect.providers.common import TASK_PROMPT, ensure_song_outputs, write_song_schema - - -class CodexSongDetector: - def __init__(self, adapter: CodexCliAdapter | None = None) -> None: - self.adapter = adapter or CodexCliAdapter() - - manifest = ProviderManifest( - id="codex", - name="Codex Song Detector", - version="0.1.0", - provider_type="song_detector", - entrypoint="biliup_next.modules.song_detect.providers.codex:CodexSongDetector", - capabilities=["song_detect"], - enabled_by_default=True, - ) - - def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]: - work_dir = Path(subtitle_srt.path).resolve().parent - write_song_schema(work_dir) - songs_json_path = work_dir / "songs.json" - songs_txt_path = work_dir / "songs.txt" - - codex_cmd = str(settings.get("codex_cmd", "codex")) +from __future__ import annotations + +import json +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.adapters.codex_cli import CodexCliAdapter +from biliup_next.modules.song_detect.providers.common import TASK_PROMPT, ensure_song_outputs, write_song_schema + + +class CodexSongDetector: + def __init__(self, adapter: CodexCliAdapter | None = None) -> None: + self.adapter = adapter or CodexCliAdapter() + + manifest = ProviderManifest( + id="codex", + name="Codex Song Detector", + version="0.1.0", + provider_type="song_detector", + entrypoint="biliup_next.modules.song_detect.providers.codex:CodexSongDetector", + capabilities=["song_detect"], + enabled_by_default=True, + ) + + def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]: + work_dir = Path(subtitle_srt.path).resolve().parent + write_song_schema(work_dir) + songs_json_path = work_dir / "songs.json" + songs_txt_path = work_dir / "songs.txt" + + codex_cmd = str(settings.get("codex_cmd", "codex")) result = self.adapter.run_song_detect( codex_cmd=codex_cmd, work_dir=work_dir, prompt=TASK_PROMPT, ) + self._write_codex_log(work_dir, result) if result.returncode != 0: + stderr = result.stderr[-2000:] + stdout = result.stdout[-2000:] + retryable = not self._is_auth_error(f"{stdout}\n{stderr}") raise ModuleError( code="SONG_DETECT_FAILED", message="codex exec 执行失败", - retryable=True, - details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, + retryable=retryable, + details={"stdout": stdout, "stderr": stderr}, ) - - ensure_song_outputs( - songs_json_path=songs_json_path, - songs_txt_path=songs_txt_path, - stdout=result.stdout, - stderr=result.stderr, + + ensure_song_outputs( + songs_json_path=songs_json_path, + songs_txt_path=songs_txt_path, + stdout=result.stdout, + stderr=result.stderr, provider_name="codex", ) @@ -72,3 +76,37 @@ class CodexSongDetector: created_at=utc_now_iso(), ), ) + + @staticmethod + def _write_codex_log(work_dir: Path, result) -> None: # noqa: ANN001 + log_path = work_dir / "codex.log" + log_path.write_text( + "\n".join( + [ + "codex song_detect", + f"returncode: {result.returncode}", + "", + "stdout:", + result.stdout, + "", + "stderr:", + result.stderr, + "", + ] + ), + encoding="utf-8", + ) + + @staticmethod + def _is_auth_error(text: str) -> bool: + lowered = text.lower() + return any( + needle in lowered + for needle in ( + "401", + "invalid access token", + "token expired", + "unauthorized", + "authentication", + ) + ) diff --git a/src/biliup_next/modules/song_detect/providers/common.py b/src/biliup_next/modules/song_detect/providers/common.py index 1e6c184..36fe911 100644 --- a/src/biliup_next/modules/song_detect/providers/common.py +++ b/src/biliup_next/modules/song_detect/providers/common.py @@ -1,122 +1,122 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from biliup_next.core.errors import ModuleError - -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 数据。""" - -QWEN_TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件 `subtitle.srt` 和一个 JSON schema 文件 `song_schema.json`。 -任务: -1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。 -2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则: - - 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。 - - 这样可以尽量保留歌曲可能存在的前奏。 -3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌。 -4. 忽略纯聊天片段。 -5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。 -6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。 -7. 仔细分析每一句歌词,识别出相关歌曲后,使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间。 -8. 歌曲名称后可以按需补充 `(片段)`、`(清唱)`、`(副歌)` 等简短标注。 -9. 通过歌曲起始和结束时间自检,一般歌曲长度在 5 分钟以内、1 分钟以上,可疑片段重新联网搜索检查。 - -输出要求: -1. 读取 `song_schema.json`,生成严格符合 schema 的 JSON。 -2. 把 JSON 保存到当前目录的 `songs.json`。 -3. 再生成一个 `songs.txt`,每行格式为 `HH:MM:SS 歌曲名 — 歌手`,其中时间取每首歌的开始时间,忽略毫秒。 -4. 不要修改其他文件。 -5. 完成后只输出简短结果说明。 -""" - - -def write_song_schema(work_dir: Path) -> Path: - schema_path = work_dir / "song_schema.json" - schema_path.write_text(json.dumps(SONG_SCHEMA, ensure_ascii=False, indent=2), encoding="utf-8") - return schema_path - - -def ensure_song_outputs( - *, - songs_json_path: Path, - songs_txt_path: Path, - stdout: str, - stderr: str, - provider_name: str, -) -> None: - if songs_json_path.exists() and not songs_txt_path.exists(): - generate_txt_fallback(songs_json_path, songs_txt_path) - - if songs_json_path.exists() and songs_txt_path.exists(): - return - - raise ModuleError( - code="SONG_DETECT_OUTPUT_MISSING", - message=f"未生成 songs.json/songs.txt: {songs_json_path.parent}", - retryable=True, - details={ - "provider": provider_name, - "stdout": stdout[-2000:], - "stderr": stderr[-2000:], - }, - ) - - -def generate_txt_fallback(songs_json_path: Path, songs_txt_path: Path) -> None: - try: - data = json.loads(songs_json_path.read_text(encoding="utf-8")) - songs = data.get("songs", []) - with songs_txt_path.open("w", encoding="utf-8") as file_handle: - for song in songs: - start_time = str(song["start"]).split(",")[0].split(".")[0] - file_handle.write(f"{start_time} {song['title']} — {song['artist']}\n") - except Exception as exc: # noqa: BLE001 - raise ModuleError( - code="SONGS_TXT_GENERATE_FAILED", - message=f"生成 songs.txt 失败: {songs_txt_path}", - retryable=False, - details={"error": str(exc)}, - ) from exc +from __future__ import annotations + +import json +from pathlib import Path + +from biliup_next.core.errors import ModuleError + +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 数据。""" + +QWEN_TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件 `subtitle.srt` 和一个 JSON schema 文件 `song_schema.json`。 +任务: +1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。 +2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则: + - 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。 + - 这样可以尽量保留歌曲可能存在的前奏。 +3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌。 +4. 忽略纯聊天片段。 +5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。 +6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。 +7. 仔细分析每一句歌词,识别出相关歌曲后,使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间。 +8. 歌曲名称后可以按需补充 `(片段)`、`(清唱)`、`(副歌)` 等简短标注。 +9. 通过歌曲起始和结束时间自检,一般歌曲长度在 5 分钟以内、1 分钟以上,可疑片段重新联网搜索检查。 + +输出要求: +1. 读取 `song_schema.json`,生成严格符合 schema 的 JSON。 +2. 把 JSON 保存到当前目录的 `songs.json`。 +3. 再生成一个 `songs.txt`,每行格式为 `HH:MM:SS 歌曲名 — 歌手`,其中时间取每首歌的开始时间,忽略毫秒。 +4. 不要修改其他文件。 +5. 完成后只输出简短结果说明。 +""" + + +def write_song_schema(work_dir: Path) -> Path: + schema_path = work_dir / "song_schema.json" + schema_path.write_text(json.dumps(SONG_SCHEMA, ensure_ascii=False, indent=2), encoding="utf-8") + return schema_path + + +def ensure_song_outputs( + *, + songs_json_path: Path, + songs_txt_path: Path, + stdout: str, + stderr: str, + provider_name: str, +) -> None: + if songs_json_path.exists() and not songs_txt_path.exists(): + generate_txt_fallback(songs_json_path, songs_txt_path) + + if songs_json_path.exists() and songs_txt_path.exists(): + return + + raise ModuleError( + code="SONG_DETECT_OUTPUT_MISSING", + message=f"未生成 songs.json/songs.txt: {songs_json_path.parent}", + retryable=True, + details={ + "provider": provider_name, + "stdout": stdout[-2000:], + "stderr": stderr[-2000:], + }, + ) + + +def generate_txt_fallback(songs_json_path: Path, songs_txt_path: Path) -> None: + try: + data = json.loads(songs_json_path.read_text(encoding="utf-8")) + songs = data.get("songs", []) + with songs_txt_path.open("w", encoding="utf-8") as file_handle: + for song in songs: + start_time = str(song["start"]).split(",")[0].split(".")[0] + file_handle.write(f"{start_time} {song['title']} — {song['artist']}\n") + except Exception as exc: # noqa: BLE001 + raise ModuleError( + code="SONGS_TXT_GENERATE_FAILED", + message=f"生成 songs.txt 失败: {songs_txt_path}", + retryable=False, + details={"error": str(exc)}, + ) from exc diff --git a/src/biliup_next/modules/song_detect/providers/qwen_cli.py b/src/biliup_next/modules/song_detect/providers/qwen_cli.py index e9055fc..3317099 100644 --- a/src/biliup_next/modules/song_detect/providers/qwen_cli.py +++ b/src/biliup_next/modules/song_detect/providers/qwen_cli.py @@ -1,78 +1,95 @@ -from __future__ import annotations - -import json -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.adapters.qwen_cli import QwenCliAdapter -from biliup_next.modules.song_detect.providers.common import ( - QWEN_TASK_PROMPT, - ensure_song_outputs, - write_song_schema, -) - - +from __future__ import annotations + +import json +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.adapters.qwen_cli import QwenCliAdapter +from biliup_next.modules.song_detect.providers.common import ( + QWEN_TASK_PROMPT, + ensure_song_outputs, + write_song_schema, +) + + class QwenCliSongDetector: - def __init__(self, adapter: QwenCliAdapter | None = None) -> None: - self.adapter = adapter or QwenCliAdapter() - - manifest = ProviderManifest( - id="qwen_cli", - name="Qwen CLI Song Detector", - version="0.1.0", - provider_type="song_detector", - entrypoint="biliup_next.modules.song_detect.providers.qwen_cli:QwenCliSongDetector", - capabilities=["song_detect"], - enabled_by_default=True, - ) - + def __init__(self, adapter: QwenCliAdapter | None = None) -> None: + self.adapter = adapter or QwenCliAdapter() + + manifest = ProviderManifest( + id="qwen_cli", + name="Qwen CLI Song Detector", + version="0.1.0", + provider_type="song_detector", + entrypoint="biliup_next.modules.song_detect.providers.qwen_cli:QwenCliSongDetector", + capabilities=["song_detect"], + enabled_by_default=True, + ) + def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]: - work_dir = Path(subtitle_srt.path).resolve().parent - write_song_schema(work_dir) - songs_json_path = work_dir / "songs.json" - songs_txt_path = work_dir / "songs.txt" - - qwen_cmd = str(settings.get("qwen_cmd", "qwen")) - result = self.adapter.run_song_detect( - qwen_cmd=qwen_cmd, - work_dir=work_dir, - prompt=QWEN_TASK_PROMPT, - ) - + work_dir = Path(subtitle_srt.path).resolve().parent + write_song_schema(work_dir) + songs_json_path = work_dir / "songs.json" + songs_txt_path = work_dir / "songs.txt" + + qwen_cmd = str(settings.get("qwen_cmd", "qwen")) + result = self.adapter.run_song_detect( + qwen_cmd=qwen_cmd, + work_dir=work_dir, + prompt=QWEN_TASK_PROMPT, + ) + if result.returncode != 0: + stderr = result.stderr[-2000:] + stdout = result.stdout[-2000:] + retryable = not self._is_auth_error(f"{stdout}\n{stderr}") raise ModuleError( code="SONG_DETECT_FAILED", message="qwen -p 执行失败", - retryable=True, - details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]}, + retryable=retryable, + details={"stdout": stdout, "stderr": stderr}, ) - - ensure_song_outputs( - songs_json_path=songs_json_path, - songs_txt_path=songs_txt_path, - stdout=result.stdout, - stderr=result.stderr, - provider_name="qwen_cli", - ) - - return ( - Artifact( - id=None, - task_id=task.id, - artifact_type="songs_json", - path=str(songs_json_path.resolve()), - metadata_json=json.dumps({"provider": "qwen_cli"}), - created_at=utc_now_iso(), - ), - Artifact( - id=None, - task_id=task.id, - artifact_type="songs_txt", - path=str(songs_txt_path.resolve()), - metadata_json=json.dumps({"provider": "qwen_cli"}), - created_at=utc_now_iso(), + + ensure_song_outputs( + songs_json_path=songs_json_path, + songs_txt_path=songs_txt_path, + stdout=result.stdout, + stderr=result.stderr, + provider_name="qwen_cli", + ) + + return ( + Artifact( + id=None, + task_id=task.id, + artifact_type="songs_json", + path=str(songs_json_path.resolve()), + metadata_json=json.dumps({"provider": "qwen_cli"}), + created_at=utc_now_iso(), + ), + Artifact( + id=None, + task_id=task.id, + artifact_type="songs_txt", + path=str(songs_txt_path.resolve()), + metadata_json=json.dumps({"provider": "qwen_cli"}), + created_at=utc_now_iso(), ), ) + + @staticmethod + def _is_auth_error(text: str) -> bool: + lowered = text.lower() + return any( + needle in lowered + for needle in ( + "401", + "invalid access token", + "token expired", + "unauthorized", + "authentication", + ) + ) diff --git a/src/biliup_next/modules/song_detect/service.py b/src/biliup_next/modules/song_detect/service.py index e7087f1..39f7124 100644 --- a/src/biliup_next/modules/song_detect/service.py +++ b/src/biliup_next/modules/song_detect/service.py @@ -1,28 +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 +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/providers/ffmpeg_copy.py b/src/biliup_next/modules/split/providers/ffmpeg_copy.py index 43721b7..38167d0 100644 --- a/src/biliup_next/modules/split/providers/ffmpeg_copy.py +++ b/src/biliup_next/modules/split/providers/ffmpeg_copy.py @@ -1,101 +1,101 @@ -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 - - -class FfmpegCopySplitProvider: - manifest = ProviderManifest( - id="ffmpeg_copy", - name="FFmpeg Copy Split Provider", - version="0.1.0", - provider_type="split_provider", - entrypoint="biliup_next.modules.split.providers.ffmpeg_copy:FfmpegCopySplitProvider", - capabilities=["split"], - enabled_by_default=True, - ) - - def split(self, task: Task, songs_json: Artifact, source_video: Artifact, settings: dict[str, Any]) -> list[Artifact]: - work_dir = Path(songs_json.path).resolve().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 file_handle: - data = json.load(file_handle) - 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).resolve() - - for index, 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"{index: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), - ] - try: - subprocess.run(cmd, capture_output=True, text=True, check=True) - except FileNotFoundError as exc: - raise ModuleError( - code="FFMPEG_NOT_FOUND", - message=f"找不到 ffmpeg: {ffmpeg_bin}", - retryable=False, - ) from exc - except subprocess.CalledProcessError as exc: - raise ModuleError( - code="SPLIT_FFMPEG_FAILED", - message=f"ffmpeg 切割失败: {output_path.name}", - retryable=True, - details={"stderr": exc.stderr[-2000:], "stdout": exc.stdout[-2000:]}, - ) from exc - - 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 not path.is_file(): - continue - artifacts.append( - Artifact( - id=None, - task_id=task_id, - artifact_type="clip_video", - path=str(path.resolve()), - metadata_json=json.dumps({"provider": "ffmpeg_copy"}), - created_at=utc_now_iso(), - ) - ) - return artifacts +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 + + +class FfmpegCopySplitProvider: + manifest = ProviderManifest( + id="ffmpeg_copy", + name="FFmpeg Copy Split Provider", + version="0.1.0", + provider_type="split_provider", + entrypoint="biliup_next.modules.split.providers.ffmpeg_copy:FfmpegCopySplitProvider", + capabilities=["split"], + enabled_by_default=True, + ) + + def split(self, task: Task, songs_json: Artifact, source_video: Artifact, settings: dict[str, Any]) -> list[Artifact]: + work_dir = Path(songs_json.path).resolve().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 file_handle: + data = json.load(file_handle) + 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).resolve() + + for index, 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"{index: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), + ] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except FileNotFoundError as exc: + raise ModuleError( + code="FFMPEG_NOT_FOUND", + message=f"找不到 ffmpeg: {ffmpeg_bin}", + retryable=False, + ) from exc + except subprocess.CalledProcessError as exc: + raise ModuleError( + code="SPLIT_FFMPEG_FAILED", + message=f"ffmpeg 切割失败: {output_path.name}", + retryable=True, + details={"stderr": exc.stderr[-2000:], "stdout": exc.stdout[-2000:]}, + ) from exc + + 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 not path.is_file(): + continue + artifacts.append( + Artifact( + id=None, + task_id=task_id, + artifact_type="clip_video", + path=str(path.resolve()), + metadata_json=json.dumps({"provider": "ffmpeg_copy"}), + created_at=utc_now_iso(), + ) + ) + return artifacts diff --git a/src/biliup_next/modules/split/service.py b/src/biliup_next/modules/split/service.py index 01adace..9cbe392 100644 --- a/src/biliup_next/modules/split/service.py +++ b/src/biliup_next/modules/split/service.py @@ -1,45 +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 +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/providers/groq.py b/src/biliup_next/modules/transcribe/providers/groq.py index 8832e3f..fd65045 100644 --- a/src/biliup_next/modules/transcribe/providers/groq.py +++ b/src/biliup_next/modules/transcribe/providers/groq.py @@ -1,74 +1,83 @@ -from __future__ import annotations - +from __future__ import annotations + import json import math +import os import shutil import subprocess import time +from contextlib import suppress +from contextlib import contextmanager 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.core.errors import ModuleError +from biliup_next.core.models import Artifact, Task, utc_now_iso +from biliup_next.core.providers import ProviderManifest + + LANGUAGE = "zh" BITRATE_KBPS = 64 MODEL_NAME = "whisper-large-v3-turbo" - - -class GroqTranscribeProvider: - manifest = ProviderManifest( - id="groq", - name="Groq Transcribe Provider", - version="0.1.0", - provider_type="transcribe_provider", - entrypoint="biliup_next.modules.transcribe.providers.groq:GroqTranscribeProvider", - capabilities=["transcribe"], - enabled_by_default=True, - ) - - def transcribe(self, task: Task, source_video: Artifact, settings: dict[str, Any]) -> Artifact: - groq_api_key = str(settings.get("groq_api_key", "")).strip() - if not groq_api_key: +SEGMENT_SIZE_SAFETY_RATIO = 0.75 + + +class GroqTranscribeProvider: + manifest = ProviderManifest( + id="groq", + name="Groq Transcribe Provider", + version="0.1.0", + provider_type="transcribe_provider", + entrypoint="biliup_next.modules.transcribe.providers.groq:GroqTranscribeProvider", + capabilities=["transcribe"], + enabled_by_default=True, + ) + + def transcribe(self, task: Task, source_video: Artifact, settings: dict[str, Any]) -> Artifact: + groq_api_keys = self._groq_api_keys(settings) + if not groq_api_keys: raise ModuleError( code="GROQ_API_KEY_MISSING", - message="未配置 transcribe.groq_api_key", + message="未配置 transcribe.groq_api_key 或 transcribe.groq_api_keys", retryable=False, ) - try: - from groq import Groq - except ModuleNotFoundError as exc: - raise ModuleError( - code="GROQ_DEPENDENCY_MISSING", - message="未安装 groq 依赖,请在 biliup-next 环境中执行 pip install -e .", - retryable=False, - ) from exc - - source_path = Path(source_video.path).resolve() - if not source_path.exists(): - raise ModuleError( - code="TRANSCRIBE_SOURCE_MISSING", - message=f"源视频不存在: {source_path}", - retryable=False, - ) - - ffmpeg_bin = str(settings.get("ffmpeg_bin", "ffmpeg")) - max_file_size_mb = int(settings.get("max_file_size_mb", 23)) + try: + from groq import Groq + except ModuleNotFoundError as exc: + raise ModuleError( + code="GROQ_DEPENDENCY_MISSING", + message="未安装 groq 依赖,请在 biliup-next 环境中执行 pip install -e .", + retryable=False, + ) from exc + + source_path = Path(source_video.path).resolve() + if not source_path.exists(): + raise ModuleError( + code="TRANSCRIBE_SOURCE_MISSING", + message=f"源视频不存在: {source_path}", + retryable=False, + ) + + ffmpeg_bin = str(settings.get("ffmpeg_bin", "ffmpeg")) + max_file_size_mb = int(settings.get("max_file_size_mb", 12)) work_dir = source_path.parent temp_audio_dir = work_dir / "temp_audio" + checkpoint_dir = work_dir / "transcribe_segments" temp_audio_dir.mkdir(parents=True, exist_ok=True) - segment_duration = max(1, math.floor((max_file_size_mb * 8 * 1024) / BITRATE_KBPS)) + checkpoint_dir.mkdir(parents=True, exist_ok=True) + max_segment_bytes = max(1, max_file_size_mb) * 1024 * 1024 + segment_duration = self._initial_segment_duration(max_file_size_mb) output_pattern = temp_audio_dir / "part_%03d.mp3" - self._extract_audio_segments( + segment_duration = self._extract_audio_segments_with_size_guard( ffmpeg_bin=ffmpeg_bin, source_path=source_path, output_pattern=output_pattern, - segment_duration=segment_duration, + temp_audio_dir=temp_audio_dir, + initial_segment_duration=segment_duration, + max_segment_bytes=max_segment_bytes, ) - + segments = sorted(temp_audio_dir.glob("part_*.mp3")) if not segments: raise ModuleError( @@ -77,115 +86,364 @@ class GroqTranscribeProvider: retryable=False, ) - client = Groq(api_key=groq_api_key) + request_timeout_seconds = max(1, int(settings.get("request_timeout_seconds", 180))) + request_max_retries = max(0, int(settings.get("request_max_retries", 1))) + request_retry_backoff_seconds = max(0, int(settings.get("request_retry_backoff_seconds", 30))) + lock_enabled = bool(settings.get("serialize_groq_requests", True)) + lock_path = self._groq_lock_path(settings, work_dir) + clients = [Groq(api_key=key, timeout=request_timeout_seconds, max_retries=0) for key in groq_api_keys] srt_path = work_dir / f"{task.title}.srt" + temp_srt_path = work_dir / f".{task.title}.srt.tmp" global_idx = 1 try: - with srt_path.open("w", encoding="utf-8") as srt_file: + with temp_srt_path.open("w", encoding="utf-8") as srt_file: for index, segment in enumerate(segments): offset_seconds = index * segment_duration - segment_data = self._transcribe_with_retry(client, segment) + segment_checkpoint = checkpoint_dir / f"{segment.stem}.json" + segment_data = self._load_segment_checkpoint(segment_checkpoint, segment_duration=segment_duration) + if segment_data is None: + with self._optional_groq_lock(lock_path, enabled=lock_enabled): + segment_data = self._transcribe_with_retry( + clients, + segment, + request_timeout_seconds=request_timeout_seconds, + request_max_retries=request_max_retries, + request_retry_backoff_seconds=request_retry_backoff_seconds, + ) + self._write_segment_checkpoint( + segment_checkpoint, + segment_data, + segment_duration=segment_duration, + audio_file=segment, + ) for chunk in segment_data: start = self._format_srt_time(float(chunk["start"]) + offset_seconds) end = self._format_srt_time(float(chunk["end"]) + offset_seconds) text = str(chunk["text"]).strip() srt_file.write(f"{global_idx}\n{start} --> {end}\n{text}\n\n") global_idx += 1 + temp_srt_path.replace(srt_path) finally: + with suppress(FileNotFoundError): + temp_srt_path.unlink() shutil.rmtree(temp_audio_dir, ignore_errors=True) - - return Artifact( - id=None, - task_id=task.id, - artifact_type="subtitle_srt", - path=str(srt_path.resolve()), - metadata_json=json.dumps( + + return Artifact( + id=None, + task_id=task.id, + artifact_type="subtitle_srt", + path=str(srt_path.resolve()), + metadata_json=json.dumps( { "provider": "groq", "model": MODEL_NAME, + "api_key_count": len(groq_api_keys), "segment_duration_seconds": segment_duration, + "checkpoint_dir": str(checkpoint_dir.resolve()), } ), created_at=utc_now_iso(), ) - def _extract_audio_segments( + @staticmethod + def _groq_api_keys(settings: dict[str, Any]) -> list[str]: + keys: list[str] = [] + raw_keys = settings.get("groq_api_keys") + if isinstance(raw_keys, list): + keys.extend(str(key).strip() for key in raw_keys if str(key).strip()) + legacy_key = str(settings.get("groq_api_key", "")).strip() + if legacy_key: + keys.append(legacy_key) + deduped: list[str] = [] + seen: set[str] = set() + for key in keys: + if key in seen: + continue + seen.add(key) + deduped.append(key) + return deduped + + @staticmethod + def _initial_segment_duration(max_file_size_mb: int) -> int: + safe_target_mb = max_file_size_mb * SEGMENT_SIZE_SAFETY_RATIO + return max(1, math.floor((safe_target_mb * 8 * 1024) / BITRATE_KBPS)) + + def _extract_audio_segments_with_size_guard( self, *, ffmpeg_bin: str, source_path: Path, output_pattern: Path, - segment_duration: int, - ) -> None: - cmd = [ - ffmpeg_bin, - "-y", - "-i", - str(source_path), - "-vn", - "-acodec", - "libmp3lame", - "-b:a", - f"{BITRATE_KBPS}k", - "-ac", - "1", - "-ar", - "22050", - "-f", - "segment", - "-segment_time", - str(segment_duration), - "-reset_timestamps", - "1", - str(output_pattern), - ] + temp_audio_dir: Path, + initial_segment_duration: int, + max_segment_bytes: int, + ) -> int: + segment_duration = initial_segment_duration + for _attempt in range(4): + self._clear_audio_segments(temp_audio_dir) + self._extract_audio_segments( + ffmpeg_bin=ffmpeg_bin, + source_path=source_path, + output_pattern=output_pattern, + segment_duration=segment_duration, + ) + largest_segment = self._largest_audio_segment(temp_audio_dir) + if largest_segment is None or largest_segment.stat().st_size <= max_segment_bytes: + return segment_duration + next_duration = max(1, math.floor(segment_duration * 0.75)) + if next_duration == segment_duration: + break + segment_duration = next_duration + largest_segment = self._largest_audio_segment(temp_audio_dir) + largest_size = largest_segment.stat().st_size if largest_segment else 0 + raise ModuleError( + code="TRANSCRIBE_AUDIO_SEGMENT_TOO_LARGE", + message="音频分片超过 Groq 上传安全阈值", + retryable=False, + details={ + "largest_segment": str(largest_segment) if largest_segment else None, + "largest_segment_bytes": largest_size, + "max_segment_bytes": max_segment_bytes, + }, + ) + + @staticmethod + def _clear_audio_segments(temp_audio_dir: Path) -> None: + for path in temp_audio_dir.glob("part_*.mp3"): + path.unlink(missing_ok=True) + + @staticmethod + def _largest_audio_segment(temp_audio_dir: Path) -> Path | None: + segments = list(temp_audio_dir.glob("part_*.mp3")) + if not segments: + return None + return max(segments, key=lambda path: path.stat().st_size) + + @staticmethod + def _load_segment_checkpoint(checkpoint_path: Path, *, segment_duration: int) -> list[dict[str, Any]] | None: + if not checkpoint_path.exists(): + return None try: - subprocess.run(cmd, check=True, capture_output=True, text=True) - except FileNotFoundError as exc: - raise ModuleError( - code="FFMPEG_NOT_FOUND", - message=f"找不到 ffmpeg: {ffmpeg_bin}", - retryable=False, - ) from exc - except subprocess.CalledProcessError as exc: - raise ModuleError( - code="FFMPEG_AUDIO_EXTRACT_FAILED", - message=f"音频提取失败: {source_path.name}", - retryable=True, + data = json.loads(checkpoint_path.read_text(encoding="utf-8")) + if data.get("model") != MODEL_NAME or data.get("language") != LANGUAGE: + return None + if data.get("segment_duration_seconds") != segment_duration: + return None + segments = data.get("segments") + if not isinstance(segments, list): + return None + return [dict(segment) for segment in segments] + except Exception: + return None + + @staticmethod + def _write_segment_checkpoint( + checkpoint_path: Path, + segments: list[dict[str, Any]], + *, + segment_duration: int, + audio_file: Path, + ) -> None: + checkpoint_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = checkpoint_path.with_suffix(f"{checkpoint_path.suffix}.tmp") + payload = { + "provider": "groq", + "model": MODEL_NAME, + "language": LANGUAGE, + "audio_file": audio_file.name, + "segment_duration_seconds": segment_duration, + "segments": segments, + } + temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + temp_path.replace(checkpoint_path) + + def _extract_audio_segments( + self, + *, + ffmpeg_bin: str, + source_path: Path, + output_pattern: Path, + segment_duration: int, + ) -> None: + cmd = [ + ffmpeg_bin, + "-y", + "-i", + str(source_path), + "-vn", + "-acodec", + "libmp3lame", + "-b:a", + f"{BITRATE_KBPS}k", + "-ac", + "1", + "-ar", + "22050", + "-f", + "segment", + "-segment_time", + str(segment_duration), + "-reset_timestamps", + "1", + str(output_pattern), + ] + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + except FileNotFoundError as exc: + raise ModuleError( + code="FFMPEG_NOT_FOUND", + message=f"找不到 ffmpeg: {ffmpeg_bin}", + retryable=False, + ) from exc + except subprocess.CalledProcessError as exc: + raise ModuleError( + code="FFMPEG_AUDIO_EXTRACT_FAILED", + message=f"音频提取失败: {source_path.name}", + retryable=True, details={"stderr": exc.stderr[-2000:], "stdout": exc.stdout[-2000:]}, ) from exc - def _transcribe_with_retry(self, client: Any, audio_file: Path) -> list[dict[str, Any]]: - retry_count = 0 - while True: - try: - with audio_file.open("rb") as file_handle: - response = client.audio.transcriptions.create( - file=(audio_file.name, file_handle.read()), - model=MODEL_NAME, - response_format="verbose_json", - language=LANGUAGE, - temperature=0.0, - ) - return [dict(segment) for segment in response.segments] - except Exception as exc: # noqa: BLE001 - retry_count += 1 - err_str = str(exc) - if "429" in err_str or "rate_limit" in err_str.lower(): - time.sleep(25) - continue - raise ModuleError( - code="GROQ_TRANSCRIBE_FAILED", - message=f"Groq 转录失败: {audio_file.name}", - retryable=True, - details={"error": err_str, "retry_count": retry_count}, - ) from exc + @staticmethod + def _groq_lock_path(settings: dict[str, Any], fallback_work_dir: Path) -> Path: + session_dir = settings.get("session_dir") + if isinstance(session_dir, str) and session_dir: + return Path(session_dir).resolve().parent / "groq_transcribe.lock" + return fallback_work_dir / "groq_transcribe.lock" @staticmethod - def _format_srt_time(seconds: float) -> str: - td_hours = int(seconds // 3600) - td_mins = int((seconds % 3600) // 60) - td_secs = int(seconds % 60) - td_millis = int((seconds - int(seconds)) * 1000) - return f"{td_hours:02}:{td_mins:02}:{td_secs:02},{td_millis:03}" + @contextmanager + def _optional_groq_lock(lock_path: Path, *, enabled: bool): + if not enabled: + yield + return + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("w", encoding="utf-8") as lock_file: + try: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + lock_file.write(f"{os.getpid()}\n") + lock_file.flush() + yield + finally: + with suppress(Exception): + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def _transcribe_with_retry( + self, + clients: list[Any], + audio_file: Path, + *, + request_timeout_seconds: int, + request_max_retries: int, + request_retry_backoff_seconds: int, + ) -> list[dict[str, Any]]: + attempt = 0 + key_attempts = 0 + last_error = "" + while True: + attempt += 1 + for key_index, client in enumerate(clients): + key_attempts += 1 + try: + with audio_file.open("rb") as file_handle: + response = client.audio.transcriptions.create( + file=(audio_file.name, file_handle.read()), + model=MODEL_NAME, + response_format="verbose_json", + language=LANGUAGE, + temperature=0.0, + timeout=request_timeout_seconds, + ) + return [dict(segment) for segment in response.segments] + except Exception as exc: # noqa: BLE001 + err_str = str(exc) + last_error = err_str + if self._is_rate_limit_error(err_str) and key_index < len(clients) - 1: + continue + if not self._should_retry_request(err_str): + raise self._transcribe_failed( + audio_file, + err_str, + request_attempts=attempt, + key_attempts=key_attempts, + api_key_count=len(clients), + request_timeout_seconds=request_timeout_seconds, + ) from exc + break + if attempt <= request_max_retries: + if request_retry_backoff_seconds > 0: + time.sleep(request_retry_backoff_seconds) + continue + raise self._transcribe_failed( + audio_file, + last_error, + request_attempts=attempt, + key_attempts=key_attempts, + api_key_count=len(clients), + request_timeout_seconds=request_timeout_seconds, + ) + + @staticmethod + def _transcribe_failed( + audio_file: Path, + error_text: str, + *, + request_attempts: int, + key_attempts: int, + api_key_count: int, + request_timeout_seconds: int, + ) -> ModuleError: + return ModuleError( + code="GROQ_TRANSCRIBE_FAILED", + message=f"Groq 转录失败: {audio_file.name}", + retryable=True, + details={ + "error": error_text, + "request_attempts": request_attempts, + "key_attempts": key_attempts, + "api_key_count": api_key_count, + "request_timeout_seconds": request_timeout_seconds, + }, + ) + + @staticmethod + def _is_rate_limit_error(error_text: str) -> bool: + lowered = error_text.lower() + return any( + needle in lowered + for needle in ( + "429", + "rate_limit", + "rate limit", + "too many requests", + ) + ) + + @staticmethod + def _should_retry_request(error_text: str) -> bool: + lowered = error_text.lower() + return any( + needle in lowered + for needle in ( + "429", + "rate_limit", + "timed out", + "timeout", + "connection error", + "connect error", + "server disconnected", + "502", + "503", + "504", + ) + ) + + @staticmethod + def _format_srt_time(seconds: float) -> str: + td_hours = int(seconds // 3600) + td_mins = int((seconds % 3600) // 60) + td_secs = int(seconds % 60) + td_millis = int((seconds - int(seconds)) * 1000) + return f"{td_hours:02}:{td_mins:02}:{td_secs:02},{td_millis:03}" diff --git a/src/biliup_next/modules/transcribe/service.py b/src/biliup_next/modules/transcribe/service.py index ff2c21a..1a3668b 100644 --- a/src/biliup_next/modules/transcribe/service.py +++ b/src/biliup_next/modules/transcribe/service.py @@ -1,27 +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 +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 index aea2de0..ebc74ea 100644 --- a/src/biliup_next/plugins/manifests/collection_bilibili_collection.json +++ b/src/biliup_next/plugins/manifests/collection_bilibili_collection.json @@ -1,9 +1,9 @@ -{ - "id": "bilibili_collection", - "name": "Bilibili Collection Provider", - "version": "0.1.0", - "provider_type": "collection_provider", - "entrypoint": "biliup_next.modules.collection.providers.bilibili_collection:BilibiliCollectionProvider", - "capabilities": ["collection"], - "enabled_by_default": true -} +{ + "id": "bilibili_collection", + "name": "Bilibili Collection Provider", + "version": "0.1.0", + "provider_type": "collection_provider", + "entrypoint": "biliup_next.modules.collection.providers.bilibili_collection:BilibiliCollectionProvider", + "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 index 82b541c..2ff6465 100644 --- a/src/biliup_next/plugins/manifests/comment_bilibili_top_comment.json +++ b/src/biliup_next/plugins/manifests/comment_bilibili_top_comment.json @@ -1,9 +1,9 @@ -{ - "id": "bilibili_top_comment", - "name": "Bilibili Top Comment Provider", - "version": "0.1.0", - "provider_type": "comment_provider", - "entrypoint": "biliup_next.modules.comment.providers.bilibili_top_comment:BilibiliTopCommentProvider", - "capabilities": ["comment"], - "enabled_by_default": true -} +{ + "id": "bilibili_top_comment", + "name": "Bilibili Top Comment Provider", + "version": "0.1.0", + "provider_type": "comment_provider", + "entrypoint": "biliup_next.modules.comment.providers.bilibili_top_comment:BilibiliTopCommentProvider", + "capabilities": ["comment"], + "enabled_by_default": true +} diff --git a/src/biliup_next/plugins/manifests/ingest_bilibili_url.json b/src/biliup_next/plugins/manifests/ingest_bilibili_url.json index 9fe1dfc..ce7e13e 100644 --- a/src/biliup_next/plugins/manifests/ingest_bilibili_url.json +++ b/src/biliup_next/plugins/manifests/ingest_bilibili_url.json @@ -1,9 +1,9 @@ -{ - "id": "bilibili_url", - "name": "Bilibili URL Ingest", - "version": "0.1.0", - "provider_type": "ingest_provider", - "entrypoint": "biliup_next.modules.ingest.providers.bilibili_url:BilibiliUrlIngestProvider", - "capabilities": ["ingest"], - "enabled_by_default": true -} +{ + "id": "bilibili_url", + "name": "Bilibili URL Ingest", + "version": "0.1.0", + "provider_type": "ingest_provider", + "entrypoint": "biliup_next.modules.ingest.providers.bilibili_url:BilibiliUrlIngestProvider", + "capabilities": ["ingest"], + "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 index 055fdec..eb8aea8 100644 --- a/src/biliup_next/plugins/manifests/ingest_local_file.json +++ b/src/biliup_next/plugins/manifests/ingest_local_file.json @@ -1,9 +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 -} +{ + "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 index 2cb1e45..4043f62 100644 --- a/src/biliup_next/plugins/manifests/publish_biliup_cli.json +++ b/src/biliup_next/plugins/manifests/publish_biliup_cli.json @@ -1,9 +1,9 @@ -{ - "id": "biliup_cli", - "name": "biliup CLI Publish Provider", - "version": "0.1.0", - "provider_type": "publish_provider", - "entrypoint": "biliup_next.modules.publish.providers.biliup_cli:BiliupCliPublishProvider", - "capabilities": ["publish"], - "enabled_by_default": true -} +{ + "id": "biliup_cli", + "name": "biliup CLI Publish Provider", + "version": "0.1.0", + "provider_type": "publish_provider", + "entrypoint": "biliup_next.modules.publish.providers.biliup_cli:BiliupCliPublishProvider", + "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 index 9612d6c..f1d5c54 100644 --- a/src/biliup_next/plugins/manifests/song_detect_codex.json +++ b/src/biliup_next/plugins/manifests/song_detect_codex.json @@ -1,9 +1,9 @@ -{ - "id": "codex", - "name": "Codex Song Detector", - "version": "0.1.0", - "provider_type": "song_detector", - "entrypoint": "biliup_next.modules.song_detect.providers.codex:CodexSongDetector", - "capabilities": ["song_detect"], - "enabled_by_default": true -} +{ + "id": "codex", + "name": "Codex Song Detector", + "version": "0.1.0", + "provider_type": "song_detector", + "entrypoint": "biliup_next.modules.song_detect.providers.codex:CodexSongDetector", + "capabilities": ["song_detect"], + "enabled_by_default": true +} diff --git a/src/biliup_next/plugins/manifests/song_detect_qwen_cli.json b/src/biliup_next/plugins/manifests/song_detect_qwen_cli.json index be7861f..3181d4d 100644 --- a/src/biliup_next/plugins/manifests/song_detect_qwen_cli.json +++ b/src/biliup_next/plugins/manifests/song_detect_qwen_cli.json @@ -1,9 +1,9 @@ -{ - "id": "qwen_cli", - "name": "Qwen CLI Song Detector", - "version": "0.1.0", - "provider_type": "song_detector", - "entrypoint": "biliup_next.modules.song_detect.providers.qwen_cli:QwenCliSongDetector", - "capabilities": ["song_detect"], - "enabled_by_default": true -} +{ + "id": "qwen_cli", + "name": "Qwen CLI Song Detector", + "version": "0.1.0", + "provider_type": "song_detector", + "entrypoint": "biliup_next.modules.song_detect.providers.qwen_cli:QwenCliSongDetector", + "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 index 063c43f..635b1b0 100644 --- a/src/biliup_next/plugins/manifests/split_ffmpeg_copy.json +++ b/src/biliup_next/plugins/manifests/split_ffmpeg_copy.json @@ -1,9 +1,9 @@ -{ - "id": "ffmpeg_copy", - "name": "FFmpeg Copy Split Provider", - "version": "0.1.0", - "provider_type": "split_provider", - "entrypoint": "biliup_next.modules.split.providers.ffmpeg_copy:FfmpegCopySplitProvider", - "capabilities": ["split"], - "enabled_by_default": true -} +{ + "id": "ffmpeg_copy", + "name": "FFmpeg Copy Split Provider", + "version": "0.1.0", + "provider_type": "split_provider", + "entrypoint": "biliup_next.modules.split.providers.ffmpeg_copy:FfmpegCopySplitProvider", + "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 index c57f648..c9afa68 100644 --- a/src/biliup_next/plugins/manifests/transcribe_groq.json +++ b/src/biliup_next/plugins/manifests/transcribe_groq.json @@ -1,9 +1,9 @@ -{ - "id": "groq", - "name": "Groq Transcribe Provider", - "version": "0.1.0", - "provider_type": "transcribe_provider", - "entrypoint": "biliup_next.modules.transcribe.providers.groq:GroqTranscribeProvider", - "capabilities": ["transcribe"], - "enabled_by_default": true -} +{ + "id": "groq", + "name": "Groq Transcribe Provider", + "version": "0.1.0", + "provider_type": "transcribe_provider", + "entrypoint": "biliup_next.modules.transcribe.providers.groq:GroqTranscribeProvider", + "capabilities": ["transcribe"], + "enabled_by_default": true +} diff --git a/systemd/biliup-next-api.service.template b/systemd/biliup-next-api.service.template index cba60dc..324bed7 100644 --- a/systemd/biliup-next-api.service.template +++ b/systemd/biliup-next-api.service.template @@ -1,19 +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 +[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 index 3b65722..55c1ecf 100644 --- a/systemd/biliup-next-worker.service.template +++ b/systemd/biliup-next-worker.service.template @@ -1,18 +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 +[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 diff --git a/tests/test_api_server.py b/tests/test_api_server.py index adaff26..9eba768 100644 --- a/tests/test_api_server.py +++ b/tests/test_api_server.py @@ -1,1060 +1,1060 @@ -from __future__ import annotations - -import io -import json -import tempfile -import unittest -from contextlib import ExitStack -from http import HTTPStatus -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -from biliup_next.app.api_server import ApiHandler -from biliup_next.core.models import ActionRecord, Artifact, Task, TaskContext, TaskStep - - -class FakeRepo: - def __init__( - self, - task: Task, - *, - context: TaskContext | None = None, - steps: list[TaskStep] | None = None, - artifacts: list[Artifact] | None = None, - actions: list[ActionRecord] | None = None, - ) -> None: - self.task = task - self.context = context - self.steps = steps or [] - self.artifacts = artifacts or [] - self.actions = actions or [] - - def query_tasks(self, **kwargs): # type: ignore[no-untyped-def] - return [self.task], 1 - - def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: - if self.context and self.context.task_id in task_ids: - return {self.context.task_id: self.context} - return {} - - def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: - if self.task.id in task_ids: - return {self.task.id: list(self.steps)} - return {} - - def get_task(self, task_id: str) -> Task | None: - return self.task if task_id == self.task.id else None - - def get_task_context(self, task_id: str) -> TaskContext | None: - return self.context if self.context and task_id == self.context.task_id else None - - def list_steps(self, task_id: str) -> list[TaskStep]: - return list(self.steps) if task_id == self.task.id else [] - - def list_artifacts(self, task_id: str) -> list[Artifact]: - return list(self.artifacts) if task_id == self.task.id else [] - - def list_action_records( - self, - task_id: str | None = None, - limit: int = 200, - action_name: str | None = None, - status: str | None = None, - ) -> list[ActionRecord]: - items = list(self.actions) - if task_id is not None: - items = [item for item in items if item.task_id == task_id] - if action_name is not None: - items = [item for item in items if item.action_name == action_name] - if status is not None: - items = [item for item in items if item.status == status] - return items[:limit] - - def add_action_record(self, action: ActionRecord) -> None: - self.actions.append(action) - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - if self.context and self.context.session_key == session_key: - return [self.context] - return [] - - -class FakeSettingsService: - save_calls: list[dict[str, object]] = [] - promote_calls: int = 0 - - def __init__(self, root) -> None: # type: ignore[no-untyped-def] - self.root = root - - def save_staged_from_redacted(self, payload: dict[str, object]) -> None: - self.__class__.save_calls.append(payload) - - def promote_staged(self) -> None: - self.__class__.promote_calls += 1 - - @classmethod - def reset(cls) -> None: - cls.save_calls = [] - cls.promote_calls = 0 - - -class FakeSettingsReader: - def __init__(self, root, *, settings=None, schema=None) -> None: # type: ignore[no-untyped-def] - self.root = root - self._settings = settings or {} - self._schema = schema or {} - - def load_redacted(self): - return SimpleNamespace(settings=self._settings) - - def load(self): - return SimpleNamespace(schema=self._schema) - - -class ApiServerTests(unittest.TestCase): - def _state( - self, - tmpdir: str, - repo: FakeRepo, - *, - control_token: str = "", - ingest_service: object | None = None, - ) -> dict[str, object]: - state = { - "root": Path(tmpdir), - "repo": repo, - "settings": { - "runtime": {"control_token": control_token}, - "paths": {"session_dir": str(Path(tmpdir) / "session")}, - "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, - "cleanup": {}, - "publish": {"retry_schedule_minutes": [10]}, - }, - "registry": SimpleNamespace(list_manifests=lambda: []), - "manifests": [], - } - if ingest_service is not None: - state["ingest_service"] = ingest_service - return state - - def _request( - self, - method: str, - path: str, - state: dict[str, object], - *, - body: bytes = b"", - headers: dict[str, str] | None = None, - ) -> tuple[int, dict[str, str], object]: - handler = ApiHandler.__new__(ApiHandler) - handler.path = path - handler.headers = {"Content-Length": str(len(body)), **(headers or {})} - handler.rfile = io.BytesIO(body) - handler.wfile = io.BytesIO() - - response_status: dict[str, int] = {"value": HTTPStatus.OK} - response_headers: dict[str, str] = {} - - def send_response(status: int, message: str | None = None) -> None: - response_status["value"] = int(status) - - def send_header(name: str, value: str) -> None: - response_headers[name] = value - - handler.send_response = send_response # type: ignore[method-assign] - handler.send_header = send_header # type: ignore[method-assign] - handler.end_headers = lambda: None # type: ignore[method-assign] - handler.log_message = lambda format, *args: None # type: ignore[method-assign] - - with patch("biliup_next.app.api_server.ensure_initialized", return_value=state): - getattr(handler, f"do_{method}")() - - raw_body = handler.wfile.getvalue().decode("utf-8") - parsed_body = json.loads(raw_body) if raw_body else None - return response_status["value"], response_headers, parsed_body - - def test_get_tasks_returns_serialized_items(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - response_status, _, body = self._request("GET", "/tasks?limit=10&offset=0", state) - - self.assertEqual(response_status, 200) - self.assertEqual(body["total"], 1) - self.assertEqual(body["items"][0]["id"], "task-1") - self.assertEqual(body["items"][0]["delivery_state"]["split_comment"], "pending") - - def test_get_task_timeline_returns_serialized_timeline(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:02:00+00:00", - ) - steps = [ - TaskStep( - id=None, - task_id="task-1", - step_name="publish", - status="failed_retryable", - error_code="ERR_UPLOAD", - error_message="upload failed", - retry_count=1, - started_at="2026-01-01T00:01:00+00:00", - finished_at="2026-01-01T00:01:30+00:00", - ) - ] - artifacts = [ - Artifact( - id=None, - task_id="task-1", - artifact_type="publish_bvid", - path="/tmp/bvid.txt", - metadata_json="{}", - created_at="2026-01-01T00:01:40+00:00", - ) - ] - actions = [ - ActionRecord( - id=None, - task_id="task-1", - action_name="comment", - status="ok", - summary="comment finished", - details_json=json.dumps({"split": {"status": "ok"}, "full": {"status": "skipped"}}), - created_at="2026-01-01T00:01:50+00:00", - ) - ] - state = self._state(tmpdir, FakeRepo(task, steps=steps, artifacts=artifacts, actions=actions)) - - response_status, _, body = self._request("GET", "/tasks/task-1/timeline", state) - - self.assertEqual(response_status, 200) - self.assertGreaterEqual(len(body["items"]), 4) - self.assertEqual(body["items"][0]["kind"], "task") - action_item = next(item for item in body["items"] if item["kind"] == "action") - self.assertIn("split=ok", action_item["summary"]) - - def test_get_session_returns_serialized_tasks(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - context = TaskContext( - id=None, - task_id="task-1", - session_key="session-1", - streamer="streamer", - room_id="room-1", - source_title="task-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid="BVFULL123", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task, context=context)) - - response_status, _, body = self._request("GET", "/sessions/session-1", state) - - self.assertEqual(response_status, 200) - self.assertEqual(body["session_key"], "session-1") - self.assertEqual(body["full_video_bvid"], "BVFULL123") - self.assertEqual(body["tasks"][0]["session_context"]["session_key"], "session-1") - - def test_get_tasks_requires_control_token_when_configured(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task), control_token="secret") - - unauthorized_status, _, unauthorized_body = self._request("GET", "/tasks", state) - authorized_status, _, authorized_body = self._request( - "GET", - "/tasks", - state, - headers={"X-Biliup-Token": "secret"}, - ) - - self.assertEqual(unauthorized_status, 401) - self.assertEqual(unauthorized_body["error"], "unauthorized") - self.assertEqual(authorized_status, 200) - self.assertEqual(authorized_body["items"][0]["id"], "task-1") - - def test_get_history_filters_action_records(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - actions = [ - ActionRecord(None, "task-1", "comment", "ok", "comment ok", "{}", "2026-01-01T00:01:00+00:00"), - ActionRecord(None, "task-1", "publish", "error", "publish error", "{}", "2026-01-01T00:02:00+00:00"), - ] - state = self._state(tmpdir, FakeRepo(task, actions=actions)) - - response_status, _, body = self._request("GET", "/history?task_id=task-1&action_name=comment&status=ok", state) - - self.assertEqual(response_status, 200) - self.assertEqual(len(body["items"]), 1) - self.assertEqual(body["items"][0]["action_name"], "comment") - - def test_get_modules_returns_registry_and_discovered_manifests(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - state["registry"] = SimpleNamespace(list_manifests=lambda: [{"name": "publish.biliup_cli"}]) - state["manifests"] = [{"name": "publish.biliup_cli", "path": "plugins/publish.json"}] - - response_status, _, body = self._request("GET", "/modules", state) - - self.assertEqual(response_status, 200) - self.assertEqual(body["items"][0]["name"], "publish.biliup_cli") - self.assertEqual(body["discovered_manifests"][0]["path"], "plugins/publish.json") - - def test_get_scheduler_preview_returns_builder_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.build_scheduler_preview", - return_value={"items": [{"task_id": "task-1"}]}, - ) as preview_mock: - response_status, _, body = self._request("GET", "/scheduler/preview", state) - - self.assertEqual(response_status, 200) - self.assertEqual(body["items"][0]["task_id"], "task-1") - preview_mock.assert_called_once() - - def test_get_settings_schema_returns_schema_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - fake_service = FakeSettingsReader(Path(tmpdir), schema={"title": "SettingsSchema"}) - - with patch("biliup_next.app.api_server.SettingsService", return_value=fake_service): - response_status, _, body = self._request("GET", "/settings/schema", state) - - self.assertEqual(response_status, 200) - self.assertEqual(body["title"], "SettingsSchema") - - def test_put_settings_promotes_and_resets_initialized_state(self) -> None: - FakeSettingsService.reset() - with tempfile.TemporaryDirectory() as tmpdir: - state = { - "root": Path(tmpdir), - "settings": {"runtime": {"control_token": ""}}, - } - - with ExitStack() as stack: - ensure_mock = stack.enter_context( - patch("biliup_next.app.api_server.ensure_initialized", return_value=state) - ) - reset_mock = stack.enter_context(patch("biliup_next.app.api_server.reset_initialized_state")) - stack.enter_context(patch("biliup_next.app.api_server.SettingsService", FakeSettingsService)) - payload = json.dumps({"runtime": {"control_token": "abc"}}) - handler = ApiHandler.__new__(ApiHandler) - handler.path = "/settings" - handler.headers = { - "Content-Length": str(len(payload.encode("utf-8"))), - "Content-Type": "application/json", - } - handler.rfile = io.BytesIO(payload.encode("utf-8")) - handler.wfile = io.BytesIO() - - response_status: dict[str, int] = {"value": HTTPStatus.OK} - handler.send_response = lambda status, message=None: response_status.__setitem__("value", int(status)) # type: ignore[method-assign] - handler.send_header = lambda name, value: None # type: ignore[method-assign] - handler.end_headers = lambda: None # type: ignore[method-assign] - handler.log_message = lambda format, *args: None # type: ignore[method-assign] - - handler.do_PUT() - body = json.loads(handler.wfile.getvalue().decode("utf-8")) - - self.assertEqual(response_status["value"], 200) - self.assertEqual(body["ok"], True) - self.assertEqual(FakeSettingsService.save_calls[-1], {"runtime": {"control_token": "abc"}}) - self.assertEqual(FakeSettingsService.promote_calls, 1) - reset_mock.assert_called_once() - self.assertGreaterEqual(ensure_mock.call_count, 2) - - def test_post_tasks_creates_task_from_source_path(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - source_path = str(Path(tmpdir) / "stage" / "source.mp4") - created_task = Task( - id="task-new", - source_type="local_file", - source_path=source_path, - title="new-task", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - ingest_service = SimpleNamespace(create_task_from_file=lambda path, settings: created_task) - repo = FakeRepo(created_task) - state = self._state(tmpdir, repo, ingest_service=ingest_service) - state["settings"]["ingest"] = {"min_duration_seconds": 60} - state["settings"]["paths"]["stage_dir"] = str(Path(tmpdir) / "stage") - - response_status, _, body = self._request( - "POST", - "/tasks", - state, - body=json.dumps({"source_path": source_path}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 201) - self.assertEqual(body["id"], "task-new") - self.assertEqual(body["source_path"], source_path) - - def test_post_tasks_creates_task_from_bilibili_url(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - source_url = "https://www.bilibili.com/video/BV1TEST1234" - created_task = Task( - id="task-bv", - source_type="bilibili_url", - source_path=str(Path(tmpdir) / "session" / "task-bv" / "task-bv.mp4"), - title="video-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - ingest_service = SimpleNamespace(create_task_from_url=lambda url, settings: created_task) - repo = FakeRepo(created_task) - state = self._state(tmpdir, repo, ingest_service=ingest_service) - state["settings"]["ingest"] = {"provider": "bilibili_url", "yt_dlp_cmd": "yt-dlp"} - - response_status, _, body = self._request( - "POST", - "/tasks", - state, - body=json.dumps({"source_type": "bilibili_url", "source_url": source_url}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 201) - self.assertEqual(body["id"], "task-bv") - self.assertEqual(body["source_type"], "bilibili_url") - - def test_post_run_task_action_returns_accepted_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch("biliup_next.app.api_server.run_task_action", return_value={"ok": True, "task_id": "task-1"}) as run_mock: - response_status, _, body = self._request("POST", "/tasks/task-1/actions/run", state) - - self.assertEqual(response_status, 202) - self.assertEqual(body["task_id"], "task-1") - run_mock.assert_called_once_with("task-1") - - def test_post_retry_step_requires_step_name(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="failed_retryable", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - response_status, _, body = self._request( - "POST", - "/tasks/task-1/actions/retry-step", - state, - body=json.dumps({}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "missing step_name") - - def test_post_retry_step_dispatches_to_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="failed_retryable", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.retry_step_action", - return_value={"ok": True, "task_id": "task-1", "step_name": "publish"}, - ) as retry_mock: - response_status, _, body = self._request( - "POST", - "/tasks/task-1/actions/retry-step", - state, - body=json.dumps({"step_name": "publish"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 202) - self.assertEqual(body["step_name"], "publish") - retry_mock.assert_called_once_with("task-1", "publish") - - def test_post_reset_to_step_dispatches_to_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.reset_to_step_action", - return_value={"ok": True, "task_id": "task-1", "step_name": "split"}, - ) as reset_mock: - response_status, _, body = self._request( - "POST", - "/tasks/task-1/actions/reset-to-step", - state, - body=json.dumps({"step_name": "split"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 202) - self.assertEqual(body["step_name"], "split") - reset_mock.assert_called_once_with("task-1", "split") - - def test_post_bind_full_video_requires_bvid(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - response_status, _, body = self._request( - "POST", - "/tasks/task-1/bind-full-video", - state, - body=json.dumps({}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "missing full_video_bvid") - - def test_post_bind_full_video_maps_task_not_found_to_404(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.bind_full_video_action", - return_value={"error": {"code": "TASK_NOT_FOUND", "message": "missing"}}, - ) as bind_mock: - response_status, _, body = self._request( - "POST", - "/tasks/missing/bind-full-video", - state, - body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 404) - self.assertEqual(body["error"]["code"], "TASK_NOT_FOUND") - bind_mock.assert_called_once_with("missing", "BVFULL123") - - def test_post_bind_full_video_dispatches_to_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.bind_full_video_action", - return_value={"ok": True, "task_id": "task-1", "full_video_bvid": "BVFULL123"}, - ) as bind_mock: - response_status, _, body = self._request( - "POST", - "/tasks/task-1/bind-full-video", - state, - body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 202) - self.assertEqual(body["full_video_bvid"], "BVFULL123") - bind_mock.assert_called_once_with("task-1", "BVFULL123") - - def test_post_session_rebind_maps_session_not_found_to_404(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.rebind_session_full_video_action", - return_value={"error": {"code": "SESSION_NOT_FOUND", "message": "missing"}}, - ) as rebind_mock: - response_status, _, body = self._request( - "POST", - "/sessions/session-x/rebind", - state, - body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 404) - self.assertEqual(body["error"]["code"], "SESSION_NOT_FOUND") - rebind_mock.assert_called_once_with("session-x", "BVFULL123") - - def test_post_session_rebind_dispatches_to_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.rebind_session_full_video_action", - return_value={"ok": True, "session_key": "session-1", "full_video_bvid": "BVFULL123"}, - ) as rebind_mock: - response_status, _, body = self._request( - "POST", - "/sessions/session-1/rebind", - state, - body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 202) - self.assertEqual(body["session_key"], "session-1") - rebind_mock.assert_called_once_with("session-1", "BVFULL123") - - def test_post_session_merge_requires_task_ids(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - response_status, _, body = self._request( - "POST", - "/sessions/session-1/merge", - state, - body=json.dumps({}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "missing task_ids") - - def test_post_session_merge_dispatches_to_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.merge_session_action", - return_value={"ok": True, "session_key": "session-1", "merged_task_ids": ["task-1", "task-2"]}, - ) as merge_mock: - response_status, _, body = self._request( - "POST", - "/sessions/session-1/merge", - state, - body=json.dumps({"task_ids": ["task-1", "task-2"]}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 202) - self.assertEqual(body["merged_task_ids"], ["task-1", "task-2"]) - merge_mock.assert_called_once_with("session-1", ["task-1", "task-2"]) - - def test_post_full_video_webhook_rejects_invalid_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - response_status, _, body = self._request( - "POST", - "/webhooks/full-video-uploaded", - state, - body=json.dumps(["not-a-dict"]).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "invalid payload") - - def test_post_full_video_webhook_dispatches_to_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="published", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - with patch( - "biliup_next.app.api_server.receive_full_video_webhook", - return_value={"ok": True, "session_key": "session-1", "full_video_bvid": "BVFULL123"}, - ) as webhook_mock: - response_status, _, body = self._request( - "POST", - "/webhooks/full-video-uploaded", - state, - body=json.dumps({"session_key": "session-1", "full_video_bvid": "BVFULL123"}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 202) - self.assertEqual(body["full_video_bvid"], "BVFULL123") - webhook_mock.assert_called_once_with({"session_key": "session-1", "full_video_bvid": "BVFULL123"}) - - def test_post_worker_run_once_records_action_and_returns_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task) - state = self._state(tmpdir, repo) - - with patch("biliup_next.app.api_server.run_once", return_value={"worker": {"picked": 1}}) as run_once_mock: - response_status, _, body = self._request("POST", "/worker/run-once", state) - - self.assertEqual(response_status, 202) - self.assertEqual(body["worker"]["picked"], 1) - self.assertEqual(repo.actions[-1].action_name, "worker_run_once") - run_once_mock.assert_called_once_with() - - def test_post_scheduler_run_once_records_action_and_returns_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task) - state = self._state(tmpdir, repo) - - with patch( - "biliup_next.app.api_server.run_once", - return_value={"scheduler": {"scan_count": 2}, "worker": {"picked": 0}}, - ) as run_once_mock: - response_status, _, body = self._request("POST", "/scheduler/run-once", state) - - self.assertEqual(response_status, 202) - self.assertEqual(body["scheduler"]["scan_count"], 2) - self.assertEqual(repo.actions[-1].action_name, "scheduler_run_once") - run_once_mock.assert_called_once_with() - - def test_post_runtime_service_maps_invalid_action_to_400(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - runtime = SimpleNamespace(act=lambda service, action: (_ for _ in ()).throw(ValueError("invalid action"))) - - with patch("biliup_next.app.api_server.SystemdRuntime", return_value=runtime): - response_status, _, body = self._request("POST", "/runtime/services/worker/restartx", state) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "invalid action") - - def test_post_runtime_service_records_action_and_returns_payload(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task) - state = self._state(tmpdir, repo) - runtime = SimpleNamespace(act=lambda service, action: {"service": service, "action": action, "command_ok": True}) - - with patch("biliup_next.app.api_server.SystemdRuntime", return_value=runtime): - response_status, _, body = self._request("POST", "/runtime/services/worker/restart", state) - - self.assertEqual(response_status, 202) - self.assertEqual(body["service"], "worker") - self.assertEqual(body["action"], "restart") - self.assertEqual(repo.actions[-1].action_name, "service_action") - - def test_post_stage_import_requires_source_path(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - state["settings"]["paths"]["stage_dir"] = str(Path(tmpdir) / "stage") - state["settings"]["ingest"] = {"stage_min_free_space_mb": 100} - - response_status, _, body = self._request( - "POST", - "/stage/import", - state, - body=json.dumps({}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "missing source_path") - - def test_post_stage_import_records_action_and_returns_created(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task) - state = self._state(tmpdir, repo) - stage_dir = Path(tmpdir) / "stage" - state["settings"]["paths"]["stage_dir"] = str(stage_dir) - state["settings"]["ingest"] = {"stage_min_free_space_mb": 100} - importer = SimpleNamespace(import_file=lambda path, dest, min_free_bytes=0: {"imported_to": str(dest / path.name)}) - - with patch("biliup_next.app.api_server.StageImporter", return_value=importer): - response_status, _, body = self._request( - "POST", - "/stage/import", - state, - body=json.dumps({"source_path": str(Path(tmpdir) / "incoming.mp4")}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - - self.assertEqual(response_status, 201) - self.assertIn("incoming.mp4", body["imported_to"]) - self.assertEqual(repo.actions[-1].action_name, "stage_import") - - def test_post_stage_upload_requires_multipart_content_type(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - state = self._state(tmpdir, FakeRepo(task)) - - response_status, _, body = self._request( - "POST", - "/stage/upload", - state, - body=b"plain", - headers={"Content-Type": "text/plain"}, - ) - - self.assertEqual(response_status, 400) - self.assertEqual(body["error"], "content-type must be multipart/form-data") - - def test_post_stage_upload_records_action_and_returns_created(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task( - id="task-1", - source_type="local_file", - source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), - title="task-title", - status="created", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task) - state = self._state(tmpdir, repo) - stage_dir = Path(tmpdir) / "stage" - state["settings"]["paths"]["stage_dir"] = str(stage_dir) - state["settings"]["ingest"] = {"stage_min_free_space_mb": 100} - importer = SimpleNamespace(import_upload=lambda filename, fileobj, dest, min_free_bytes=0: {"filename": filename, "dest": str(dest)}) - file_item = SimpleNamespace(filename="incoming.mp4", file=io.BytesIO(b"fake-video-content")) - - class FakeFieldStorage(dict): - def __contains__(self, key: object) -> bool: - return key == "file" - - def __getitem__(self, key: str): - if key == "file": - return file_item - raise KeyError(key) - - with patch("biliup_next.app.api_server.StageImporter", return_value=importer): - with patch("biliup_next.app.api_server.cgi.FieldStorage", return_value=FakeFieldStorage()): - response_status, _, payload = self._request( - "POST", - "/stage/upload", - state, - body=b"ignored", - headers={"Content-Type": "multipart/form-data; boundary=----biliupnexttest"}, - ) - - self.assertEqual(response_status, 201) - self.assertEqual(payload["filename"], "incoming.mp4") - self.assertEqual(repo.actions[-1].action_name, "stage_upload") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import ExitStack +from http import HTTPStatus +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from biliup_next.app.api_server import ApiHandler +from biliup_next.core.models import ActionRecord, Artifact, Task, TaskContext, TaskStep + + +class FakeRepo: + def __init__( + self, + task: Task, + *, + context: TaskContext | None = None, + steps: list[TaskStep] | None = None, + artifacts: list[Artifact] | None = None, + actions: list[ActionRecord] | None = None, + ) -> None: + self.task = task + self.context = context + self.steps = steps or [] + self.artifacts = artifacts or [] + self.actions = actions or [] + + def query_tasks(self, **kwargs): # type: ignore[no-untyped-def] + return [self.task], 1 + + def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: + if self.context and self.context.task_id in task_ids: + return {self.context.task_id: self.context} + return {} + + def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: + if self.task.id in task_ids: + return {self.task.id: list(self.steps)} + return {} + + def get_task(self, task_id: str) -> Task | None: + return self.task if task_id == self.task.id else None + + def get_task_context(self, task_id: str) -> TaskContext | None: + return self.context if self.context and task_id == self.context.task_id else None + + def list_steps(self, task_id: str) -> list[TaskStep]: + return list(self.steps) if task_id == self.task.id else [] + + def list_artifacts(self, task_id: str) -> list[Artifact]: + return list(self.artifacts) if task_id == self.task.id else [] + + def list_action_records( + self, + task_id: str | None = None, + limit: int = 200, + action_name: str | None = None, + status: str | None = None, + ) -> list[ActionRecord]: + items = list(self.actions) + if task_id is not None: + items = [item for item in items if item.task_id == task_id] + if action_name is not None: + items = [item for item in items if item.action_name == action_name] + if status is not None: + items = [item for item in items if item.status == status] + return items[:limit] + + def add_action_record(self, action: ActionRecord) -> None: + self.actions.append(action) + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + if self.context and self.context.session_key == session_key: + return [self.context] + return [] + + +class FakeSettingsService: + save_calls: list[dict[str, object]] = [] + promote_calls: int = 0 + + def __init__(self, root) -> None: # type: ignore[no-untyped-def] + self.root = root + + def save_staged_from_redacted(self, payload: dict[str, object]) -> None: + self.__class__.save_calls.append(payload) + + def promote_staged(self) -> None: + self.__class__.promote_calls += 1 + + @classmethod + def reset(cls) -> None: + cls.save_calls = [] + cls.promote_calls = 0 + + +class FakeSettingsReader: + def __init__(self, root, *, settings=None, schema=None) -> None: # type: ignore[no-untyped-def] + self.root = root + self._settings = settings or {} + self._schema = schema or {} + + def load_redacted(self): + return SimpleNamespace(settings=self._settings) + + def load(self): + return SimpleNamespace(schema=self._schema) + + +class ApiServerTests(unittest.TestCase): + def _state( + self, + tmpdir: str, + repo: FakeRepo, + *, + control_token: str = "", + ingest_service: object | None = None, + ) -> dict[str, object]: + state = { + "root": Path(tmpdir), + "repo": repo, + "settings": { + "runtime": {"control_token": control_token}, + "paths": {"session_dir": str(Path(tmpdir) / "session")}, + "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, + "cleanup": {}, + "publish": {"retry_schedule_minutes": [10]}, + }, + "registry": SimpleNamespace(list_manifests=lambda: []), + "manifests": [], + } + if ingest_service is not None: + state["ingest_service"] = ingest_service + return state + + def _request( + self, + method: str, + path: str, + state: dict[str, object], + *, + body: bytes = b"", + headers: dict[str, str] | None = None, + ) -> tuple[int, dict[str, str], object]: + handler = ApiHandler.__new__(ApiHandler) + handler.path = path + handler.headers = {"Content-Length": str(len(body)), **(headers or {})} + handler.rfile = io.BytesIO(body) + handler.wfile = io.BytesIO() + + response_status: dict[str, int] = {"value": HTTPStatus.OK} + response_headers: dict[str, str] = {} + + def send_response(status: int, message: str | None = None) -> None: + response_status["value"] = int(status) + + def send_header(name: str, value: str) -> None: + response_headers[name] = value + + handler.send_response = send_response # type: ignore[method-assign] + handler.send_header = send_header # type: ignore[method-assign] + handler.end_headers = lambda: None # type: ignore[method-assign] + handler.log_message = lambda format, *args: None # type: ignore[method-assign] + + with patch("biliup_next.app.api_server.ensure_initialized", return_value=state): + getattr(handler, f"do_{method}")() + + raw_body = handler.wfile.getvalue().decode("utf-8") + parsed_body = json.loads(raw_body) if raw_body else None + return response_status["value"], response_headers, parsed_body + + def test_get_tasks_returns_serialized_items(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + response_status, _, body = self._request("GET", "/tasks?limit=10&offset=0", state) + + self.assertEqual(response_status, 200) + self.assertEqual(body["total"], 1) + self.assertEqual(body["items"][0]["id"], "task-1") + self.assertEqual(body["items"][0]["delivery_state"]["split_comment"], "pending") + + def test_get_task_timeline_returns_serialized_timeline(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:02:00+00:00", + ) + steps = [ + TaskStep( + id=None, + task_id="task-1", + step_name="publish", + status="failed_retryable", + error_code="ERR_UPLOAD", + error_message="upload failed", + retry_count=1, + started_at="2026-01-01T00:01:00+00:00", + finished_at="2026-01-01T00:01:30+00:00", + ) + ] + artifacts = [ + Artifact( + id=None, + task_id="task-1", + artifact_type="publish_bvid", + path="/tmp/bvid.txt", + metadata_json="{}", + created_at="2026-01-01T00:01:40+00:00", + ) + ] + actions = [ + ActionRecord( + id=None, + task_id="task-1", + action_name="comment", + status="ok", + summary="comment finished", + details_json=json.dumps({"split": {"status": "ok"}, "full": {"status": "skipped"}}), + created_at="2026-01-01T00:01:50+00:00", + ) + ] + state = self._state(tmpdir, FakeRepo(task, steps=steps, artifacts=artifacts, actions=actions)) + + response_status, _, body = self._request("GET", "/tasks/task-1/timeline", state) + + self.assertEqual(response_status, 200) + self.assertGreaterEqual(len(body["items"]), 4) + self.assertEqual(body["items"][0]["kind"], "task") + action_item = next(item for item in body["items"] if item["kind"] == "action") + self.assertIn("split=ok", action_item["summary"]) + + def test_get_session_returns_serialized_tasks(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + context = TaskContext( + id=None, + task_id="task-1", + session_key="session-1", + streamer="streamer", + room_id="room-1", + source_title="task-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid="BVFULL123", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task, context=context)) + + response_status, _, body = self._request("GET", "/sessions/session-1", state) + + self.assertEqual(response_status, 200) + self.assertEqual(body["session_key"], "session-1") + self.assertEqual(body["full_video_bvid"], "BVFULL123") + self.assertEqual(body["tasks"][0]["session_context"]["session_key"], "session-1") + + def test_get_tasks_requires_control_token_when_configured(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task), control_token="secret") + + unauthorized_status, _, unauthorized_body = self._request("GET", "/tasks", state) + authorized_status, _, authorized_body = self._request( + "GET", + "/tasks", + state, + headers={"X-Biliup-Token": "secret"}, + ) + + self.assertEqual(unauthorized_status, 401) + self.assertEqual(unauthorized_body["error"], "unauthorized") + self.assertEqual(authorized_status, 200) + self.assertEqual(authorized_body["items"][0]["id"], "task-1") + + def test_get_history_filters_action_records(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + actions = [ + ActionRecord(None, "task-1", "comment", "ok", "comment ok", "{}", "2026-01-01T00:01:00+00:00"), + ActionRecord(None, "task-1", "publish", "error", "publish error", "{}", "2026-01-01T00:02:00+00:00"), + ] + state = self._state(tmpdir, FakeRepo(task, actions=actions)) + + response_status, _, body = self._request("GET", "/history?task_id=task-1&action_name=comment&status=ok", state) + + self.assertEqual(response_status, 200) + self.assertEqual(len(body["items"]), 1) + self.assertEqual(body["items"][0]["action_name"], "comment") + + def test_get_modules_returns_registry_and_discovered_manifests(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + state["registry"] = SimpleNamespace(list_manifests=lambda: [{"name": "publish.biliup_cli"}]) + state["manifests"] = [{"name": "publish.biliup_cli", "path": "plugins/publish.json"}] + + response_status, _, body = self._request("GET", "/modules", state) + + self.assertEqual(response_status, 200) + self.assertEqual(body["items"][0]["name"], "publish.biliup_cli") + self.assertEqual(body["discovered_manifests"][0]["path"], "plugins/publish.json") + + def test_get_scheduler_preview_returns_builder_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.build_scheduler_preview", + return_value={"items": [{"task_id": "task-1"}]}, + ) as preview_mock: + response_status, _, body = self._request("GET", "/scheduler/preview", state) + + self.assertEqual(response_status, 200) + self.assertEqual(body["items"][0]["task_id"], "task-1") + preview_mock.assert_called_once() + + def test_get_settings_schema_returns_schema_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + fake_service = FakeSettingsReader(Path(tmpdir), schema={"title": "SettingsSchema"}) + + with patch("biliup_next.app.api_server.SettingsService", return_value=fake_service): + response_status, _, body = self._request("GET", "/settings/schema", state) + + self.assertEqual(response_status, 200) + self.assertEqual(body["title"], "SettingsSchema") + + def test_put_settings_promotes_and_resets_initialized_state(self) -> None: + FakeSettingsService.reset() + with tempfile.TemporaryDirectory() as tmpdir: + state = { + "root": Path(tmpdir), + "settings": {"runtime": {"control_token": ""}}, + } + + with ExitStack() as stack: + ensure_mock = stack.enter_context( + patch("biliup_next.app.api_server.ensure_initialized", return_value=state) + ) + reset_mock = stack.enter_context(patch("biliup_next.app.api_server.reset_initialized_state")) + stack.enter_context(patch("biliup_next.app.api_server.SettingsService", FakeSettingsService)) + payload = json.dumps({"runtime": {"control_token": "abc"}}) + handler = ApiHandler.__new__(ApiHandler) + handler.path = "/settings" + handler.headers = { + "Content-Length": str(len(payload.encode("utf-8"))), + "Content-Type": "application/json", + } + handler.rfile = io.BytesIO(payload.encode("utf-8")) + handler.wfile = io.BytesIO() + + response_status: dict[str, int] = {"value": HTTPStatus.OK} + handler.send_response = lambda status, message=None: response_status.__setitem__("value", int(status)) # type: ignore[method-assign] + handler.send_header = lambda name, value: None # type: ignore[method-assign] + handler.end_headers = lambda: None # type: ignore[method-assign] + handler.log_message = lambda format, *args: None # type: ignore[method-assign] + + handler.do_PUT() + body = json.loads(handler.wfile.getvalue().decode("utf-8")) + + self.assertEqual(response_status["value"], 200) + self.assertEqual(body["ok"], True) + self.assertEqual(FakeSettingsService.save_calls[-1], {"runtime": {"control_token": "abc"}}) + self.assertEqual(FakeSettingsService.promote_calls, 1) + reset_mock.assert_called_once() + self.assertGreaterEqual(ensure_mock.call_count, 2) + + def test_post_tasks_creates_task_from_source_path(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + source_path = str(Path(tmpdir) / "stage" / "source.mp4") + created_task = Task( + id="task-new", + source_type="local_file", + source_path=source_path, + title="new-task", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + ingest_service = SimpleNamespace(create_task_from_file=lambda path, settings: created_task) + repo = FakeRepo(created_task) + state = self._state(tmpdir, repo, ingest_service=ingest_service) + state["settings"]["ingest"] = {"min_duration_seconds": 60} + state["settings"]["paths"]["stage_dir"] = str(Path(tmpdir) / "stage") + + response_status, _, body = self._request( + "POST", + "/tasks", + state, + body=json.dumps({"source_path": source_path}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 201) + self.assertEqual(body["id"], "task-new") + self.assertEqual(body["source_path"], source_path) + + def test_post_tasks_creates_task_from_bilibili_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + source_url = "https://www.bilibili.com/video/BV1TEST1234" + created_task = Task( + id="task-bv", + source_type="bilibili_url", + source_path=str(Path(tmpdir) / "session" / "task-bv" / "task-bv.mp4"), + title="video-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + ingest_service = SimpleNamespace(create_task_from_url=lambda url, settings: created_task) + repo = FakeRepo(created_task) + state = self._state(tmpdir, repo, ingest_service=ingest_service) + state["settings"]["ingest"] = {"provider": "bilibili_url", "yt_dlp_cmd": "yt-dlp"} + + response_status, _, body = self._request( + "POST", + "/tasks", + state, + body=json.dumps({"source_type": "bilibili_url", "source_url": source_url}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 201) + self.assertEqual(body["id"], "task-bv") + self.assertEqual(body["source_type"], "bilibili_url") + + def test_post_run_task_action_returns_accepted_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch("biliup_next.app.api_server.run_task_action", return_value={"ok": True, "task_id": "task-1"}) as run_mock: + response_status, _, body = self._request("POST", "/tasks/task-1/actions/run", state) + + self.assertEqual(response_status, 202) + self.assertEqual(body["task_id"], "task-1") + run_mock.assert_called_once_with("task-1") + + def test_post_retry_step_requires_step_name(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="failed_retryable", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + response_status, _, body = self._request( + "POST", + "/tasks/task-1/actions/retry-step", + state, + body=json.dumps({}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "missing step_name") + + def test_post_retry_step_dispatches_to_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="failed_retryable", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.retry_step_action", + return_value={"ok": True, "task_id": "task-1", "step_name": "publish"}, + ) as retry_mock: + response_status, _, body = self._request( + "POST", + "/tasks/task-1/actions/retry-step", + state, + body=json.dumps({"step_name": "publish"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 202) + self.assertEqual(body["step_name"], "publish") + retry_mock.assert_called_once_with("task-1", "publish") + + def test_post_reset_to_step_dispatches_to_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.reset_to_step_action", + return_value={"ok": True, "task_id": "task-1", "step_name": "split"}, + ) as reset_mock: + response_status, _, body = self._request( + "POST", + "/tasks/task-1/actions/reset-to-step", + state, + body=json.dumps({"step_name": "split"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 202) + self.assertEqual(body["step_name"], "split") + reset_mock.assert_called_once_with("task-1", "split") + + def test_post_bind_full_video_requires_bvid(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + response_status, _, body = self._request( + "POST", + "/tasks/task-1/bind-full-video", + state, + body=json.dumps({}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "missing full_video_bvid") + + def test_post_bind_full_video_maps_task_not_found_to_404(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.bind_full_video_action", + return_value={"error": {"code": "TASK_NOT_FOUND", "message": "missing"}}, + ) as bind_mock: + response_status, _, body = self._request( + "POST", + "/tasks/missing/bind-full-video", + state, + body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 404) + self.assertEqual(body["error"]["code"], "TASK_NOT_FOUND") + bind_mock.assert_called_once_with("missing", "BVFULL123") + + def test_post_bind_full_video_dispatches_to_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.bind_full_video_action", + return_value={"ok": True, "task_id": "task-1", "full_video_bvid": "BVFULL123"}, + ) as bind_mock: + response_status, _, body = self._request( + "POST", + "/tasks/task-1/bind-full-video", + state, + body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 202) + self.assertEqual(body["full_video_bvid"], "BVFULL123") + bind_mock.assert_called_once_with("task-1", "BVFULL123") + + def test_post_session_rebind_maps_session_not_found_to_404(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.rebind_session_full_video_action", + return_value={"error": {"code": "SESSION_NOT_FOUND", "message": "missing"}}, + ) as rebind_mock: + response_status, _, body = self._request( + "POST", + "/sessions/session-x/rebind", + state, + body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 404) + self.assertEqual(body["error"]["code"], "SESSION_NOT_FOUND") + rebind_mock.assert_called_once_with("session-x", "BVFULL123") + + def test_post_session_rebind_dispatches_to_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.rebind_session_full_video_action", + return_value={"ok": True, "session_key": "session-1", "full_video_bvid": "BVFULL123"}, + ) as rebind_mock: + response_status, _, body = self._request( + "POST", + "/sessions/session-1/rebind", + state, + body=json.dumps({"full_video_bvid": "BVFULL123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 202) + self.assertEqual(body["session_key"], "session-1") + rebind_mock.assert_called_once_with("session-1", "BVFULL123") + + def test_post_session_merge_requires_task_ids(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + response_status, _, body = self._request( + "POST", + "/sessions/session-1/merge", + state, + body=json.dumps({}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "missing task_ids") + + def test_post_session_merge_dispatches_to_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.merge_session_action", + return_value={"ok": True, "session_key": "session-1", "merged_task_ids": ["task-1", "task-2"]}, + ) as merge_mock: + response_status, _, body = self._request( + "POST", + "/sessions/session-1/merge", + state, + body=json.dumps({"task_ids": ["task-1", "task-2"]}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 202) + self.assertEqual(body["merged_task_ids"], ["task-1", "task-2"]) + merge_mock.assert_called_once_with("session-1", ["task-1", "task-2"]) + + def test_post_full_video_webhook_rejects_invalid_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + response_status, _, body = self._request( + "POST", + "/webhooks/full-video-uploaded", + state, + body=json.dumps(["not-a-dict"]).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "invalid payload") + + def test_post_full_video_webhook_dispatches_to_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="published", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + with patch( + "biliup_next.app.api_server.receive_full_video_webhook", + return_value={"ok": True, "session_key": "session-1", "full_video_bvid": "BVFULL123"}, + ) as webhook_mock: + response_status, _, body = self._request( + "POST", + "/webhooks/full-video-uploaded", + state, + body=json.dumps({"session_key": "session-1", "full_video_bvid": "BVFULL123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 202) + self.assertEqual(body["full_video_bvid"], "BVFULL123") + webhook_mock.assert_called_once_with({"session_key": "session-1", "full_video_bvid": "BVFULL123"}) + + def test_post_worker_run_once_records_action_and_returns_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task) + state = self._state(tmpdir, repo) + + with patch("biliup_next.app.api_server.run_once", return_value={"worker": {"picked": 1}}) as run_once_mock: + response_status, _, body = self._request("POST", "/worker/run-once", state) + + self.assertEqual(response_status, 202) + self.assertEqual(body["worker"]["picked"], 1) + self.assertEqual(repo.actions[-1].action_name, "worker_run_once") + run_once_mock.assert_called_once_with() + + def test_post_scheduler_run_once_records_action_and_returns_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task) + state = self._state(tmpdir, repo) + + with patch( + "biliup_next.app.api_server.run_once", + return_value={"scheduler": {"scan_count": 2}, "worker": {"picked": 0}}, + ) as run_once_mock: + response_status, _, body = self._request("POST", "/scheduler/run-once", state) + + self.assertEqual(response_status, 202) + self.assertEqual(body["scheduler"]["scan_count"], 2) + self.assertEqual(repo.actions[-1].action_name, "scheduler_run_once") + run_once_mock.assert_called_once_with() + + def test_post_runtime_service_maps_invalid_action_to_400(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + runtime = SimpleNamespace(act=lambda service, action: (_ for _ in ()).throw(ValueError("invalid action"))) + + with patch("biliup_next.app.api_server.SystemdRuntime", return_value=runtime): + response_status, _, body = self._request("POST", "/runtime/services/worker/restartx", state) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "invalid action") + + def test_post_runtime_service_records_action_and_returns_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task) + state = self._state(tmpdir, repo) + runtime = SimpleNamespace(act=lambda service, action: {"service": service, "action": action, "command_ok": True}) + + with patch("biliup_next.app.api_server.SystemdRuntime", return_value=runtime): + response_status, _, body = self._request("POST", "/runtime/services/worker/restart", state) + + self.assertEqual(response_status, 202) + self.assertEqual(body["service"], "worker") + self.assertEqual(body["action"], "restart") + self.assertEqual(repo.actions[-1].action_name, "service_action") + + def test_post_stage_import_requires_source_path(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + state["settings"]["paths"]["stage_dir"] = str(Path(tmpdir) / "stage") + state["settings"]["ingest"] = {"stage_min_free_space_mb": 100} + + response_status, _, body = self._request( + "POST", + "/stage/import", + state, + body=json.dumps({}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "missing source_path") + + def test_post_stage_import_records_action_and_returns_created(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task) + state = self._state(tmpdir, repo) + stage_dir = Path(tmpdir) / "stage" + state["settings"]["paths"]["stage_dir"] = str(stage_dir) + state["settings"]["ingest"] = {"stage_min_free_space_mb": 100} + importer = SimpleNamespace(import_file=lambda path, dest, min_free_bytes=0: {"imported_to": str(dest / path.name)}) + + with patch("biliup_next.app.api_server.StageImporter", return_value=importer): + response_status, _, body = self._request( + "POST", + "/stage/import", + state, + body=json.dumps({"source_path": str(Path(tmpdir) / "incoming.mp4")}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + self.assertEqual(response_status, 201) + self.assertIn("incoming.mp4", body["imported_to"]) + self.assertEqual(repo.actions[-1].action_name, "stage_import") + + def test_post_stage_upload_requires_multipart_content_type(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + state = self._state(tmpdir, FakeRepo(task)) + + response_status, _, body = self._request( + "POST", + "/stage/upload", + state, + body=b"plain", + headers={"Content-Type": "text/plain"}, + ) + + self.assertEqual(response_status, 400) + self.assertEqual(body["error"], "content-type must be multipart/form-data") + + def test_post_stage_upload_records_action_and_returns_created(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task( + id="task-1", + source_type="local_file", + source_path=str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), + title="task-title", + status="created", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task) + state = self._state(tmpdir, repo) + stage_dir = Path(tmpdir) / "stage" + state["settings"]["paths"]["stage_dir"] = str(stage_dir) + state["settings"]["ingest"] = {"stage_min_free_space_mb": 100} + importer = SimpleNamespace(import_upload=lambda filename, fileobj, dest, min_free_bytes=0: {"filename": filename, "dest": str(dest)}) + file_item = SimpleNamespace(filename="incoming.mp4", file=io.BytesIO(b"fake-video-content")) + + class FakeFieldStorage(dict): + def __contains__(self, key: object) -> bool: + return key == "file" + + def __getitem__(self, key: str): + if key == "file": + return file_item + raise KeyError(key) + + with patch("biliup_next.app.api_server.StageImporter", return_value=importer): + with patch("biliup_next.app.api_server.cgi.FieldStorage", return_value=FakeFieldStorage()): + response_status, _, payload = self._request( + "POST", + "/stage/upload", + state, + body=b"ignored", + headers={"Content-Type": "multipart/form-data; boundary=----biliupnexttest"}, + ) + + self.assertEqual(response_status, 201) + self.assertEqual(payload["filename"], "incoming.mp4") + self.assertEqual(repo.actions[-1].action_name, "stage_upload") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bilibili_top_comment_provider.py b/tests/test_bilibili_top_comment_provider.py index 4c893c3..9b68686 100644 --- a/tests/test_bilibili_top_comment_provider.py +++ b/tests/test_bilibili_top_comment_provider.py @@ -88,7 +88,7 @@ class BilibiliTopCommentProviderTests(unittest.TestCase): self.assertEqual(result["split"]["reason"], "comment_disabled") self.assertEqual(len(api.reply_calls), 1) self.assertIn("P1:\n1. Song A — Artist A", api.reply_calls[0]["content"]) - self.assertIn("P2:\n1. Song B — Artist B", api.reply_calls[0]["content"]) + self.assertIn("P2:\n2. Song B — Artist B", api.reply_calls[0]["content"]) def test_split_comment_skips_on_non_anchor_task(self) -> None: api = _FakeBilibiliApi() @@ -212,6 +212,63 @@ class BilibiliTopCommentProviderTests(unittest.TestCase): self.assertEqual(result["split"]["reason"], "comment_disabled") self.assertTrue((work_dir / "comment_done.flag").exists()) + def test_comment_format_can_be_configured_from_upload_config(self) -> None: + api = _FakeBilibiliApi() + provider = BilibiliTopCommentProvider(bilibili_api=api) + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + work_dir = root / "task-1" + work_dir.mkdir(parents=True, exist_ok=True) + task = Task( + id="task-1", + source_type="local_file", + source_path=str(work_dir / "source.mp4"), + title="task-1", + status="published", + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + (work_dir / "songs.txt").write_text("00:00:00 Song From Text — Artist T\n", encoding="utf-8") + (work_dir / "songs.json").write_text( + json.dumps({"songs": [{"title": "Song A", "artist": "Artist A"}]}), + encoding="utf-8", + ) + (work_dir / "bvid.txt").write_text("BV1COMMENT123", encoding="utf-8") + (work_dir / "full_video_bvid.txt").write_text("BV1FULL12345", encoding="utf-8") + cookies_file = root / "cookies.json" + cookies_file.write_text("{}", encoding="utf-8") + upload_config = root / "upload_config.json" + upload_config.write_text( + json.dumps( + { + "comment_template": { + "split_header": "这是纯享:{current_full_video_link}\n上一场:{previous_full_video_link}", + "split_song_line": "#{song_index} {title} / {artist}", + } + } + ), + encoding="utf-8", + ) + + result = provider.comment( + task, + { + "session_dir": str(root), + "cookies_file": str(cookies_file), + "upload_config_file": str(upload_config), + "post_split_comment": True, + "post_full_video_timeline_comment": False, + }, + ) + + self.assertEqual(result["status"], "ok") + self.assertEqual(result["split"]["reason"], "comment_disabled") + self.assertEqual(len(api.reply_calls), 1) + content = str(api.reply_calls[0]["content"]) + self.assertIn("这是纯享:https://www.bilibili.com/video/BV1FULL12345", content) + self.assertNotIn("上一场:", content) + self.assertIn("#1 Song A / Artist A", content) + def test_full_comment_aggregates_session_parts_on_anchor_task(self) -> None: api = _FakeBilibiliApi() provider = BilibiliTopCommentProvider(bilibili_api=api) @@ -263,8 +320,8 @@ class BilibiliTopCommentProviderTests(unittest.TestCase): self.assertEqual(result["full"]["status"], "skipped") self.assertEqual(result["full"]["reason"], "comment_disabled") self.assertEqual(len(api.reply_calls), 1) - self.assertIn("P1:\n00:00:01 Song A\n00:02:00 Song B", api.reply_calls[0]["content"]) - self.assertIn("P2:\n00:00:03 Song C", api.reply_calls[0]["content"]) + self.assertIn("P1:\n1. 00:00:01 Song A\n2. 00:02:00 Song B", api.reply_calls[0]["content"]) + self.assertIn("P2:\n3. 00:00:03 Song C", api.reply_calls[0]["content"]) def test_full_comment_skips_on_non_anchor_task(self) -> None: api = _FakeBilibiliApi() diff --git a/tests/test_biliup_cli_publish_provider.py b/tests/test_biliup_cli_publish_provider.py index 75f3172..86550ce 100644 --- a/tests/test_biliup_cli_publish_provider.py +++ b/tests/test_biliup_cli_publish_provider.py @@ -269,6 +269,117 @@ class BiliupCliPublishProviderTests(unittest.TestCase): self.assertIn("BV1RESUME1234", adapter.run_calls[0]["cmd"]) self.assertTrue((work_dir / "upload_done.flag").exists()) + def test_publish_recovers_bvid_from_progress_when_bvid_file_was_removed(self) -> None: + adapter = _FakeBiliupAdapter() + provider = BiliupCliPublishProvider(adapter=adapter) + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + work_dir = root / "task-1" + work_dir.mkdir(parents=True, exist_ok=True) + task = Task( + id="task-1", + source_type="local_file", + source_path=str(work_dir / "source.mp4"), + title="task-1", + status="split_done", + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") + (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8") + (work_dir / "publish_progress.json").write_text( + json.dumps({"bvid": "BV1RESUME1234", "completed_append_batches": [2]}), + encoding="utf-8", + ) + upload_config = root / "upload_config.json" + upload_config.write_text("{}", encoding="utf-8") + clips = [] + for index in range(1, 16): + clip_path = work_dir / f"clip-{index}.mp4" + clip_path.write_text("fake", encoding="utf-8") + clips.append( + Artifact( + id=None, + task_id=task.id, + artifact_type="clip_video", + path=str(clip_path), + metadata_json="{}", + created_at=utc_now_iso(), + ) + ) + + with patch("biliup_next.modules.publish.providers.biliup_cli.time.sleep", return_value=None): + record = provider.publish( + task, + clips, + { + "session_dir": str(root), + "upload_config_file": str(upload_config), + "biliup_path": "runtime/biliup", + "cookie_file": "runtime/cookies.json", + "retry_count": 2, + "command_timeout_seconds": 123, + }, + ) + + self.assertEqual(record.bvid, "BV1RESUME1234") + self.assertEqual((work_dir / "bvid.txt").read_text(encoding="utf-8"), "BV1RESUME1234") + self.assertEqual(len(adapter.run_calls), 1) + self.assertIn("append", adapter.run_calls[0]["cmd"]) + self.assertIn("BV1RESUME1234", adapter.run_calls[0]["cmd"]) + + def test_publish_renumbers_clip_filenames_across_aggregated_sessions(self) -> None: + adapter = _FakeBiliupAdapter() + provider = BiliupCliPublishProvider(adapter=adapter) + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + work_dir = root / "task-1" + second_dir = root / "task-2" + work_dir.mkdir(parents=True, exist_ok=True) + second_dir.mkdir(parents=True, exist_ok=True) + task = Task( + id="task-1", + source_type="local_file", + source_path=str(work_dir / "source.mp4"), + title="task-1", + status="split_done", + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") + (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8") + upload_config = root / "upload_config.json" + upload_config.write_text("{}", encoding="utf-8") + clips = [] + for index in range(1, 11): + clip_path = work_dir / f"{index:02d}_first-{index}.mp4" + clip_path.write_text("fake", encoding="utf-8") + clips.append(Artifact(None, task.id, "clip_video", str(clip_path), "{}", utc_now_iso())) + for index in range(1, 8): + clip_path = second_dir / f"{index:02d}_second-{index}.mp4" + clip_path.write_text("fake", encoding="utf-8") + clips.append(Artifact(None, "task-2", "clip_video", str(clip_path), "{}", utc_now_iso())) + + with patch("biliup_next.modules.publish.providers.biliup_cli.time.sleep", return_value=None): + provider.publish( + task, + clips, + { + "session_dir": str(root), + "upload_config_file": str(upload_config), + "biliup_path": "runtime/biliup", + "cookie_file": "runtime/cookies.json", + "retry_count": 1, + "command_timeout_seconds": 123, + }, + ) + + all_uploaded = [part for call in adapter.run_calls for part in call["cmd"] if str(part).endswith(".mp4")] + self.assertTrue(all_uploaded[0].endswith("01_first-1.mp4")) + self.assertTrue(all_uploaded[9].endswith("10_first-10.mp4")) + self.assertTrue(all_uploaded[10].endswith("11_second-1.mp4")) + self.assertTrue(all_uploaded[16].endswith("17_second-7.mp4")) + def test_publish_creates_progress_from_existing_bvid_for_append_resume(self) -> None: adapter = _FakeBiliupAdapter() provider = BiliupCliPublishProvider(adapter=adapter) diff --git a/tests/test_collection_service.py b/tests/test_collection_service.py new file mode 100644 index 0000000..1f9b544 --- /dev/null +++ b/tests/test_collection_service.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from biliup_next.core.models import Task, TaskStep, utc_now_iso +from biliup_next.modules.collection.service import CollectionService + + +class _FakeRegistry: + def __init__(self, provider) -> None: # type: ignore[no-untyped-def] + self.provider = provider + + def get(self, provider_type: str, provider_id: str): # type: ignore[no-untyped-def] + return self.provider + + +class _FakeProvider: + def sync(self, task, target: str, settings: dict[str, object]) -> dict[str, object]: # type: ignore[no-untyped-def] + return {"status": "skipped", "target": target} + + +class _FakeRepo: + def __init__(self) -> None: + now = utc_now_iso() + self.task = Task("task-1", "local_file", "/tmp/source.mp4", "task-1", "running", now, now) + self.steps = { + "collection_a": TaskStep(None, "task-1", "collection_a", "pending", None, None, 0, None, None), + "collection_b": TaskStep(None, "task-1", "collection_b", "pending", None, None, 0, None, None), + } + self.task_status_updates: list[tuple[str, str]] = [] + + def get_task(self, task_id: str): # type: ignore[no-untyped-def] + return self.task if task_id == self.task.id else None + + def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # type: ignore[no-untyped-def] + step = self.steps[step_name] + self.steps[step_name] = TaskStep( + step.id, + step.task_id, + step.step_name, + status, + kwargs.get("error_code", step.error_code), + kwargs.get("error_message", step.error_message), + kwargs.get("retry_count", step.retry_count), + kwargs.get("started_at", step.started_at), + kwargs.get("finished_at", step.finished_at), + ) + + def list_steps(self, task_id: str) -> list[TaskStep]: + return list(self.steps.values()) + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + self.task_status_updates.append((task_id, status)) + + +class CollectionServiceTests(unittest.TestCase): + def test_collection_a_restores_commented_status_so_collection_b_can_run(self) -> None: + repo = _FakeRepo() + service = CollectionService(_FakeRegistry(_FakeProvider()), repo) # type: ignore[arg-type] + service.cleanup = SimpleNamespace(cleanup_task_outputs=lambda task_id, settings: {}) # type: ignore[assignment] + + result = service.run("task-1", "a", {"provider": "fake"}) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(repo.steps["collection_a"].status, "succeeded") + self.assertEqual(repo.steps["collection_b"].status, "pending") + self.assertEqual(repo.task_status_updates[-1], ("task-1", "commented")) + + def test_collection_b_marks_collection_synced_when_both_steps_succeeded(self) -> None: + repo = _FakeRepo() + repo.steps["collection_a"] = TaskStep(None, "task-1", "collection_a", "succeeded", None, None, 0, None, utc_now_iso()) + service = CollectionService(_FakeRegistry(_FakeProvider()), repo) # type: ignore[arg-type] + service.cleanup = SimpleNamespace(cleanup_task_outputs=lambda task_id, settings: {"deleted": []}) # type: ignore[assignment] + + result = service.run("task-1", "b", {"provider": "fake"}) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(repo.steps["collection_b"].status, "succeeded") + self.assertEqual(repo.task_status_updates[-1], ("task-1", "collection_synced")) + self.assertEqual(result["cleanup"], {"deleted": []}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_control_plane_get_dispatcher.py b/tests/test_control_plane_get_dispatcher.py index 0e6840d..b71843d 100644 --- a/tests/test_control_plane_get_dispatcher.py +++ b/tests/test_control_plane_get_dispatcher.py @@ -1,149 +1,149 @@ -from __future__ import annotations - -import tempfile -import unittest -from http import HTTPStatus -from pathlib import Path -from types import SimpleNamespace - -from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher -from biliup_next.core.models import ActionRecord, Task, TaskContext - - -class FakeRepo: - def __init__(self, task: Task, context: TaskContext | None = None, actions: list[ActionRecord] | None = None) -> None: - self.task = task - self.context = context - self.actions = actions or [] - - def query_tasks(self, **kwargs): # type: ignore[no-untyped-def] - return [self.task], 1 - - def get_task(self, task_id: str) -> Task | None: - return self.task if task_id == self.task.id else None - - def get_task_context(self, task_id: str) -> TaskContext | None: - return self.context if self.context and self.context.task_id == task_id else None - - def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: - if self.context and self.context.task_id in task_ids: - return {self.context.task_id: self.context} - return {} - - def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[object]]: - return {self.task.id: []} if self.task.id in task_ids else {} - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - if self.context and self.context.session_key == session_key: - return [self.context] - return [] - - def list_steps(self, task_id: str) -> list[object]: - return [] - - def list_artifacts(self, task_id: str) -> list[object]: - return [] - - def list_action_records( - self, - task_id: str | None = None, - limit: int = 200, - action_name: str | None = None, - status: str | None = None, - ) -> list[ActionRecord]: - items = list(self.actions) - if task_id is not None: - items = [item for item in items if item.task_id == task_id] - if action_name is not None: - items = [item for item in items if item.action_name == action_name] - if status is not None: - items = [item for item in items if item.status == status] - return items[:limit] - - -class FakeSettingsService: - def __init__(self, root) -> None: # type: ignore[no-untyped-def] - self.root = root - - def load_redacted(self): - return SimpleNamespace(settings={"runtime": {"control_token": "secret"}}) - - def load(self): - return SimpleNamespace(schema={"title": "SettingsSchema"}) - - -class ControlPlaneGetDispatcherTests(unittest.TestCase): - def _dispatcher(self, tmpdir: str, repo: FakeRepo) -> ControlPlaneGetDispatcher: - state = { - "root": Path(tmpdir), - "repo": repo, - "settings": { - "paths": {"session_dir": str(Path(tmpdir) / "session")}, - "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, - "cleanup": {}, - "publish": {}, - }, - "registry": SimpleNamespace(list_manifests=lambda: [{"name": "publish.biliup_cli"}]), - "manifests": [{"name": "publish.biliup_cli"}], - } - return ControlPlaneGetDispatcher( - state, - attention_state_fn=lambda payload: "running" if payload.get("status") == "running" else "stable", - delivery_state_label_fn=lambda payload: "pending_comment" if payload.get("delivery_state", {}).get("split_comment") == "pending" else "stable", - build_scheduler_preview_fn=lambda state, include_stage_scan=False, limit=200: {"items": [{"limit": limit}]}, - settings_service_factory=FakeSettingsService, - ) - - def test_handle_settings_schema_returns_schema(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - dispatcher = self._dispatcher(tmpdir, FakeRepo(task)) - - body, status = dispatcher.handle_settings_schema() - - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(body["title"], "SettingsSchema") - - def test_handle_history_filters_records(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - actions = [ - ActionRecord(None, "task-1", "comment", "ok", "comment ok", "{}", "2026-01-01T00:01:00+00:00"), - ActionRecord(None, "task-1", "publish", "error", "publish failed", "{}", "2026-01-01T00:02:00+00:00"), - ] - dispatcher = self._dispatcher(tmpdir, FakeRepo(task, actions=actions)) - - body, status = dispatcher.handle_history(limit=100, task_id="task-1", action_name="comment", status="ok") - - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(len(body["items"]), 1) - self.assertEqual(body["items"][0]["action_name"], "comment") - - def test_handle_session_returns_not_found_when_missing(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - dispatcher = self._dispatcher(tmpdir, FakeRepo(task)) - - body, status = dispatcher.handle_session("missing-session") - - self.assertEqual(status, HTTPStatus.NOT_FOUND) - self.assertEqual(body["error"], "session not found") - - def test_handle_tasks_filters_attention(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - dispatcher = self._dispatcher(tmpdir, FakeRepo(task)) - - body, status = dispatcher.handle_tasks( - limit=10, - offset=0, - status=None, - search=None, - sort="updated_desc", - attention="running", - delivery=None, - ) - - self.assertEqual(status, HTTPStatus.OK) - self.assertEqual(body["total"], 1) - self.assertEqual(body["items"][0]["id"], "task-1") +from __future__ import annotations + +import tempfile +import unittest +from http import HTTPStatus +from pathlib import Path +from types import SimpleNamespace + +from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher +from biliup_next.core.models import ActionRecord, Task, TaskContext + + +class FakeRepo: + def __init__(self, task: Task, context: TaskContext | None = None, actions: list[ActionRecord] | None = None) -> None: + self.task = task + self.context = context + self.actions = actions or [] + + def query_tasks(self, **kwargs): # type: ignore[no-untyped-def] + return [self.task], 1 + + def get_task(self, task_id: str) -> Task | None: + return self.task if task_id == self.task.id else None + + def get_task_context(self, task_id: str) -> TaskContext | None: + return self.context if self.context and self.context.task_id == task_id else None + + def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: + if self.context and self.context.task_id in task_ids: + return {self.context.task_id: self.context} + return {} + + def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[object]]: + return {self.task.id: []} if self.task.id in task_ids else {} + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + if self.context and self.context.session_key == session_key: + return [self.context] + return [] + + def list_steps(self, task_id: str) -> list[object]: + return [] + + def list_artifacts(self, task_id: str) -> list[object]: + return [] + + def list_action_records( + self, + task_id: str | None = None, + limit: int = 200, + action_name: str | None = None, + status: str | None = None, + ) -> list[ActionRecord]: + items = list(self.actions) + if task_id is not None: + items = [item for item in items if item.task_id == task_id] + if action_name is not None: + items = [item for item in items if item.action_name == action_name] + if status is not None: + items = [item for item in items if item.status == status] + return items[:limit] + + +class FakeSettingsService: + def __init__(self, root) -> None: # type: ignore[no-untyped-def] + self.root = root + + def load_redacted(self): + return SimpleNamespace(settings={"runtime": {"control_token": "secret"}}) + + def load(self): + return SimpleNamespace(schema={"title": "SettingsSchema"}) + + +class ControlPlaneGetDispatcherTests(unittest.TestCase): + def _dispatcher(self, tmpdir: str, repo: FakeRepo) -> ControlPlaneGetDispatcher: + state = { + "root": Path(tmpdir), + "repo": repo, + "settings": { + "paths": {"session_dir": str(Path(tmpdir) / "session")}, + "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, + "cleanup": {}, + "publish": {}, + }, + "registry": SimpleNamespace(list_manifests=lambda: [{"name": "publish.biliup_cli"}]), + "manifests": [{"name": "publish.biliup_cli"}], + } + return ControlPlaneGetDispatcher( + state, + attention_state_fn=lambda payload: "running" if payload.get("status") == "running" else "stable", + delivery_state_label_fn=lambda payload: "pending_comment" if payload.get("delivery_state", {}).get("split_comment") == "pending" else "stable", + build_scheduler_preview_fn=lambda state, include_stage_scan=False, limit=200: {"items": [{"limit": limit}]}, + settings_service_factory=FakeSettingsService, + ) + + def test_handle_settings_schema_returns_schema(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + dispatcher = self._dispatcher(tmpdir, FakeRepo(task)) + + body, status = dispatcher.handle_settings_schema() + + self.assertEqual(status, HTTPStatus.OK) + self.assertEqual(body["title"], "SettingsSchema") + + def test_handle_history_filters_records(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + actions = [ + ActionRecord(None, "task-1", "comment", "ok", "comment ok", "{}", "2026-01-01T00:01:00+00:00"), + ActionRecord(None, "task-1", "publish", "error", "publish failed", "{}", "2026-01-01T00:02:00+00:00"), + ] + dispatcher = self._dispatcher(tmpdir, FakeRepo(task, actions=actions)) + + body, status = dispatcher.handle_history(limit=100, task_id="task-1", action_name="comment", status="ok") + + self.assertEqual(status, HTTPStatus.OK) + self.assertEqual(len(body["items"]), 1) + self.assertEqual(body["items"][0]["action_name"], "comment") + + def test_handle_session_returns_not_found_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + dispatcher = self._dispatcher(tmpdir, FakeRepo(task)) + + body, status = dispatcher.handle_session("missing-session") + + self.assertEqual(status, HTTPStatus.NOT_FOUND) + self.assertEqual(body["error"], "session not found") + + def test_handle_tasks_filters_attention(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + dispatcher = self._dispatcher(tmpdir, FakeRepo(task)) + + body, status = dispatcher.handle_tasks( + limit=10, + offset=0, + status=None, + search=None, + sort="updated_desc", + attention="running", + delivery=None, + ) + + self.assertEqual(status, HTTPStatus.OK) + self.assertEqual(body["total"], 1) + self.assertEqual(body["items"][0]["id"], "task-1") diff --git a/tests/test_control_plane_post_dispatcher.py b/tests/test_control_plane_post_dispatcher.py index dff79e8..6c21c9c 100644 --- a/tests/test_control_plane_post_dispatcher.py +++ b/tests/test_control_plane_post_dispatcher.py @@ -1,111 +1,111 @@ -from __future__ import annotations - -import io -import tempfile -import unittest -from http import HTTPStatus -from pathlib import Path -from types import SimpleNamespace - -from biliup_next.app.control_plane_post_dispatcher import ControlPlanePostDispatcher -from biliup_next.core.models import Task - - -class FakeRepo: - def __init__(self) -> None: - self.actions = [] - - def add_action_record(self, action) -> None: # type: ignore[no-untyped-def] - self.actions.append(action) - - -class ModuleError(Exception): - def to_dict(self) -> dict[str, object]: - return {"error": "conflict"} - - -class ControlPlanePostDispatcherTests(unittest.TestCase): - def _dispatcher(self, tmpdir: str, repo: FakeRepo, *, ingest_service: object | None = None) -> ControlPlanePostDispatcher: - state = { - "repo": repo, - "root": Path(tmpdir), - "settings": { - "paths": {"stage_dir": str(Path(tmpdir) / "stage"), "session_dir": str(Path(tmpdir) / "session")}, - "ingest": {"stage_min_free_space_mb": 100}, - }, - "ingest_service": ingest_service or SimpleNamespace( - create_task_from_file=lambda path, settings: Task( - "task-1", - "local_file", - str(path), - "task-title", - "created", - "2026-01-01T00:00:00+00:00", - "2026-01-01T00:00:00+00:00", - ) - ), - } - return ControlPlanePostDispatcher( - state, - bind_full_video_action=lambda task_id, bvid: {"task_id": task_id, "full_video_bvid": bvid}, - merge_session_action=lambda session_key, task_ids: {"session_key": session_key, "task_ids": task_ids}, - receive_full_video_webhook=lambda payload: {"ok": True, **payload}, - rebind_session_full_video_action=lambda session_key, bvid: {"session_key": session_key, "full_video_bvid": bvid}, - reset_to_step_action=lambda task_id, step_name: {"task_id": task_id, "step_name": step_name}, - retry_step_action=lambda task_id, step_name: {"task_id": task_id, "step_name": step_name}, - run_task_action=lambda task_id: {"task_id": task_id}, - run_once=lambda: {"scheduler": {"scan_count": 1}, "worker": {"picked": 1}}, - stage_importer_factory=lambda: SimpleNamespace( - import_file=lambda source, dest, min_free_bytes=0: {"imported_to": str(dest / source.name)}, - import_upload=lambda filename, fileobj, dest, min_free_bytes=0: {"filename": filename, "dest": str(dest)}, - ), - systemd_runtime_factory=lambda: SimpleNamespace(act=lambda service, action: {"service": service, "action": action, "command_ok": True}), - ) - - def test_handle_bind_full_video_maps_missing_bvid(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - dispatcher = self._dispatcher(tmpdir, FakeRepo()) - - body, status = dispatcher.handle_bind_full_video("task-1", {}) - - self.assertEqual(status, HTTPStatus.BAD_REQUEST) - self.assertEqual(body["error"], "missing full_video_bvid") - - def test_handle_worker_run_once_records_action(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo = FakeRepo() - dispatcher = self._dispatcher(tmpdir, repo) - - body, status = dispatcher.handle_worker_run_once() - - self.assertEqual(status, HTTPStatus.ACCEPTED) - self.assertEqual(body["worker"]["picked"], 1) - self.assertEqual(repo.actions[-1].action_name, "worker_run_once") - - def test_handle_stage_upload_returns_created(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - dispatcher = self._dispatcher(tmpdir, FakeRepo()) - file_item = SimpleNamespace(filename="incoming.mp4", file=io.BytesIO(b"video")) - - body, status = dispatcher.handle_stage_upload(file_item) - - self.assertEqual(status, HTTPStatus.CREATED) - self.assertEqual(body["filename"], "incoming.mp4") - - def test_handle_create_task_maps_module_error_to_conflict(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo = FakeRepo() - - def raise_module_error(path, settings): # type: ignore[no-untyped-def] - raise ModuleError() - - dispatcher = self._dispatcher( - tmpdir, - repo, - ingest_service=SimpleNamespace(create_task_from_file=raise_module_error), - ) - - body, status = dispatcher.handle_create_task({"source_path": str(Path(tmpdir) / "source.mp4")}) - - self.assertEqual(status, HTTPStatus.CONFLICT) - self.assertEqual(body["error"], "conflict") +from __future__ import annotations + +import io +import tempfile +import unittest +from http import HTTPStatus +from pathlib import Path +from types import SimpleNamespace + +from biliup_next.app.control_plane_post_dispatcher import ControlPlanePostDispatcher +from biliup_next.core.models import Task + + +class FakeRepo: + def __init__(self) -> None: + self.actions = [] + + def add_action_record(self, action) -> None: # type: ignore[no-untyped-def] + self.actions.append(action) + + +class ModuleError(Exception): + def to_dict(self) -> dict[str, object]: + return {"error": "conflict"} + + +class ControlPlanePostDispatcherTests(unittest.TestCase): + def _dispatcher(self, tmpdir: str, repo: FakeRepo, *, ingest_service: object | None = None) -> ControlPlanePostDispatcher: + state = { + "repo": repo, + "root": Path(tmpdir), + "settings": { + "paths": {"stage_dir": str(Path(tmpdir) / "stage"), "session_dir": str(Path(tmpdir) / "session")}, + "ingest": {"stage_min_free_space_mb": 100}, + }, + "ingest_service": ingest_service or SimpleNamespace( + create_task_from_file=lambda path, settings: Task( + "task-1", + "local_file", + str(path), + "task-title", + "created", + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ) + ), + } + return ControlPlanePostDispatcher( + state, + bind_full_video_action=lambda task_id, bvid: {"task_id": task_id, "full_video_bvid": bvid}, + merge_session_action=lambda session_key, task_ids: {"session_key": session_key, "task_ids": task_ids}, + receive_full_video_webhook=lambda payload: {"ok": True, **payload}, + rebind_session_full_video_action=lambda session_key, bvid: {"session_key": session_key, "full_video_bvid": bvid}, + reset_to_step_action=lambda task_id, step_name: {"task_id": task_id, "step_name": step_name}, + retry_step_action=lambda task_id, step_name: {"task_id": task_id, "step_name": step_name}, + run_task_action=lambda task_id: {"task_id": task_id}, + run_once=lambda: {"scheduler": {"scan_count": 1}, "worker": {"picked": 1}}, + stage_importer_factory=lambda: SimpleNamespace( + import_file=lambda source, dest, min_free_bytes=0: {"imported_to": str(dest / source.name)}, + import_upload=lambda filename, fileobj, dest, min_free_bytes=0: {"filename": filename, "dest": str(dest)}, + ), + systemd_runtime_factory=lambda: SimpleNamespace(act=lambda service, action: {"service": service, "action": action, "command_ok": True}), + ) + + def test_handle_bind_full_video_maps_missing_bvid(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + dispatcher = self._dispatcher(tmpdir, FakeRepo()) + + body, status = dispatcher.handle_bind_full_video("task-1", {}) + + self.assertEqual(status, HTTPStatus.BAD_REQUEST) + self.assertEqual(body["error"], "missing full_video_bvid") + + def test_handle_worker_run_once_records_action(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo = FakeRepo() + dispatcher = self._dispatcher(tmpdir, repo) + + body, status = dispatcher.handle_worker_run_once() + + self.assertEqual(status, HTTPStatus.ACCEPTED) + self.assertEqual(body["worker"]["picked"], 1) + self.assertEqual(repo.actions[-1].action_name, "worker_run_once") + + def test_handle_stage_upload_returns_created(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + dispatcher = self._dispatcher(tmpdir, FakeRepo()) + file_item = SimpleNamespace(filename="incoming.mp4", file=io.BytesIO(b"video")) + + body, status = dispatcher.handle_stage_upload(file_item) + + self.assertEqual(status, HTTPStatus.CREATED) + self.assertEqual(body["filename"], "incoming.mp4") + + def test_handle_create_task_maps_module_error_to_conflict(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo = FakeRepo() + + def raise_module_error(path, settings): # type: ignore[no-untyped-def] + raise ModuleError() + + dispatcher = self._dispatcher( + tmpdir, + repo, + ingest_service=SimpleNamespace(create_task_from_file=raise_module_error), + ) + + body, status = dispatcher.handle_create_task({"source_path": str(Path(tmpdir) / "source.mp4")}) + + self.assertEqual(status, HTTPStatus.CONFLICT) + self.assertEqual(body["error"], "conflict") diff --git a/tests/test_groq_transcribe_provider.py b/tests/test_groq_transcribe_provider.py new file mode 100644 index 0000000..e87878d --- /dev/null +++ b/tests/test_groq_transcribe_provider.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import Artifact, Task +from biliup_next.modules.transcribe.providers.groq import GroqTranscribeProvider + + +class _FakeResponse: + def __init__(self, segments): + self.segments = segments + + +class _FakeTranscriptions: + def __init__(self, outcomes: list[object]) -> None: + self.outcomes = list(outcomes) + self.calls: list[dict[str, object]] = [] + + def create(self, **kwargs): # noqa: ANN003 + self.calls.append(kwargs) + outcome = self.outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +class _FakeGroqClient: + def __init__(self, outcomes: list[object]) -> None: + self.audio = SimpleNamespace(transcriptions=_FakeTranscriptions(outcomes)) + + +class GroqTranscribeProviderTests(unittest.TestCase): + def test_transcribe_retries_timeout_and_writes_srt_atomically(self) -> None: + provider = GroqTranscribeProvider() + task = Task("task-1", "local_file", "/tmp/input.mp4", "demo", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + source_path = work_dir / "input.mp4" + source_path.write_bytes(b"video") + source_video = Artifact(None, task.id, "source_video", str(source_path), "{}", "2026-01-01T00:00:00+00:00") + segment = work_dir / "temp_audio" / "part_000.mp3" + + def fake_extract_audio_segments(**kwargs): # noqa: ANN003 + segment.parent.mkdir(parents=True, exist_ok=True) + segment.write_bytes(b"audio") + + client = _FakeGroqClient( + [ + RuntimeError("Request timed out."), + _FakeResponse([{"start": 0, "end": 1.2, "text": "hello"}]), + ] + ) + + settings = { + "groq_api_key": "gsk_test", + "ffmpeg_bin": "ffmpeg", + "max_file_size_mb": 23, + "request_timeout_seconds": 33, + "request_max_retries": 1, + "request_retry_backoff_seconds": 0, + "serialize_groq_requests": False, + } + + with patch("groq.Groq", return_value=client) as groq_ctor: + with patch.object(provider, "_extract_audio_segments", side_effect=fake_extract_audio_segments): + artifact = provider.transcribe(task, source_video, settings) + + self.assertEqual(Path(artifact.path).read_text(encoding="utf-8"), "1\n00:00:00,000 --> 00:00:01,199\nhello\n\n") + self.assertFalse((work_dir / ".demo.srt.tmp").exists()) + self.assertEqual(len(client.audio.transcriptions.calls), 2) + self.assertEqual(client.audio.transcriptions.calls[0]["timeout"], 33) + self.assertTrue((work_dir / "transcribe_segments" / "part_000.json").exists()) + groq_ctor.assert_called_once_with(api_key="gsk_test", timeout=33, max_retries=0) + + def test_transcribe_reuses_completed_segment_checkpoints(self) -> None: + provider = GroqTranscribeProvider() + task = Task("task-1", "local_file", "/tmp/input.mp4", "demo", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + source_path = work_dir / "input.mp4" + source_path.write_bytes(b"video") + source_video = Artifact(None, task.id, "source_video", str(source_path), "{}", "2026-01-01T00:00:00+00:00") + segments = [work_dir / "temp_audio" / "part_000.mp3", work_dir / "temp_audio" / "part_001.mp3"] + checkpoint_dir = work_dir / "transcribe_segments" + checkpoint_dir.mkdir() + (checkpoint_dir / "part_000.json").write_text( + json.dumps( + { + "provider": "groq", + "model": "whisper-large-v3-turbo", + "language": "zh", + "audio_file": "part_000.mp3", + "segment_duration_seconds": 75, + "segments": [{"start": 0, "end": 1, "text": "first"}], + } + ), + encoding="utf-8", + ) + + def fake_extract_audio_segments(**kwargs): # noqa: ANN003 + for segment in segments: + segment.parent.mkdir(parents=True, exist_ok=True) + segment.write_bytes(b"audio") + + client = _FakeGroqClient([_FakeResponse([{"start": 0, "end": 1.5, "text": "second"}])]) + settings = { + "groq_api_key": "gsk_test", + "ffmpeg_bin": "ffmpeg", + "max_file_size_mb": 23, + "request_timeout_seconds": 33, + "request_max_retries": 1, + "request_retry_backoff_seconds": 0, + "serialize_groq_requests": False, + } + + with patch("groq.Groq", return_value=client): + with patch.object(provider, "_initial_segment_duration", return_value=75): + with patch.object(provider, "_extract_audio_segments", side_effect=fake_extract_audio_segments): + artifact = provider.transcribe(task, source_video, settings) + + srt = Path(artifact.path).read_text(encoding="utf-8") + self.assertIn("00:00:00,000 --> 00:00:01,000\nfirst", srt) + self.assertIn("00:01:15,000 --> 00:01:16,500\nsecond", srt) + self.assertEqual(len(client.audio.transcriptions.calls), 1) + self.assertEqual(client.audio.transcriptions.calls[0]["file"][0], "part_001.mp3") + self.assertTrue((checkpoint_dir / "part_001.json").exists()) + + def test_transcribe_switches_to_next_api_key_on_rate_limit(self) -> None: + provider = GroqTranscribeProvider() + task = Task("task-1", "local_file", "/tmp/input.mp4", "demo", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + source_path = work_dir / "input.mp4" + source_path.write_bytes(b"video") + source_video = Artifact(None, task.id, "source_video", str(source_path), "{}", "2026-01-01T00:00:00+00:00") + segment = work_dir / "temp_audio" / "part_000.mp3" + + def fake_extract_audio_segments(**kwargs): # noqa: ANN003 + segment.parent.mkdir(parents=True, exist_ok=True) + segment.write_bytes(b"audio") + + limited_client = _FakeGroqClient([RuntimeError("Error code: 429 rate_limit")]) + fallback_client = _FakeGroqClient([_FakeResponse([{"start": 0, "end": 1.2, "text": "fallback"}])]) + settings = { + "groq_api_key": "", + "groq_api_keys": ["gsk_first", "gsk_second"], + "ffmpeg_bin": "ffmpeg", + "max_file_size_mb": 23, + "request_timeout_seconds": 20, + "request_max_retries": 0, + "request_retry_backoff_seconds": 0, + "serialize_groq_requests": False, + } + + with patch("groq.Groq", side_effect=[limited_client, fallback_client]) as groq_ctor: + with patch.object(provider, "_extract_audio_segments", side_effect=fake_extract_audio_segments): + artifact = provider.transcribe(task, source_video, settings) + + self.assertIn("fallback", Path(artifact.path).read_text(encoding="utf-8")) + self.assertEqual(len(limited_client.audio.transcriptions.calls), 1) + self.assertEqual(len(fallback_client.audio.transcriptions.calls), 1) + self.assertEqual([call.kwargs["api_key"] for call in groq_ctor.call_args_list], ["gsk_first", "gsk_second"]) + + def test_transcribe_waits_after_all_api_keys_are_rate_limited(self) -> None: + provider = GroqTranscribeProvider() + task = Task("task-1", "local_file", "/tmp/input.mp4", "demo", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + source_path = work_dir / "input.mp4" + source_path.write_bytes(b"video") + source_video = Artifact(None, task.id, "source_video", str(source_path), "{}", "2026-01-01T00:00:00+00:00") + segment = work_dir / "temp_audio" / "part_000.mp3" + + def fake_extract_audio_segments(**kwargs): # noqa: ANN003 + segment.parent.mkdir(parents=True, exist_ok=True) + segment.write_bytes(b"audio") + + first_client = _FakeGroqClient([RuntimeError("429 rate_limit"), _FakeResponse([{"start": 0, "end": 1, "text": "retry ok"}])]) + second_client = _FakeGroqClient([RuntimeError("429 rate_limit")]) + settings = { + "groq_api_key": "", + "groq_api_keys": ["gsk_first", "gsk_second"], + "ffmpeg_bin": "ffmpeg", + "max_file_size_mb": 23, + "request_timeout_seconds": 20, + "request_max_retries": 1, + "request_retry_backoff_seconds": 7, + "serialize_groq_requests": False, + } + + with patch("groq.Groq", side_effect=[first_client, second_client]): + with patch("time.sleep") as sleep_mock: + with patch.object(provider, "_extract_audio_segments", side_effect=fake_extract_audio_segments): + artifact = provider.transcribe(task, source_video, settings) + + self.assertIn("retry ok", Path(artifact.path).read_text(encoding="utf-8")) + sleep_mock.assert_called_once_with(7) + self.assertEqual(len(first_client.audio.transcriptions.calls), 2) + self.assertEqual(len(second_client.audio.transcriptions.calls), 1) + + def test_transcribe_raises_after_retry_budget_is_exhausted(self) -> None: + provider = GroqTranscribeProvider() + task = Task("task-1", "local_file", "/tmp/input.mp4", "demo", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + source_path = work_dir / "input.mp4" + source_path.write_bytes(b"video") + source_video = Artifact(None, task.id, "source_video", str(source_path), "{}", "2026-01-01T00:00:00+00:00") + segment = work_dir / "temp_audio" / "part_000.mp3" + + def fake_extract_audio_segments(**kwargs): # noqa: ANN003 + segment.parent.mkdir(parents=True, exist_ok=True) + segment.write_bytes(b"audio") + + client = _FakeGroqClient([RuntimeError("Connection error."), RuntimeError("Connection error.")]) + settings = { + "groq_api_key": "gsk_test", + "ffmpeg_bin": "ffmpeg", + "max_file_size_mb": 23, + "request_timeout_seconds": 20, + "request_max_retries": 1, + "request_retry_backoff_seconds": 0, + "serialize_groq_requests": False, + } + + with patch("groq.Groq", return_value=client): + with patch.object(provider, "_extract_audio_segments", side_effect=fake_extract_audio_segments): + with self.assertRaises(ModuleError) as exc_info: + provider.transcribe(task, source_video, settings) + + self.assertEqual(exc_info.exception.message, "Groq 转录失败: part_000.mp3") + + def test_initial_segment_duration_keeps_safety_margin(self) -> None: + self.assertLess(GroqTranscribeProvider._initial_segment_duration(12), 1536) + + def test_extract_audio_segments_retries_when_segment_exceeds_size_limit(self) -> None: + provider = GroqTranscribeProvider() + + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + temp_audio_dir = work_dir / "temp_audio" + temp_audio_dir.mkdir() + output_pattern = temp_audio_dir / "part_%03d.mp3" + durations: list[int] = [] + + def fake_extract_audio_segments(**kwargs): # noqa: ANN003 + durations.append(int(kwargs["segment_duration"])) + size = 20 if len(durations) == 1 else 5 + (temp_audio_dir / "part_000.mp3").write_bytes(b"x" * size) + + with patch.object(provider, "_extract_audio_segments", side_effect=fake_extract_audio_segments): + result = provider._extract_audio_segments_with_size_guard( + ffmpeg_bin="ffmpeg", + source_path=work_dir / "input.mp4", + output_pattern=output_pattern, + temp_audio_dir=temp_audio_dir, + initial_segment_duration=100, + max_segment_bytes=10, + ) + + self.assertEqual(durations, [100, 75]) + self.assertEqual(result, 75) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ingest_bilibili_url.py b/tests/test_ingest_bilibili_url.py index 11cb184..634e8a5 100644 --- a/tests/test_ingest_bilibili_url.py +++ b/tests/test_ingest_bilibili_url.py @@ -1,49 +1,49 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from biliup_next.modules.ingest.providers.bilibili_url import BilibiliUrlIngestProvider - - -class FakeYtDlpAdapter: - def probe(self, *, yt_dlp_cmd: str, source_url: str): # noqa: ANN001 - return { - "id": "BV1TEST1234", - "title": "测试视频标题", - "uploader": "测试主播", - "duration": 321.0, - } - - def download(self, *, yt_dlp_cmd: str, source_url: str, output_template: str, format_selector=None): # noqa: ANN001 - output_path = Path(output_template.replace("%(ext)s", "mp4")) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_bytes(b"fake-video") - return type("Result", (), {"returncode": 0, "stdout": "ok", "stderr": ""})() - - -class BilibiliUrlIngestProviderTests(unittest.TestCase): - def test_resolve_and_download_source(self) -> None: - provider = BilibiliUrlIngestProvider(yt_dlp=FakeYtDlpAdapter()) - settings = {"yt_dlp_cmd": "yt-dlp"} - - resolved = provider.resolve_source("https://www.bilibili.com/video/BV1TEST1234", settings) - - self.assertEqual(resolved["video_id"], "BV1TEST1234") - self.assertEqual(resolved["title"], "测试视频标题") - self.assertEqual(resolved["streamer"], "测试主播") - - with tempfile.TemporaryDirectory() as tmpdir: - downloaded = provider.download_source( - "https://www.bilibili.com/video/BV1TEST1234", - Path(tmpdir), - settings, - task_id=str(resolved["task_id"]), - ) - self.assertTrue(downloaded.exists()) - self.assertEqual(downloaded.suffix, ".mp4") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from biliup_next.modules.ingest.providers.bilibili_url import BilibiliUrlIngestProvider + + +class FakeYtDlpAdapter: + def probe(self, *, yt_dlp_cmd: str, source_url: str): # noqa: ANN001 + return { + "id": "BV1TEST1234", + "title": "测试视频标题", + "uploader": "测试主播", + "duration": 321.0, + } + + def download(self, *, yt_dlp_cmd: str, source_url: str, output_template: str, format_selector=None): # noqa: ANN001 + output_path = Path(output_template.replace("%(ext)s", "mp4")) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(b"fake-video") + return type("Result", (), {"returncode": 0, "stdout": "ok", "stderr": ""})() + + +class BilibiliUrlIngestProviderTests(unittest.TestCase): + def test_resolve_and_download_source(self) -> None: + provider = BilibiliUrlIngestProvider(yt_dlp=FakeYtDlpAdapter()) + settings = {"yt_dlp_cmd": "yt-dlp"} + + resolved = provider.resolve_source("https://www.bilibili.com/video/BV1TEST1234", settings) + + self.assertEqual(resolved["video_id"], "BV1TEST1234") + self.assertEqual(resolved["title"], "测试视频标题") + self.assertEqual(resolved["streamer"], "测试主播") + + with tempfile.TemporaryDirectory() as tmpdir: + downloaded = provider.download_source( + "https://www.bilibili.com/video/BV1TEST1234", + Path(tmpdir), + settings, + task_id=str(resolved["task_id"]), + ) + self.assertTrue(downloaded.exists()) + self.assertEqual(downloaded.suffix, ".mp4") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ingest_scan_stage.py b/tests/test_ingest_scan_stage.py new file mode 100644 index 0000000..473d361 --- /dev/null +++ b/tests/test_ingest_scan_stage.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from biliup_next.core.providers import ProviderManifest +from biliup_next.core.registry import Registry +from biliup_next.infra.db import Database +from biliup_next.infra.task_repository import TaskRepository +from biliup_next.modules.ingest.service import IngestService + + +class _FakeLocalFileProvider: + manifest = ProviderManifest( + id="local_file", + name="Fake Local File Ingest", + version="0.1.0", + provider_type="ingest_provider", + entrypoint="tests.test_ingest_scan_stage:_FakeLocalFileProvider", + capabilities=["ingest"], + enabled_by_default=True, + ) + + def validate_source(self, source_path: Path, settings: dict[str, object]) -> None: + if not source_path.exists() or not source_path.is_file(): + raise AssertionError(f"unexpected source path: {source_path}") + + +class IngestScanStageTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + root = Path(self.tempdir.name) + self.stage_dir = root / "stage" + self.backup_dir = root / "backup" + self.session_dir = root / "session" + self.stage_dir.mkdir() + self.backup_dir.mkdir() + self.session_dir.mkdir() + + db = Database(root / "test.db") + db.initialize() + repo = TaskRepository(db) + registry = Registry() + provider = _FakeLocalFileProvider() + registry.register("ingest_provider", "local_file", provider, provider.manifest) + self.service = IngestService(registry=registry, repo=repo) + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def test_scan_stage_uses_moved_file_for_reference_timestamp(self) -> None: + source_path = self.stage_dir / "王海颖唱歌录播 04月14日 17时49分.mp4" + source_path.write_bytes(b"fake-video") + + settings = { + "provider": "local_file", + "stage_dir": str(self.stage_dir), + "backup_dir": str(self.backup_dir), + "session_dir": str(self.session_dir), + "allowed_extensions": [".mp4"], + "ffprobe_bin": "ffprobe", + "min_duration_seconds": 0, + "stability_wait_seconds": 0, + "meta_sidecar_enabled": True, + } + + self.service._probe_duration_seconds = lambda *_args, **_kwargs: 120.0 # type: ignore[method-assign] + + result = self.service.scan_stage(settings) + + self.assertEqual(len(result["accepted"]), 1) + accepted = result["accepted"][0] + moved_path = Path(str(accepted["source_path"])) + self.assertTrue(moved_path.exists()) + self.assertFalse(source_path.exists()) + task = self.service.repo.get_task(moved_path.stem) + self.assertIsNotNone(task) + context = self.service.repo.get_task_context(moved_path.stem) + self.assertIsNotNone(context) + self.assertIsNotNone(context.segment_started_at) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ingest_session_grouping.py b/tests/test_ingest_session_grouping.py index e37509d..80c3f26 100644 --- a/tests/test_ingest_session_grouping.py +++ b/tests/test_ingest_session_grouping.py @@ -1,61 +1,61 @@ -from __future__ import annotations - -import unittest - -from biliup_next.core.models import Task, TaskContext -from biliup_next.modules.ingest.service import IngestService - - -class _FakeRepo: - def __init__(self, contexts: list[TaskContext]) -> None: - self.contexts = contexts - - def find_recent_task_contexts(self, streamer: str) -> list[TaskContext]: - return [context for context in self.contexts if context.streamer == streamer] - - -class IngestSessionGroupingTests(unittest.TestCase): - def test_infer_session_key_groups_same_streamer_within_three_hours_to_earliest_title(self) -> None: - existing_context = TaskContext( - id=None, - task_id="task-1", - session_key="王海颖唱歌录播 04月04日 21时59分 p01 王海颖唱歌录播 04月04日 21时59分", - streamer="王海颖唱歌录播", - room_id=None, - source_title="王海颖唱歌录播 04月04日 21时59分 p01 王海颖唱歌录播 04月04日 21时59分", - segment_started_at="2026-04-04T21:59:00+08:00", - segment_duration_seconds=None, - full_video_bvid="BVFULL123", - created_at="2026-04-04T14:00:00+00:00", - updated_at="2026-04-04T14:00:00+00:00", - ) - service = IngestService(registry=None, repo=_FakeRepo([existing_context])) # type: ignore[arg-type] - - session_key, inherited_bvid = service._infer_session_key( - streamer="王海颖唱歌录播", - room_id=None, - segment_started_at="2026-04-05T00:30:00+08:00", - source_title="王海颖唱歌录播 04月05日 00时30分 p02 王海颖唱歌录播 04月05日 00时30分", - gap_minutes=60, - ) - - self.assertEqual(session_key, existing_context.session_key) - self.assertEqual(inherited_bvid, "BVFULL123") - - def test_infer_session_key_uses_current_title_when_no_recent_context_matches(self) -> None: - service = IngestService(registry=None, repo=_FakeRepo([])) # type: ignore[arg-type] - - session_key, inherited_bvid = service._infer_session_key( - streamer="王海颖唱歌录播", - room_id=None, - segment_started_at="2026-04-05T00:30:00+08:00", - source_title="王海颖唱歌录播 04月05日 00时30分 p02 王海颖唱歌录播 04月05日 00时30分", - gap_minutes=60, - ) - - self.assertEqual(session_key, "王海颖唱歌录播 04月05日 00时30分 p02 王海颖唱歌录播 04月05日 00时30分") - self.assertIsNone(inherited_bvid) - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import unittest + +from biliup_next.core.models import Task, TaskContext +from biliup_next.modules.ingest.service import IngestService + + +class _FakeRepo: + def __init__(self, contexts: list[TaskContext]) -> None: + self.contexts = contexts + + def find_recent_task_contexts(self, streamer: str) -> list[TaskContext]: + return [context for context in self.contexts if context.streamer == streamer] + + +class IngestSessionGroupingTests(unittest.TestCase): + def test_infer_session_key_groups_same_streamer_within_three_hours_to_earliest_title(self) -> None: + existing_context = TaskContext( + id=None, + task_id="task-1", + session_key="王海颖唱歌录播 04月04日 21时59分 p01 王海颖唱歌录播 04月04日 21时59分", + streamer="王海颖唱歌录播", + room_id=None, + source_title="王海颖唱歌录播 04月04日 21时59分 p01 王海颖唱歌录播 04月04日 21时59分", + segment_started_at="2026-04-04T21:59:00+08:00", + segment_duration_seconds=None, + full_video_bvid="BVFULL123", + created_at="2026-04-04T14:00:00+00:00", + updated_at="2026-04-04T14:00:00+00:00", + ) + service = IngestService(registry=None, repo=_FakeRepo([existing_context])) # type: ignore[arg-type] + + session_key, inherited_bvid = service._infer_session_key( + streamer="王海颖唱歌录播", + room_id=None, + segment_started_at="2026-04-05T00:30:00+08:00", + source_title="王海颖唱歌录播 04月05日 00时30分 p02 王海颖唱歌录播 04月05日 00时30分", + gap_minutes=60, + ) + + self.assertEqual(session_key, existing_context.session_key) + self.assertEqual(inherited_bvid, "BVFULL123") + + def test_infer_session_key_uses_current_title_when_no_recent_context_matches(self) -> None: + service = IngestService(registry=None, repo=_FakeRepo([])) # type: ignore[arg-type] + + session_key, inherited_bvid = service._infer_session_key( + streamer="王海颖唱歌录播", + room_id=None, + segment_started_at="2026-04-05T00:30:00+08:00", + source_title="王海颖唱歌录播 04月05日 00时30分 p02 王海颖唱歌录播 04月05日 00时30分", + gap_minutes=60, + ) + + self.assertEqual(session_key, "王海颖唱歌录播 04月05日 00时30分 p02 王海颖唱歌录播 04月05日 00时30分") + self.assertIsNone(inherited_bvid) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_publish_service.py b/tests/test_publish_service.py index 840ccac..8425091 100644 --- a/tests/test_publish_service.py +++ b/tests/test_publish_service.py @@ -1,162 +1,162 @@ -from __future__ import annotations - -import json -import tempfile -import unittest -from pathlib import Path - -from biliup_next.core.models import Artifact, PublishRecord, Task, TaskContext, TaskStep -from biliup_next.modules.publish.service import PublishService - - -class _FakePublishProvider: - def __init__(self) -> None: - self.calls: list[tuple[str, list[str], dict[str, object]]] = [] - - def publish(self, task: Task, clip_videos: list[Artifact], settings: dict[str, object]) -> PublishRecord: - self.calls.append((task.id, [artifact.path for artifact in clip_videos], dict(settings))) - return PublishRecord( - id=None, - task_id=task.id, - platform="bilibili", - aid=None, - bvid="BV1SESSION123", - title=task.title, - published_at="2026-01-01T00:00:00+00:00", - ) - - -class _FakeRegistry: - def __init__(self, provider) -> None: # noqa: ANN001 - self.provider = provider - - def get(self, provider_type: str, provider_id: str): # noqa: ANN001 - return self.provider - - -class _FakeRepo: - def __init__(self, tasks: list[Task], contexts: list[TaskContext], artifacts: dict[str, list[Artifact]]) -> None: - self.tasks = {task.id: task for task in tasks} - self.contexts = {context.task_id: context for context in contexts} - self.artifacts = artifacts - self.publish_records: list[PublishRecord] = [] - self.step_updates: list[tuple[str, str, str]] = [] - self.task_updates: list[tuple[str, str]] = [] - - def get_task(self, task_id: str) -> Task | None: - return self.tasks.get(task_id) - - def list_artifacts(self, task_id: str) -> list[Artifact]: - return list(self.artifacts.get(task_id, [])) - - def get_task_context(self, task_id: str) -> TaskContext | None: - return self.contexts.get(task_id) - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - return [context for context in self.contexts.values() if context.session_key == session_key] - - def add_publish_record(self, record: PublishRecord) -> None: - self.publish_records.append(record) - - def add_artifact(self, artifact: Artifact) -> None: - self.artifacts.setdefault(artifact.task_id, []).append(artifact) - - def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # noqa: ANN001 - self.step_updates.append((task_id, step_name, status)) - - def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: - self.task_updates.append((task_id, status)) - task = self.tasks[task_id] - self.tasks[task_id] = Task(task.id, task.source_type, task.source_path, task.title, status, task.created_at, updated_at) - - -class PublishServiceTests(unittest.TestCase): - def test_anchor_task_publishes_aggregated_session_clips_and_marks_all_tasks_published(self) -> None: - provider = _FakePublishProvider() - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "task-1").mkdir(parents=True, exist_ok=True) - (root / "task-2").mkdir(parents=True, exist_ok=True) - task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) - ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) - artifacts = { - "task-1": [Artifact(None, "task-1", "clip_video", str(root / "a1.mp4"), "{}", task1.created_at)], - "task-2": [Artifact(None, "task-2", "clip_video", str(root / "b1.mp4"), "{}", task2.created_at)], - } - repo = _FakeRepo([task1, task2], [ctx1, ctx2], artifacts) - service = PublishService(_FakeRegistry(provider), repo) - - record = service.run("task-1", {"provider": "biliup_cli", "session_dir": str(root)}) - - self.assertEqual(record.bvid, "BV1SESSION123") - self.assertEqual(provider.calls[0][0], "task-1") - self.assertEqual(provider.calls[0][1], [str(root / "a1.mp4"), str(root / "b1.mp4")]) - aggregate_settings = provider.calls[0][2] - aggregate_txt = Path(str(aggregate_settings["publish_songs_txt_path"])) - aggregate_json = Path(str(aggregate_settings["publish_songs_json_path"])) - self.assertTrue(aggregate_txt.exists()) - self.assertTrue(aggregate_json.exists()) - self.assertIn(("task-1", "published"), repo.task_updates) - self.assertIn(("task-2", "published"), repo.task_updates) - self.assertEqual(len(repo.publish_records), 2) - self.assertTrue((root / "task-1" / "bvid.txt").exists()) - self.assertTrue((root / "task-2" / "bvid.txt").exists()) - - def test_non_anchor_task_reuses_existing_session_bvid_without_republishing(self) -> None: - provider = _FakePublishProvider() - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "task-1").mkdir(parents=True, exist_ok=True) - (root / "task-1" / "bvid.txt").write_text("BV1SESSION123", encoding="utf-8") - (root / "task-2").mkdir(parents=True, exist_ok=True) - task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) - ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) - repo = _FakeRepo([task1, task2], [ctx1, ctx2], {"task-2": []}) - service = PublishService(_FakeRegistry(provider), repo) - - record = service.run("task-2", {"provider": "biliup_cli", "session_dir": str(root)}) - - self.assertEqual(record.bvid, "BV1SESSION123") - self.assertEqual(provider.calls, []) - self.assertIn(("task-2", "published"), repo.task_updates) - self.assertTrue((root / "task-2" / "bvid.txt").exists()) - - def test_session_publish_aggregates_song_lists_for_provider_metadata(self) -> None: - provider = _FakePublishProvider() - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "task-1").mkdir(parents=True, exist_ok=True) - (root / "task-2").mkdir(parents=True, exist_ok=True) - (root / "task-1" / "songs.txt").write_text("00:00:00 Song A — Artist A\n", encoding="utf-8") - (root / "task-2" / "songs.txt").write_text("00:00:00 Song B — Artist B\n", encoding="utf-8") - (root / "task-1" / "songs.json").write_text('{"songs":[{"title":"Song A"},{"title":"Song A2"}]}\n', encoding="utf-8") - (root / "task-2" / "songs.json").write_text('{"songs":[{"title":"Song B"}]}\n', encoding="utf-8") - task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) - ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) - artifacts = { - "task-1": [Artifact(None, "task-1", "clip_video", str(root / "a1.mp4"), "{}", task1.created_at)], - "task-2": [Artifact(None, "task-2", "clip_video", str(root / "b1.mp4"), "{}", task2.created_at)], - } - repo = _FakeRepo([task1, task2], [ctx1, ctx2], artifacts) - service = PublishService(_FakeRegistry(provider), repo) - - service.run("task-1", {"provider": "biliup_cli", "session_dir": str(root)}) - - settings = provider.calls[0][2] - aggregate_txt = Path(str(settings["publish_songs_txt_path"])).read_text(encoding="utf-8") - aggregate_json = Path(str(settings["publish_songs_json_path"])).read_text(encoding="utf-8") - self.assertIn("P1:", aggregate_txt) - self.assertIn("Song A — Artist A", aggregate_txt) - self.assertIn("P2:", aggregate_txt) - self.assertIn("Song B — Artist B", aggregate_txt) - self.assertEqual(len(json.loads(aggregate_json)["songs"]), 3) - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from biliup_next.core.models import Artifact, PublishRecord, Task, TaskContext, TaskStep +from biliup_next.modules.publish.service import PublishService + + +class _FakePublishProvider: + def __init__(self) -> None: + self.calls: list[tuple[str, list[str], dict[str, object]]] = [] + + def publish(self, task: Task, clip_videos: list[Artifact], settings: dict[str, object]) -> PublishRecord: + self.calls.append((task.id, [artifact.path for artifact in clip_videos], dict(settings))) + return PublishRecord( + id=None, + task_id=task.id, + platform="bilibili", + aid=None, + bvid="BV1SESSION123", + title=task.title, + published_at="2026-01-01T00:00:00+00:00", + ) + + +class _FakeRegistry: + def __init__(self, provider) -> None: # noqa: ANN001 + self.provider = provider + + def get(self, provider_type: str, provider_id: str): # noqa: ANN001 + return self.provider + + +class _FakeRepo: + def __init__(self, tasks: list[Task], contexts: list[TaskContext], artifacts: dict[str, list[Artifact]]) -> None: + self.tasks = {task.id: task for task in tasks} + self.contexts = {context.task_id: context for context in contexts} + self.artifacts = artifacts + self.publish_records: list[PublishRecord] = [] + self.step_updates: list[tuple[str, str, str]] = [] + self.task_updates: list[tuple[str, str]] = [] + + def get_task(self, task_id: str) -> Task | None: + return self.tasks.get(task_id) + + def list_artifacts(self, task_id: str) -> list[Artifact]: + return list(self.artifacts.get(task_id, [])) + + def get_task_context(self, task_id: str) -> TaskContext | None: + return self.contexts.get(task_id) + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + return [context for context in self.contexts.values() if context.session_key == session_key] + + def add_publish_record(self, record: PublishRecord) -> None: + self.publish_records.append(record) + + def add_artifact(self, artifact: Artifact) -> None: + self.artifacts.setdefault(artifact.task_id, []).append(artifact) + + def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # noqa: ANN001 + self.step_updates.append((task_id, step_name, status)) + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + self.task_updates.append((task_id, status)) + task = self.tasks[task_id] + self.tasks[task_id] = Task(task.id, task.source_type, task.source_path, task.title, status, task.created_at, updated_at) + + +class PublishServiceTests(unittest.TestCase): + def test_anchor_task_publishes_aggregated_session_clips_and_marks_all_tasks_published(self) -> None: + provider = _FakePublishProvider() + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "task-1").mkdir(parents=True, exist_ok=True) + (root / "task-2").mkdir(parents=True, exist_ok=True) + task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) + ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) + artifacts = { + "task-1": [Artifact(None, "task-1", "clip_video", str(root / "a1.mp4"), "{}", task1.created_at)], + "task-2": [Artifact(None, "task-2", "clip_video", str(root / "b1.mp4"), "{}", task2.created_at)], + } + repo = _FakeRepo([task1, task2], [ctx1, ctx2], artifacts) + service = PublishService(_FakeRegistry(provider), repo) + + record = service.run("task-1", {"provider": "biliup_cli", "session_dir": str(root)}) + + self.assertEqual(record.bvid, "BV1SESSION123") + self.assertEqual(provider.calls[0][0], "task-1") + self.assertEqual(provider.calls[0][1], [str(root / "a1.mp4"), str(root / "b1.mp4")]) + aggregate_settings = provider.calls[0][2] + aggregate_txt = Path(str(aggregate_settings["publish_songs_txt_path"])) + aggregate_json = Path(str(aggregate_settings["publish_songs_json_path"])) + self.assertTrue(aggregate_txt.exists()) + self.assertTrue(aggregate_json.exists()) + self.assertIn(("task-1", "published"), repo.task_updates) + self.assertIn(("task-2", "published"), repo.task_updates) + self.assertEqual(len(repo.publish_records), 2) + self.assertTrue((root / "task-1" / "bvid.txt").exists()) + self.assertTrue((root / "task-2" / "bvid.txt").exists()) + + def test_non_anchor_task_reuses_existing_session_bvid_without_republishing(self) -> None: + provider = _FakePublishProvider() + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "task-1").mkdir(parents=True, exist_ok=True) + (root / "task-1" / "bvid.txt").write_text("BV1SESSION123", encoding="utf-8") + (root / "task-2").mkdir(parents=True, exist_ok=True) + task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) + ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) + repo = _FakeRepo([task1, task2], [ctx1, ctx2], {"task-2": []}) + service = PublishService(_FakeRegistry(provider), repo) + + record = service.run("task-2", {"provider": "biliup_cli", "session_dir": str(root)}) + + self.assertEqual(record.bvid, "BV1SESSION123") + self.assertEqual(provider.calls, []) + self.assertIn(("task-2", "published"), repo.task_updates) + self.assertTrue((root / "task-2" / "bvid.txt").exists()) + + def test_session_publish_aggregates_song_lists_for_provider_metadata(self) -> None: + provider = _FakePublishProvider() + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "task-1").mkdir(parents=True, exist_ok=True) + (root / "task-2").mkdir(parents=True, exist_ok=True) + (root / "task-1" / "songs.txt").write_text("00:00:00 Song A — Artist A\n", encoding="utf-8") + (root / "task-2" / "songs.txt").write_text("00:00:00 Song B — Artist B\n", encoding="utf-8") + (root / "task-1" / "songs.json").write_text('{"songs":[{"title":"Song A"},{"title":"Song A2"}]}\n', encoding="utf-8") + (root / "task-2" / "songs.json").write_text('{"songs":[{"title":"Song B"}]}\n', encoding="utf-8") + task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) + ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) + artifacts = { + "task-1": [Artifact(None, "task-1", "clip_video", str(root / "a1.mp4"), "{}", task1.created_at)], + "task-2": [Artifact(None, "task-2", "clip_video", str(root / "b1.mp4"), "{}", task2.created_at)], + } + repo = _FakeRepo([task1, task2], [ctx1, ctx2], artifacts) + service = PublishService(_FakeRegistry(provider), repo) + + service.run("task-1", {"provider": "biliup_cli", "session_dir": str(root)}) + + settings = provider.calls[0][2] + aggregate_txt = Path(str(settings["publish_songs_txt_path"])).read_text(encoding="utf-8") + aggregate_json = Path(str(settings["publish_songs_json_path"])).read_text(encoding="utf-8") + self.assertIn("P1:", aggregate_txt) + self.assertIn("Song A — Artist A", aggregate_txt) + self.assertIn("P2:", aggregate_txt) + self.assertIn("Song B — Artist B", aggregate_txt) + self.assertEqual(len(json.loads(aggregate_json)["songs"]), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_retry_meta.py b/tests/test_retry_meta.py index 1ce0532..c09c7aa 100644 --- a/tests/test_retry_meta.py +++ b/tests/test_retry_meta.py @@ -1,42 +1,42 @@ -from __future__ import annotations - -import unittest -from types import SimpleNamespace - -from biliup_next.app.retry_meta import retry_meta_for_step - - -class RetryMetaTests(unittest.TestCase): - def test_retry_meta_uses_schedule_minutes(self) -> None: - step = SimpleNamespace( - step_name="publish", - status="failed_retryable", - retry_count=1, - started_at=None, - finished_at="2099-01-01T00:00:00+00:00", - ) - - payload = retry_meta_for_step(step, {"publish": {"retry_schedule_minutes": [15, 5]}}) - - self.assertIsNotNone(payload) - self.assertEqual(payload["retry_wait_seconds"], 900) - self.assertFalse(payload["retry_due"]) - - def test_retry_meta_marks_exhausted_after_schedule_is_consumed(self) -> None: - step = SimpleNamespace( - step_name="comment", - status="failed_retryable", - retry_count=3, - started_at=None, - finished_at="2026-01-01T00:00:00+00:00", - ) - - payload = retry_meta_for_step(step, {"comment": {"retry_schedule_minutes": [1, 2]}}) - - self.assertIsNotNone(payload) - self.assertTrue(payload["retry_exhausted"]) - self.assertIsNone(payload["next_retry_at"]) - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from biliup_next.app.retry_meta import retry_meta_for_step + + +class RetryMetaTests(unittest.TestCase): + def test_retry_meta_uses_schedule_minutes(self) -> None: + step = SimpleNamespace( + step_name="publish", + status="failed_retryable", + retry_count=1, + started_at=None, + finished_at="2099-01-01T00:00:00+00:00", + ) + + payload = retry_meta_for_step(step, {"publish": {"retry_schedule_minutes": [15, 5]}}) + + self.assertIsNotNone(payload) + self.assertEqual(payload["retry_wait_seconds"], 900) + self.assertFalse(payload["retry_due"]) + + def test_retry_meta_marks_exhausted_after_schedule_is_consumed(self) -> None: + step = SimpleNamespace( + step_name="comment", + status="failed_retryable", + retry_count=3, + started_at=None, + finished_at="2026-01-01T00:00:00+00:00", + ) + + payload = retry_meta_for_step(step, {"comment": {"retry_schedule_minutes": [1, 2]}}) + + self.assertIsNotNone(payload) + self.assertTrue(payload["retry_exhausted"]) + self.assertIsNone(payload["next_retry_at"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 71b064c..b5b1b60 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,177 +1,177 @@ -from __future__ import annotations - -import json -import tempfile -import unittest -from pathlib import Path - -from biliup_next.app.serializers import ControlPlaneSerializer -from biliup_next.core.models import ActionRecord, Artifact, Task, TaskContext, TaskStep - - -class FakeSerializerRepo: - def __init__( - self, - *, - task: Task, - context: TaskContext | None = None, - steps: list[TaskStep] | None = None, - artifacts: list[Artifact] | None = None, - actions: list[ActionRecord] | None = None, - ) -> None: - self.task = task - self.context = context - self.steps = steps or [] - self.artifacts = artifacts or [] - self.actions = actions or [] - - def get_task(self, task_id: str) -> Task | None: - return self.task if task_id == self.task.id else None - - def get_task_context(self, task_id: str) -> TaskContext | None: - return self.context if task_id == self.task.id else None - - def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: - if self.context and self.context.task_id in task_ids: - return {self.context.task_id: self.context} - return {} - - def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: - if self.task.id in task_ids: - return {self.task.id: list(self.steps)} - return {} - - def list_steps(self, task_id: str) -> list[TaskStep]: - return list(self.steps) if task_id == self.task.id else [] - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - if self.context and self.context.session_key == session_key: - return [self.context] - return [] - - def list_artifacts(self, task_id: str) -> list[Artifact]: - return list(self.artifacts) if task_id == self.task.id else [] - - def list_action_records(self, task_id: str, limit: int = 200) -> list[ActionRecord]: - return list(self.actions)[:limit] if task_id == self.task.id else [] - - -class SerializerTests(unittest.TestCase): - def test_task_payload_includes_context_retry_and_delivery_state(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00") - session_dir = Path(tmpdir) / "session" / "task-title" - session_dir.mkdir(parents=True, exist_ok=True) - (session_dir / "full_video_bvid.txt").write_text("BVFULL123", encoding="utf-8") - (session_dir / "bvid.txt").write_text("BVSPLIT123", encoding="utf-8") - steps = [ - TaskStep(None, "task-1", "publish", "failed_retryable", "ERR", "upload failed", 1, None, "2099-01-01T00:00:00+00:00"), - ] - context = TaskContext( - id=None, - task_id="task-1", - session_key="session-1", - streamer="streamer", - room_id="room", - source_title="task-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid=None, - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeSerializerRepo(task=task, context=context, steps=steps) - state = { - "repo": repo, - "settings": { - "paths": {"session_dir": str(Path(tmpdir) / "session")}, - "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, - "cleanup": {}, - "publish": {"retry_schedule_minutes": [10]}, - }, - } - - payload = ControlPlaneSerializer(state).task_payload("task-1") - - self.assertIsNotNone(payload) - self.assertEqual(payload["session_context"]["session_key"], "session-1") - self.assertEqual(payload["session_context"]["full_video_bvid"], "BVFULL123") - self.assertEqual(payload["retry_state"]["step_name"], "publish") - self.assertEqual(payload["delivery_state"]["split_comment"], "pending") - - def test_session_payload_reuses_task_payload_serialization(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00") - context = TaskContext( - id=None, - task_id="task-1", - session_key="session-1", - streamer="streamer", - room_id="room", - source_title="task-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid="BVFULL123", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeSerializerRepo(task=task, context=context) - state = { - "repo": repo, - "settings": { - "paths": {"session_dir": str(Path(tmpdir) / "session")}, - "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, - "cleanup": {}, - "publish": {}, - }, - } - - payload = ControlPlaneSerializer(state).session_payload("session-1") - - self.assertIsNotNone(payload) - self.assertEqual(payload["session_key"], "session-1") - self.assertEqual(payload["task_count"], 1) - self.assertEqual(payload["full_video_url"], "https://www.bilibili.com/video/BVFULL123") - self.assertEqual(payload["tasks"][0]["id"], "task-1") - - def test_timeline_payload_includes_task_step_artifact_and_action_entries(self) -> None: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00") - steps = [ - TaskStep(None, "task-1", "comment", "succeeded", None, None, 0, "2026-01-01T00:01:00+00:00", "2026-01-01T00:01:30+00:00"), - ] - artifacts = [ - Artifact(None, "task-1", "publish_bvid", "/tmp/bvid.txt", "{}", "2026-01-01T00:01:40+00:00"), - ] - actions = [ - ActionRecord( - id=None, - task_id="task-1", - action_name="comment", - status="ok", - summary="comment succeeded", - details_json=json.dumps({"split": {"status": "ok"}, "full": {"status": "skipped"}}), - created_at="2026-01-01T00:01:50+00:00", - ) - ] - repo = FakeSerializerRepo(task=task, steps=steps, artifacts=artifacts, actions=actions) - state = { - "repo": repo, - "settings": { - "paths": {"session_dir": "/tmp/session"}, - "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, - "cleanup": {}, - "publish": {}, - }, - } - - payload = ControlPlaneSerializer(state).timeline_payload("task-1") - - self.assertIsNotNone(payload) - action_item = next(item for item in payload["items"] if item["kind"] == "action") - self.assertIn("split=ok", action_item["summary"]) - kinds = {item["kind"] for item in payload["items"]} - self.assertTrue({"task", "step", "artifact", "action"}.issubset(kinds)) - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from biliup_next.app.serializers import ControlPlaneSerializer +from biliup_next.core.models import ActionRecord, Artifact, Task, TaskContext, TaskStep + + +class FakeSerializerRepo: + def __init__( + self, + *, + task: Task, + context: TaskContext | None = None, + steps: list[TaskStep] | None = None, + artifacts: list[Artifact] | None = None, + actions: list[ActionRecord] | None = None, + ) -> None: + self.task = task + self.context = context + self.steps = steps or [] + self.artifacts = artifacts or [] + self.actions = actions or [] + + def get_task(self, task_id: str) -> Task | None: + return self.task if task_id == self.task.id else None + + def get_task_context(self, task_id: str) -> TaskContext | None: + return self.context if task_id == self.task.id else None + + def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]: + if self.context and self.context.task_id in task_ids: + return {self.context.task_id: self.context} + return {} + + def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[TaskStep]]: + if self.task.id in task_ids: + return {self.task.id: list(self.steps)} + return {} + + def list_steps(self, task_id: str) -> list[TaskStep]: + return list(self.steps) if task_id == self.task.id else [] + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + if self.context and self.context.session_key == session_key: + return [self.context] + return [] + + def list_artifacts(self, task_id: str) -> list[Artifact]: + return list(self.artifacts) if task_id == self.task.id else [] + + def list_action_records(self, task_id: str, limit: int = 200) -> list[ActionRecord]: + return list(self.actions)[:limit] if task_id == self.task.id else [] + + +class SerializerTests(unittest.TestCase): + def test_task_payload_includes_context_retry_and_delivery_state(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00") + session_dir = Path(tmpdir) / "session" / "task-title" + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / "full_video_bvid.txt").write_text("BVFULL123", encoding="utf-8") + (session_dir / "bvid.txt").write_text("BVSPLIT123", encoding="utf-8") + steps = [ + TaskStep(None, "task-1", "publish", "failed_retryable", "ERR", "upload failed", 1, None, "2099-01-01T00:00:00+00:00"), + ] + context = TaskContext( + id=None, + task_id="task-1", + session_key="session-1", + streamer="streamer", + room_id="room", + source_title="task-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid=None, + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeSerializerRepo(task=task, context=context, steps=steps) + state = { + "repo": repo, + "settings": { + "paths": {"session_dir": str(Path(tmpdir) / "session")}, + "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, + "cleanup": {}, + "publish": {"retry_schedule_minutes": [10]}, + }, + } + + payload = ControlPlaneSerializer(state).task_payload("task-1") + + self.assertIsNotNone(payload) + self.assertEqual(payload["session_context"]["session_key"], "session-1") + self.assertEqual(payload["session_context"]["full_video_bvid"], "BVFULL123") + self.assertEqual(payload["retry_state"]["step_name"], "publish") + self.assertEqual(payload["delivery_state"]["split_comment"], "pending") + + def test_session_payload_reuses_task_payload_serialization(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", str(Path(tmpdir) / "session" / "task-title" / "source.mp4"), "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00") + context = TaskContext( + id=None, + task_id="task-1", + session_key="session-1", + streamer="streamer", + room_id="room", + source_title="task-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid="BVFULL123", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeSerializerRepo(task=task, context=context) + state = { + "repo": repo, + "settings": { + "paths": {"session_dir": str(Path(tmpdir) / "session")}, + "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, + "cleanup": {}, + "publish": {}, + }, + } + + payload = ControlPlaneSerializer(state).session_payload("session-1") + + self.assertIsNotNone(payload) + self.assertEqual(payload["session_key"], "session-1") + self.assertEqual(payload["task_count"], 1) + self.assertEqual(payload["full_video_url"], "https://www.bilibili.com/video/BVFULL123") + self.assertEqual(payload["tasks"][0]["id"], "task-1") + + def test_timeline_payload_includes_task_step_artifact_and_action_entries(self) -> None: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00") + steps = [ + TaskStep(None, "task-1", "comment", "succeeded", None, None, 0, "2026-01-01T00:01:00+00:00", "2026-01-01T00:01:30+00:00"), + ] + artifacts = [ + Artifact(None, "task-1", "publish_bvid", "/tmp/bvid.txt", "{}", "2026-01-01T00:01:40+00:00"), + ] + actions = [ + ActionRecord( + id=None, + task_id="task-1", + action_name="comment", + status="ok", + summary="comment succeeded", + details_json=json.dumps({"split": {"status": "ok"}, "full": {"status": "skipped"}}), + created_at="2026-01-01T00:01:50+00:00", + ) + ] + repo = FakeSerializerRepo(task=task, steps=steps, artifacts=artifacts, actions=actions) + state = { + "repo": repo, + "settings": { + "paths": {"session_dir": "/tmp/session"}, + "comment": {"post_split_comment": True, "post_full_video_timeline_comment": True}, + "cleanup": {}, + "publish": {}, + }, + } + + payload = ControlPlaneSerializer(state).timeline_payload("task-1") + + self.assertIsNotNone(payload) + action_item = next(item for item in payload["items"] if item["kind"] == "action") + self.assertIn("split=ok", action_item["summary"]) + kinds = {item["kind"] for item in payload["items"]} + self.assertTrue({"task", "step", "artifact", "action"}.issubset(kinds)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_delivery_service.py b/tests/test_session_delivery_service.py index 9a3b310..53347bf 100644 --- a/tests/test_session_delivery_service.py +++ b/tests/test_session_delivery_service.py @@ -1,143 +1,143 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from biliup_next.app.session_delivery_service import SessionDeliveryService -from biliup_next.core.models import Task, TaskContext - - -class FakeRepo: - def __init__(self, task: Task, context: TaskContext | None = None, contexts: list[TaskContext] | None = None) -> None: - self.task = task - self.tasks = {task.id: task} - self.context = context - self.contexts = contexts or ([] if context is None else [context]) - self.task_context_upserts: list[TaskContext] = [] - self.session_binding_upserts = [] - self.action_records = [] - self.updated_session_bvid: tuple[str, str, str] | None = None - - def get_task(self, task_id: str) -> Task | None: - return self.tasks.get(task_id) - - def get_task_context(self, task_id: str) -> TaskContext | None: - return self.context if task_id == self.task.id else None - - def upsert_task_context(self, context: TaskContext) -> None: - self.context = context - self.task_context_upserts.append(context) - - def upsert_session_binding(self, binding) -> None: # type: ignore[no-untyped-def] - self.session_binding_upserts.append(binding) - - def add_action_record(self, record) -> None: # type: ignore[no-untyped-def] - self.action_records.append(record) - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - return [context for context in self.contexts if context.session_key == session_key] - - def update_session_full_video_bvid(self, session_key: str, full_video_bvid: str, updated_at: str) -> int: - self.updated_session_bvid = (session_key, full_video_bvid, updated_at) - return len(self.list_task_contexts_by_session_key(session_key)) - - def list_task_contexts_by_source_title(self, source_title: str) -> list[TaskContext]: - return [context for context in self.contexts if context.source_title == source_title] - - -class SessionDeliveryServiceTests(unittest.TestCase): - def test_receive_full_video_webhook_updates_binding_context_and_action_record(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - context = TaskContext( - id=None, - task_id="task-1", - session_key="task:task-1", - streamer="streamer", - room_id="room", - source_title="task-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid=None, - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task, context=context, contexts=[context]) - state = {"repo": repo, "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}} - - result = SessionDeliveryService(state).receive_full_video_webhook( - {"session_key": "session-1", "source_title": "task-title", "full_video_bvid": "BVWEBHOOK123"} - ) - - self.assertEqual(result["updated_count"], 1) - self.assertEqual(repo.context.session_key, "session-1") - self.assertEqual(repo.context.full_video_bvid, "BVWEBHOOK123") - self.assertEqual(repo.session_binding_upserts[-1].full_video_bvid, "BVWEBHOOK123") - self.assertEqual(repo.action_records[-1].action_name, "webhook_full_video_uploaded") - persisted_path = Path(result["tasks"][0]["path"]) - self.assertTrue(persisted_path.exists()) - self.assertEqual(persisted_path.read_text(encoding="utf-8"), "BVWEBHOOK123") - - def test_receive_full_video_webhook_uses_source_title_to_expand_to_session(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - anchor = TaskContext( - id=None, - task_id="task-1", - session_key="session-anchor", - streamer="streamer", - room_id="room", - source_title="anchor-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid=None, - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - sibling = TaskContext( - id=None, - task_id="task-2", - session_key="session-anchor", - streamer="streamer", - room_id="room", - source_title="sibling-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid=None, - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task, context=anchor, contexts=[anchor, sibling]) - repo.tasks["task-2"] = Task( - "task-2", - "local_file", - "/tmp/source-2.mp4", - "task-title-2", - "published", - "2026-01-01T00:00:00+00:00", - "2026-01-01T00:00:00+00:00", - ) - state = {"repo": repo, "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}} - - result = SessionDeliveryService(state).receive_full_video_webhook( - {"source_title": "anchor-title", "full_video_bvid": "BVWEBHOOK123"} - ) - - self.assertEqual(result["session_key"], "session-anchor") - self.assertEqual(result["updated_count"], 2) - self.assertTrue(any(binding.session_key == "session-anchor" for binding in repo.session_binding_upserts)) - self.assertTrue(any(binding.source_title == "anchor-title" for binding in repo.session_binding_upserts)) - - def test_merge_session_returns_error_when_task_ids_empty(self) -> None: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - repo = FakeRepo(task) - state = {"repo": repo, "settings": {"paths": {"session_dir": "/tmp/session"}}} - - result = SessionDeliveryService(state).merge_session("session-1", ["", " "]) - - self.assertEqual(result["error"]["code"], "TASK_IDS_EMPTY") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from biliup_next.app.session_delivery_service import SessionDeliveryService +from biliup_next.core.models import Task, TaskContext + + +class FakeRepo: + def __init__(self, task: Task, context: TaskContext | None = None, contexts: list[TaskContext] | None = None) -> None: + self.task = task + self.tasks = {task.id: task} + self.context = context + self.contexts = contexts or ([] if context is None else [context]) + self.task_context_upserts: list[TaskContext] = [] + self.session_binding_upserts = [] + self.action_records = [] + self.updated_session_bvid: tuple[str, str, str] | None = None + + def get_task(self, task_id: str) -> Task | None: + return self.tasks.get(task_id) + + def get_task_context(self, task_id: str) -> TaskContext | None: + return self.context if task_id == self.task.id else None + + def upsert_task_context(self, context: TaskContext) -> None: + self.context = context + self.task_context_upserts.append(context) + + def upsert_session_binding(self, binding) -> None: # type: ignore[no-untyped-def] + self.session_binding_upserts.append(binding) + + def add_action_record(self, record) -> None: # type: ignore[no-untyped-def] + self.action_records.append(record) + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + return [context for context in self.contexts if context.session_key == session_key] + + def update_session_full_video_bvid(self, session_key: str, full_video_bvid: str, updated_at: str) -> int: + self.updated_session_bvid = (session_key, full_video_bvid, updated_at) + return len(self.list_task_contexts_by_session_key(session_key)) + + def list_task_contexts_by_source_title(self, source_title: str) -> list[TaskContext]: + return [context for context in self.contexts if context.source_title == source_title] + + +class SessionDeliveryServiceTests(unittest.TestCase): + def test_receive_full_video_webhook_updates_binding_context_and_action_record(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + context = TaskContext( + id=None, + task_id="task-1", + session_key="task:task-1", + streamer="streamer", + room_id="room", + source_title="task-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid=None, + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task, context=context, contexts=[context]) + state = {"repo": repo, "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}} + + result = SessionDeliveryService(state).receive_full_video_webhook( + {"session_key": "session-1", "source_title": "task-title", "full_video_bvid": "BVWEBHOOK123"} + ) + + self.assertEqual(result["updated_count"], 1) + self.assertEqual(repo.context.session_key, "session-1") + self.assertEqual(repo.context.full_video_bvid, "BVWEBHOOK123") + self.assertEqual(repo.session_binding_upserts[-1].full_video_bvid, "BVWEBHOOK123") + self.assertEqual(repo.action_records[-1].action_name, "webhook_full_video_uploaded") + persisted_path = Path(result["tasks"][0]["path"]) + self.assertTrue(persisted_path.exists()) + self.assertEqual(persisted_path.read_text(encoding="utf-8"), "BVWEBHOOK123") + + def test_receive_full_video_webhook_uses_source_title_to_expand_to_session(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + anchor = TaskContext( + id=None, + task_id="task-1", + session_key="session-anchor", + streamer="streamer", + room_id="room", + source_title="anchor-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid=None, + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + sibling = TaskContext( + id=None, + task_id="task-2", + session_key="session-anchor", + streamer="streamer", + room_id="room", + source_title="sibling-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid=None, + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task, context=anchor, contexts=[anchor, sibling]) + repo.tasks["task-2"] = Task( + "task-2", + "local_file", + "/tmp/source-2.mp4", + "task-title-2", + "published", + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ) + state = {"repo": repo, "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}} + + result = SessionDeliveryService(state).receive_full_video_webhook( + {"source_title": "anchor-title", "full_video_bvid": "BVWEBHOOK123"} + ) + + self.assertEqual(result["session_key"], "session-anchor") + self.assertEqual(result["updated_count"], 2) + self.assertTrue(any(binding.session_key == "session-anchor" for binding in repo.session_binding_upserts)) + self.assertTrue(any(binding.source_title == "anchor-title" for binding in repo.session_binding_upserts)) + + def test_merge_session_returns_error_when_task_ids_empty(self) -> None: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + repo = FakeRepo(task) + state = {"repo": repo, "settings": {"paths": {"session_dir": "/tmp/session"}}} + + result = SessionDeliveryService(state).merge_session("session-1", ["", " "]) + + self.assertEqual(result["error"]["code"], "TASK_IDS_EMPTY") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_service.py b/tests/test_settings_service.py index b28b26c..e28c66a 100644 --- a/tests/test_settings_service.py +++ b/tests/test_settings_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import tempfile import unittest +from unittest.mock import patch from pathlib import Path from biliup_next.core.config import SettingsService @@ -78,6 +79,146 @@ class SettingsServiceTests(unittest.TestCase): self.assertTrue((config_dir / "settings.staged.json").exists()) self.assertEqual(bundle.settings["paths"]["cookies_file"], str((root / "runtime" / "cookies.json").resolve())) + def test_load_applies_environment_overrides_before_path_normalization(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_dir = root / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "settings.schema.json").write_text( + """ + { + "groups": { + "runtime": { + "database_path": {"type": "string", "default": "data/workspace/biliup_next.db"} + }, + "paths": { + "stage_dir": {"type": "string", "default": "data/workspace/stage"}, + "backup_dir": {"type": "string", "default": "data/workspace/backup"}, + "session_dir": {"type": "string", "default": "data/workspace/session"}, + "cookies_file": {"type": "string", "default": "runtime/cookies.json"}, + "upload_config_file": {"type": "string", "default": "runtime/upload_config.json"} + }, + "ingest": { + "ffprobe_bin": {"type": "string", "default": "ffprobe"}, + "yt_dlp_cmd": {"type": "string", "default": "yt-dlp"}, + "yt_dlp_format": {"type": "string", "default": ""} + }, + "transcribe": { + "groq_api_key": {"type": "string", "default": "", "sensitive": true}, + "ffmpeg_bin": {"type": "string", "default": "ffmpeg"} + }, + "split": { + "ffmpeg_bin": {"type": "string", "default": "ffmpeg"} + }, + "song_detect": { + "codex_cmd": {"type": "string", "default": "codex"}, + "qwen_cmd": {"type": "string", "default": "qwen"} + }, + "publish": { + "biliup_path": {"type": "string", "default": "runtime/biliup"}, + "cookie_file": {"type": "string", "default": "runtime/cookies.json"} + }, + "collection": { + "season_id_a": {"type": "integer", "default": 0}, + "season_id_b": {"type": "integer", "default": 0} + } + } + } + """, + encoding="utf-8", + ) + (config_dir / "settings.standalone.example.json").write_text( + """ + { + "runtime": {"database_path": "data/workspace/biliup_next.db"}, + "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": {"ffprobe_bin": "ffprobe", "yt_dlp_cmd": "yt-dlp", "yt_dlp_format": ""}, + "transcribe": {"groq_api_key": "", "ffmpeg_bin": "ffmpeg"}, + "split": {"ffmpeg_bin": "ffmpeg"}, + "song_detect": {"codex_cmd": "codex", "qwen_cmd": "qwen"}, + "publish": {"biliup_path": "runtime/biliup", "cookie_file": "runtime/cookies.json"}, + "collection": {"season_id_a": 0, "season_id_b": 0} + } + """, + encoding="utf-8", + ) + + with patch.dict( + "os.environ", + { + "GROQ_API_KEY": "gsk_test", + "COLLECTION_SEASON_ID_A": "7196643", + "BILIUP_NEXT__COLLECTION__SEASON_ID_B": "7196624", + "BILIUP_NEXT__PATHS__STAGE_DIR": "data/custom-stage", + }, + clear=True, + ): + bundle = SettingsService(root).load() + + self.assertEqual(bundle.settings["transcribe"]["groq_api_key"], "gsk_test") + self.assertEqual(bundle.settings["collection"]["season_id_a"], 7196643) + self.assertEqual(bundle.settings["collection"]["season_id_b"], 7196624) + self.assertEqual(bundle.settings["paths"]["stage_dir"], str((root / "data" / "custom-stage").resolve())) + + def test_empty_environment_values_do_not_override_settings(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_dir = root / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "settings.schema.json").write_text( + """ + { + "groups": { + "runtime": {"database_path": {"type": "string", "default": "data/workspace/biliup_next.db"}}, + "paths": { + "stage_dir": {"type": "string", "default": "data/workspace/stage"}, + "backup_dir": {"type": "string", "default": "data/workspace/backup"}, + "session_dir": {"type": "string", "default": "data/workspace/session"}, + "cookies_file": {"type": "string", "default": "runtime/cookies.json"}, + "upload_config_file": {"type": "string", "default": "runtime/upload_config.json"} + }, + "ingest": {"ffprobe_bin": {"type": "string", "default": "ffprobe"}, "yt_dlp_cmd": {"type": "string", "default": "yt-dlp"}}, + "transcribe": {"groq_api_key": {"type": "string", "default": ""}, "ffmpeg_bin": {"type": "string", "default": "ffmpeg"}}, + "split": {"ffmpeg_bin": {"type": "string", "default": "ffmpeg"}}, + "song_detect": {"codex_cmd": {"type": "string", "default": "codex"}, "qwen_cmd": {"type": "string", "default": "qwen"}}, + "publish": {"biliup_path": {"type": "string", "default": "runtime/biliup"}, "cookie_file": {"type": "string", "default": "runtime/cookies.json"}} + } + } + """, + encoding="utf-8", + ) + (config_dir / "settings.standalone.example.json").write_text( + """ + { + "runtime": {"database_path": "data/workspace/biliup_next.db"}, + "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": {"ffprobe_bin": "ffprobe", "yt_dlp_cmd": "yt-dlp"}, + "transcribe": {"groq_api_key": "from-file", "ffmpeg_bin": "ffmpeg"}, + "split": {"ffmpeg_bin": "ffmpeg"}, + "song_detect": {"codex_cmd": "codex", "qwen_cmd": "qwen"}, + "publish": {"biliup_path": "runtime/biliup", "cookie_file": "runtime/cookies.json"} + } + """, + encoding="utf-8", + ) + + with patch.dict("os.environ", {"GROQ_API_KEY": ""}, clear=True): + bundle = SettingsService(root).load() + + self.assertEqual(bundle.settings["transcribe"]["groq_api_key"], "from-file") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_song_detect_providers.py b/tests/test_song_detect_providers.py index 5ddff7b..6966931 100644 --- a/tests/test_song_detect_providers.py +++ b/tests/test_song_detect_providers.py @@ -1,21 +1,52 @@ -from __future__ import annotations - +from __future__ import annotations + import json +import os import tempfile import unittest from pathlib import Path +from unittest.mock import patch from biliup_next.core.models import Artifact, Task, utc_now_iso +from biliup_next.infra.adapters.codex_cli import CodexCliAdapter +from biliup_next.modules.song_detect.providers.codex import CodexSongDetector from biliup_next.modules.song_detect.providers.qwen_cli import QwenCliSongDetector class FakeQwenCliAdapter: + def __init__(self, returncode: int = 0) -> None: + self.returncode = returncode + self.last_qwen_cmd: str | None = None + + def run_song_detect(self, *, qwen_cmd: str, work_dir: Path, prompt: str): # noqa: ANN001 + self.last_qwen_cmd = qwen_cmd + songs_json_path = work_dir / "songs.json" + songs_json_path.write_text( + json.dumps( + { + "songs": [ + { + "start": "00:01:23,000", + "end": "00:03:45,000", + "title": "测试歌曲", + "artist": "测试歌手", + "confidence": 0.93, + "evidence": "歌词命中", + } + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return type("Result", (), {"returncode": self.returncode, "stdout": "ok", "stderr": ""})() + + +class FakeCodexCliAdapter: def __init__(self, returncode: int = 0) -> None: self.returncode = returncode - self.last_qwen_cmd: str | None = None - def run_song_detect(self, *, qwen_cmd: str, work_dir: Path, prompt: str): # noqa: ANN001 - self.last_qwen_cmd = qwen_cmd + def run_song_detect(self, *, codex_cmd: str, work_dir: Path, prompt: str): # noqa: ANN001 songs_json_path = work_dir / "songs.json" songs_json_path.write_text( json.dumps( @@ -35,16 +66,49 @@ class FakeQwenCliAdapter: ), encoding="utf-8", ) - return type("Result", (), {"returncode": self.returncode, "stdout": "ok", "stderr": ""})() + return type("Result", (), {"returncode": self.returncode, "stdout": "codex stdout", "stderr": "codex stderr"})() + + +class SongDetectProviderTests(unittest.TestCase): + def test_qwen_cli_provider_generates_json_and_txt_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + work_dir = Path(tmpdir) + subtitle_path = work_dir / "subtitle.srt" + subtitle_path.write_text("1\n00:00:00,000 --> 00:00:03,000\n测试字幕\n", encoding="utf-8") + provider = QwenCliSongDetector(adapter=FakeQwenCliAdapter()) + + task = Task( + id="task-1", + source_type="local_file", + source_path=str(work_dir / "video.mp4"), + title="task-1", + status="transcribed", + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + subtitle = Artifact( + id=None, + task_id=task.id, + artifact_type="subtitle_srt", + path=str(subtitle_path), + metadata_json=None, + created_at=utc_now_iso(), + ) + + songs_json, songs_txt = provider.detect(task, subtitle, {"qwen_cmd": "qwen"}) + + self.assertEqual(json.loads(songs_json.metadata_json)["provider"], "qwen_cli") + self.assertEqual(json.loads(songs_txt.metadata_json)["provider"], "qwen_cli") + self.assertTrue(Path(songs_json.path).exists()) + self.assertTrue(Path(songs_txt.path).exists()) + self.assertIn("测试歌曲", Path(songs_txt.path).read_text(encoding="utf-8")) - -class SongDetectProviderTests(unittest.TestCase): - def test_qwen_cli_provider_generates_json_and_txt_artifacts(self) -> None: + def test_codex_provider_writes_execution_output_to_session_log(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: work_dir = Path(tmpdir) subtitle_path = work_dir / "subtitle.srt" subtitle_path.write_text("1\n00:00:00,000 --> 00:00:03,000\n测试字幕\n", encoding="utf-8") - provider = QwenCliSongDetector(adapter=FakeQwenCliAdapter()) + provider = CodexSongDetector(adapter=FakeCodexCliAdapter()) task = Task( id="task-1", @@ -64,14 +128,42 @@ class SongDetectProviderTests(unittest.TestCase): created_at=utc_now_iso(), ) - songs_json, songs_txt = provider.detect(task, subtitle, {"qwen_cmd": "qwen"}) + songs_json, songs_txt = provider.detect(task, subtitle, {"codex_cmd": "codex"}) - self.assertEqual(json.loads(songs_json.metadata_json)["provider"], "qwen_cli") - self.assertEqual(json.loads(songs_txt.metadata_json)["provider"], "qwen_cli") - self.assertTrue(Path(songs_json.path).exists()) - self.assertTrue(Path(songs_txt.path).exists()) - self.assertIn("测试歌曲", Path(songs_txt.path).read_text(encoding="utf-8")) + json_metadata = json.loads(songs_json.metadata_json) + txt_metadata = json.loads(songs_txt.metadata_json) + self.assertEqual(json_metadata["provider"], "codex") + self.assertEqual(txt_metadata["provider"], "codex") + self.assertNotIn("execution", json_metadata) + codex_log = work_dir / "codex.log" + self.assertTrue(codex_log.exists()) + log_text = codex_log.read_text(encoding="utf-8") + self.assertIn("returncode: 0", log_text) + self.assertIn("codex stdout", log_text) + self.assertIn("codex stderr", log_text) + def test_codex_cli_adapter_disables_inner_sandbox_and_normalizes_proxy_env(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + calls = [] -if __name__ == "__main__": - unittest.main() + def fake_run(cmd, **kwargs): # noqa: ANN001 + calls.append((cmd, kwargs)) + return type("Result", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + with patch.dict(os.environ, {"HTTPS_PROXY": "192.168.1.100:7897"}, clear=True): + with patch("subprocess.run", side_effect=fake_run): + CodexCliAdapter().run_song_detect( + codex_cmd="codex", + work_dir=Path(tmpdir), + prompt="detect songs", + ) + + cmd, kwargs = calls[0] + self.assertIn("--dangerously-bypass-approvals-and-sandbox", cmd) + self.assertNotIn("--full-auto", cmd) + self.assertNotIn("workspace-write", cmd) + self.assertEqual(kwargs["env"]["HTTPS_PROXY"], "http://192.168.1.100:7897") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_song_detect_retry_policy.py b/tests/test_song_detect_retry_policy.py new file mode 100644 index 0000000..27d3c95 --- /dev/null +++ b/tests/test_song_detect_retry_policy.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from biliup_next.app.retry_meta import retry_meta_for_step +from biliup_next.app.task_engine import next_runnable_step +from biliup_next.app.task_policies import resolve_failure +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import TaskStep +from biliup_next.modules.song_detect.providers.qwen_cli import QwenCliSongDetector + + +class _Repo: + def __init__(self) -> None: + self.steps = [TaskStep(None, "task-1", "song_detect", "running", None, None, 0, None, None)] + self.step_updates: list[tuple] = [] + self.task_updates: list[tuple] = [] + + def list_steps(self, task_id: str): # noqa: ANN001 + return list(self.steps) + + def get_task(self, task_id: str): # noqa: ANN001 + return SimpleNamespace(id=task_id, status="running") + + def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # noqa: ANN001 + self.step_updates.append((task_id, step_name, status, kwargs)) + self.steps = [ + TaskStep( + None, + task_id, + step_name, + status, + kwargs.get("error_code"), + kwargs.get("error_message"), + kwargs.get("retry_count", 0), + kwargs.get("started_at"), + kwargs.get("finished_at"), + ) + ] + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + self.task_updates.append((task_id, status, updated_at)) + + +class SongDetectRetryPolicyTests(unittest.TestCase): + def test_retry_meta_reports_wait_window_for_song_detect(self) -> None: + step = TaskStep(None, "task-1", "song_detect", "failed_retryable", "ERR", "boom", 1, None, "2099-01-01T00:00:00+00:00") + + payload = retry_meta_for_step(step, {"song_detect": {"retry_schedule_minutes": [10]}}) + + self.assertIsNotNone(payload) + self.assertFalse(payload["retry_due"]) + self.assertEqual(payload["retry_wait_seconds"], 600) + + def test_next_runnable_step_waits_for_retryable_song_detect(self) -> None: + task = SimpleNamespace(id="task-1", status="failed_retryable") + steps = { + "song_detect": TaskStep(None, "task-1", "song_detect", "failed_retryable", "ERR", "boom", 1, None, "2099-01-01T00:00:00+00:00"), + } + state = { + "settings": { + "transcribe": {}, + "song_detect": {"retry_schedule_minutes": [10]}, + "comment": {"enabled": True}, + "collection": {"enabled": True}, + "paths": {}, + "publish": {}, + } + } + + step_name, waiting_payload = next_runnable_step(task, steps, state) + + self.assertIsNone(step_name) + self.assertIsNotNone(waiting_payload) + self.assertEqual(waiting_payload["step"], "song_detect") + + def test_resolve_failure_adds_song_detect_retry_delay(self) -> None: + repo = _Repo() + task = SimpleNamespace(id="task-1", status="running") + state = { + "settings": { + "transcribe": {}, + "song_detect": {"retry_schedule_minutes": [5, 10]}, + "publish": {}, + "comment": {}, + "paths": {}, + "collection": {"enabled": True}, + } + } + + result = resolve_failure(task, repo, state, ModuleError(code="SONG_DETECT_FAILED", message="boom", retryable=True)) + + self.assertEqual(result["payload"]["retry_status"], "failed_retryable") + self.assertEqual(result["payload"]["next_retry_delay_seconds"], 300) + + def test_qwen_auth_errors_are_not_retryable(self) -> None: + self.assertTrue(QwenCliSongDetector._is_auth_error("[API Error: 401 invalid access token or token expired]")) + self.assertFalse(QwenCliSongDetector._is_auth_error("temporary network failure")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_actions.py b/tests/test_task_actions.py index 8c124e2..1267fda 100644 --- a/tests/test_task_actions.py +++ b/tests/test_task_actions.py @@ -1,143 +1,143 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from biliup_next.app.task_actions import bind_full_video_action, merge_session_action, rebind_session_full_video_action -from biliup_next.core.models import Task, TaskContext - - -class FakeRepo: - def __init__(self, task: Task, context: TaskContext | None = None, contexts: list[TaskContext] | None = None) -> None: - self.task = task - self.context = context - self.contexts = contexts or ([] if context is None else [context]) - self.task_context_upserts: list[TaskContext] = [] - self.session_binding_upserts = [] - self.updated_session_bvid: tuple[str, str, str] | None = None - - def get_task(self, task_id: str) -> Task | None: - return self.task if task_id == self.task.id else None - - def get_task_context(self, task_id: str) -> TaskContext | None: - return self.context if task_id == self.task.id else None - - def upsert_task_context(self, context: TaskContext) -> None: - self.context = context - self.task_context_upserts.append(context) - - def upsert_session_binding(self, binding) -> None: # type: ignore[no-untyped-def] - self.session_binding_upserts.append(binding) - - def add_action_record(self, record) -> None: # type: ignore[no-untyped-def] - return None - - def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: - return [context for context in self.contexts if context.session_key == session_key] - - def update_session_full_video_bvid(self, session_key: str, full_video_bvid: str, updated_at: str) -> int: - self.updated_session_bvid = (session_key, full_video_bvid, updated_at) - return len(self.list_task_contexts_by_session_key(session_key)) - - def list_task_contexts_by_source_title(self, source_title: str) -> list[TaskContext]: - return [context for context in self.contexts if context.source_title == source_title] - - -class TaskActionsTests(unittest.TestCase): - def test_bind_full_video_action_persists_context_binding_and_file(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - repo = FakeRepo(task) - state = { - "repo": repo, - "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}, - } - - with patch("biliup_next.app.task_actions.ensure_initialized", return_value=state), patch( - "biliup_next.app.task_actions.record_task_action" - ): - result = bind_full_video_action("task-1", " BV1234567890 ") - - self.assertEqual(result["full_video_bvid"], "BV1234567890") - self.assertEqual(repo.context.full_video_bvid, "BV1234567890") - self.assertEqual(len(repo.session_binding_upserts), 1) - self.assertTrue(Path(result["path"]).exists()) - self.assertEqual(Path(result["path"]).read_text(encoding="utf-8"), "BV1234567890") - - def test_rebind_session_full_video_action_updates_binding_and_all_task_files(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - context = TaskContext( - id=None, - task_id="task-1", - session_key="session-1", - streamer="streamer", - room_id="room", - source_title="task-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid="BVOLD", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task, context=context, contexts=[context]) - state = { - "repo": repo, - "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}, - } - - with patch("biliup_next.app.task_actions.ensure_initialized", return_value=state), patch( - "biliup_next.app.task_actions.record_task_action" - ): - result = rebind_session_full_video_action("session-1", "BVNEW1234567") - - self.assertEqual(result["updated_count"], 1) - self.assertEqual(repo.context.full_video_bvid, "BVNEW1234567") - self.assertIsNotNone(repo.updated_session_bvid) - self.assertEqual(len(repo.session_binding_upserts), 1) - self.assertEqual(repo.session_binding_upserts[-1].full_video_bvid, "BVNEW1234567") - persisted_path = Path(result["tasks"][0]["path"]) - self.assertTrue(persisted_path.exists()) - self.assertEqual(persisted_path.read_text(encoding="utf-8"), "BVNEW1234567") - - def test_merge_session_action_reuses_persist_path_for_inherited_bvid(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") - existing_context = TaskContext( - id=None, - task_id="existing-task", - session_key="session-1", - streamer="streamer", - room_id="room", - source_title="existing-title", - segment_started_at=None, - segment_duration_seconds=None, - full_video_bvid="BVINHERITED123", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - repo = FakeRepo(task, contexts=[existing_context]) - state = { - "repo": repo, - "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}, - } - - with patch("biliup_next.app.task_actions.ensure_initialized", return_value=state), patch( - "biliup_next.app.task_actions.record_task_action" - ): - result = merge_session_action("session-1", ["task-1"]) - - self.assertEqual(result["merged_count"], 1) - self.assertEqual(repo.context.full_video_bvid, "BVINHERITED123") - self.assertEqual(len(repo.session_binding_upserts), 1) - self.assertEqual(repo.session_binding_upserts[0].full_video_bvid, "BVINHERITED123") - self.assertIn("path", result["tasks"][0]) - persisted_path = Path(result["tasks"][0]["path"]) - self.assertTrue(persisted_path.exists()) - self.assertEqual(persisted_path.read_text(encoding="utf-8"), "BVINHERITED123") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from biliup_next.app.task_actions import bind_full_video_action, merge_session_action, rebind_session_full_video_action +from biliup_next.core.models import Task, TaskContext + + +class FakeRepo: + def __init__(self, task: Task, context: TaskContext | None = None, contexts: list[TaskContext] | None = None) -> None: + self.task = task + self.context = context + self.contexts = contexts or ([] if context is None else [context]) + self.task_context_upserts: list[TaskContext] = [] + self.session_binding_upserts = [] + self.updated_session_bvid: tuple[str, str, str] | None = None + + def get_task(self, task_id: str) -> Task | None: + return self.task if task_id == self.task.id else None + + def get_task_context(self, task_id: str) -> TaskContext | None: + return self.context if task_id == self.task.id else None + + def upsert_task_context(self, context: TaskContext) -> None: + self.context = context + self.task_context_upserts.append(context) + + def upsert_session_binding(self, binding) -> None: # type: ignore[no-untyped-def] + self.session_binding_upserts.append(binding) + + def add_action_record(self, record) -> None: # type: ignore[no-untyped-def] + return None + + def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]: + return [context for context in self.contexts if context.session_key == session_key] + + def update_session_full_video_bvid(self, session_key: str, full_video_bvid: str, updated_at: str) -> int: + self.updated_session_bvid = (session_key, full_video_bvid, updated_at) + return len(self.list_task_contexts_by_session_key(session_key)) + + def list_task_contexts_by_source_title(self, source_title: str) -> list[TaskContext]: + return [context for context in self.contexts if context.source_title == source_title] + + +class TaskActionsTests(unittest.TestCase): + def test_bind_full_video_action_persists_context_binding_and_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + repo = FakeRepo(task) + state = { + "repo": repo, + "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}, + } + + with patch("biliup_next.app.task_actions.ensure_initialized", return_value=state), patch( + "biliup_next.app.task_actions.record_task_action" + ): + result = bind_full_video_action("task-1", " BV1234567890 ") + + self.assertEqual(result["full_video_bvid"], "BV1234567890") + self.assertEqual(repo.context.full_video_bvid, "BV1234567890") + self.assertEqual(len(repo.session_binding_upserts), 1) + self.assertTrue(Path(result["path"]).exists()) + self.assertEqual(Path(result["path"]).read_text(encoding="utf-8"), "BV1234567890") + + def test_rebind_session_full_video_action_updates_binding_and_all_task_files(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + context = TaskContext( + id=None, + task_id="task-1", + session_key="session-1", + streamer="streamer", + room_id="room", + source_title="task-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid="BVOLD", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task, context=context, contexts=[context]) + state = { + "repo": repo, + "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}, + } + + with patch("biliup_next.app.task_actions.ensure_initialized", return_value=state), patch( + "biliup_next.app.task_actions.record_task_action" + ): + result = rebind_session_full_video_action("session-1", "BVNEW1234567") + + self.assertEqual(result["updated_count"], 1) + self.assertEqual(repo.context.full_video_bvid, "BVNEW1234567") + self.assertIsNotNone(repo.updated_session_bvid) + self.assertEqual(len(repo.session_binding_upserts), 1) + self.assertEqual(repo.session_binding_upserts[-1].full_video_bvid, "BVNEW1234567") + persisted_path = Path(result["tasks"][0]["path"]) + self.assertTrue(persisted_path.exists()) + self.assertEqual(persisted_path.read_text(encoding="utf-8"), "BVNEW1234567") + + def test_merge_session_action_reuses_persist_path_for_inherited_bvid(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") + existing_context = TaskContext( + id=None, + task_id="existing-task", + session_key="session-1", + streamer="streamer", + room_id="room", + source_title="existing-title", + segment_started_at=None, + segment_duration_seconds=None, + full_video_bvid="BVINHERITED123", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + repo = FakeRepo(task, contexts=[existing_context]) + state = { + "repo": repo, + "settings": {"paths": {"session_dir": str(Path(tmpdir) / "session")}}, + } + + with patch("biliup_next.app.task_actions.ensure_initialized", return_value=state), patch( + "biliup_next.app.task_actions.record_task_action" + ): + result = merge_session_action("session-1", ["task-1"]) + + self.assertEqual(result["merged_count"], 1) + self.assertEqual(repo.context.full_video_bvid, "BVINHERITED123") + self.assertEqual(len(repo.session_binding_upserts), 1) + self.assertEqual(repo.session_binding_upserts[0].full_video_bvid, "BVINHERITED123") + self.assertIn("path", result["tasks"][0]) + persisted_path = Path(result["tasks"][0]["path"]) + self.assertTrue(persisted_path.exists()) + self.assertEqual(persisted_path.read_text(encoding="utf-8"), "BVINHERITED123") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_control_service.py b/tests/test_task_control_service.py index 19e6e95..6c2083b 100644 --- a/tests/test_task_control_service.py +++ b/tests/test_task_control_service.py @@ -1,46 +1,46 @@ -from __future__ import annotations - -import unittest -from types import SimpleNamespace -from unittest.mock import patch - -from biliup_next.app.task_control_service import TaskControlService - - -class TaskControlServiceTests(unittest.TestCase): - def test_run_task_delegates_to_process_task(self) -> None: - state = {"repo": object(), "settings": {"paths": {"session_dir": "/tmp/session"}}} - - with patch("biliup_next.app.task_control_service.process_task", return_value={"processed": [{"task_id": "task-1"}]}) as process_mock: - result = TaskControlService(state).run_task("task-1") - - self.assertEqual(result["processed"][0]["task_id"], "task-1") - process_mock.assert_called_once_with("task-1") - - def test_retry_step_delegates_with_reset_step(self) -> None: - state = {"repo": object(), "settings": {"paths": {"session_dir": "/tmp/session"}}} - - with patch("biliup_next.app.task_control_service.process_task", return_value={"processed": [{"step": "publish"}]}) as process_mock: - result = TaskControlService(state).retry_step("task-1", "publish") - - self.assertEqual(result["processed"][0]["step"], "publish") - process_mock.assert_called_once_with("task-1", reset_step="publish") - - def test_reset_to_step_combines_reset_and_run_payloads(self) -> None: - state = {"repo": object(), "settings": {"paths": {"session_dir": "/tmp/session"}}} - reset_service = SimpleNamespace(reset_to_step=lambda task_id, step_name: {"task_id": task_id, "reset_to": step_name}) - - with patch("biliup_next.app.task_control_service.TaskResetService", return_value=reset_service) as reset_cls: - with patch.object(reset_service, "reset_to_step", return_value={"task_id": "task-1", "reset_to": "split"}) as reset_mock: - with patch("biliup_next.app.task_control_service.process_task", return_value={"processed": [{"task_id": "task-1"}]}) as process_mock: - result = TaskControlService(state).reset_to_step("task-1", "split") - - self.assertEqual(result["reset"]["reset_to"], "split") - self.assertEqual(result["run"]["processed"][0]["task_id"], "task-1") - reset_cls.assert_called_once() - reset_mock.assert_called_once_with("task-1", "split") - process_mock.assert_called_once_with("task-1") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from biliup_next.app.task_control_service import TaskControlService + + +class TaskControlServiceTests(unittest.TestCase): + def test_run_task_delegates_to_process_task(self) -> None: + state = {"repo": object(), "settings": {"paths": {"session_dir": "/tmp/session"}}} + + with patch("biliup_next.app.task_control_service.process_task", return_value={"processed": [{"task_id": "task-1"}]}) as process_mock: + result = TaskControlService(state).run_task("task-1") + + self.assertEqual(result["processed"][0]["task_id"], "task-1") + process_mock.assert_called_once_with("task-1") + + def test_retry_step_delegates_with_reset_step(self) -> None: + state = {"repo": object(), "settings": {"paths": {"session_dir": "/tmp/session"}}} + + with patch("biliup_next.app.task_control_service.process_task", return_value={"processed": [{"step": "publish"}]}) as process_mock: + result = TaskControlService(state).retry_step("task-1", "publish") + + self.assertEqual(result["processed"][0]["step"], "publish") + process_mock.assert_called_once_with("task-1", reset_step="publish") + + def test_reset_to_step_combines_reset_and_run_payloads(self) -> None: + state = {"repo": object(), "settings": {"paths": {"session_dir": "/tmp/session"}}} + reset_service = SimpleNamespace(reset_to_step=lambda task_id, step_name: {"task_id": task_id, "reset_to": step_name}) + + with patch("biliup_next.app.task_control_service.TaskResetService", return_value=reset_service) as reset_cls: + with patch.object(reset_service, "reset_to_step", return_value={"task_id": "task-1", "reset_to": "split"}) as reset_mock: + with patch("biliup_next.app.task_control_service.process_task", return_value={"processed": [{"task_id": "task-1"}]}) as process_mock: + result = TaskControlService(state).reset_to_step("task-1", "split") + + self.assertEqual(result["reset"]["reset_to"], "split") + self.assertEqual(result["run"]["processed"][0]["task_id"], "task-1") + reset_cls.assert_called_once() + reset_mock.assert_called_once_with("task-1", "split") + process_mock.assert_called_once_with("task-1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_engine.py b/tests/test_task_engine.py index 23198d6..a385898 100644 --- a/tests/test_task_engine.py +++ b/tests/test_task_engine.py @@ -1,133 +1,134 @@ -from __future__ import annotations - +from __future__ import annotations + import unittest from types import SimpleNamespace from biliup_next.app.task_engine import infer_error_step_name, next_runnable_step from biliup_next.core.models import TaskStep - - + + class TaskEngineTests(unittest.TestCase): - def test_infer_error_step_name_prefers_running_step(self) -> None: - task = SimpleNamespace(status="running") - steps = { - "transcribe": TaskStep(None, "task-1", "transcribe", "running", None, None, 0, None, None), - "song_detect": TaskStep(None, "task-1", "song_detect", "pending", None, None, 0, None, None), - } - - self.assertEqual(infer_error_step_name(task, steps), "transcribe") - + def test_infer_error_step_name_prefers_running_step(self) -> None: + task = SimpleNamespace(status="running") + steps = { + "transcribe": TaskStep(None, "task-1", "transcribe", "running", None, None, 0, None, None), + "song_detect": TaskStep(None, "task-1", "song_detect", "pending", None, None, 0, None, None), + } + + self.assertEqual(infer_error_step_name(task, steps), "transcribe") + def test_next_runnable_step_returns_none_while_a_step_is_running(self) -> None: - task = SimpleNamespace(id="task-1", status="running") - steps = { - "transcribe": TaskStep(None, "task-1", "transcribe", "running", None, None, 0, None, None), - "song_detect": TaskStep(None, "task-1", "song_detect", "pending", None, None, 0, None, None), - } - state = { - "settings": { - "comment": {"enabled": True}, - "collection": {"enabled": True}, - "paths": {}, - "publish": {}, - } - } - + task = SimpleNamespace(id="task-1", status="running") + steps = { + "transcribe": TaskStep(None, "task-1", "transcribe", "running", None, None, 0, None, None), + "song_detect": TaskStep(None, "task-1", "song_detect", "pending", None, None, 0, None, None), + } + state = { + "settings": { + "comment": {"enabled": True}, + "collection": {"enabled": True}, + "paths": {}, + "publish": {}, + } + } + self.assertEqual(next_runnable_step(task, steps, state), (None, None)) - def test_next_runnable_step_returns_wait_payload_for_retryable_publish(self) -> None: - task = SimpleNamespace(id="task-1", status="failed_retryable") - steps = { - "publish": TaskStep( - None, - "task-1", - "publish", - "failed_retryable", - "PUBLISH_UPLOAD_FAILED", - "upload failed", - 1, - None, - "2099-01-01T00:00:00+00:00", - ) - } + def test_next_runnable_step_returns_wait_payload_for_retryable_publish(self) -> None: + task = SimpleNamespace(id="task-1", status="failed_retryable") + steps = { + "publish": TaskStep( + None, + "task-1", + "publish", + "failed_retryable", + "PUBLISH_UPLOAD_FAILED", + "upload failed", + 1, + None, + "2099-01-01T00:00:00+00:00", + ) + } state = { "settings": { + "transcribe": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "paths": {}, "publish": {"retry_schedule_minutes": [10]}, } - } - - step_name, waiting_payload = next_runnable_step(task, steps, state) - - self.assertIsNone(step_name) - self.assertIsNotNone(waiting_payload) - self.assertTrue(waiting_payload["waiting_for_retry"]) - self.assertEqual(waiting_payload["step"], "publish") - - def test_next_runnable_step_blocks_non_anchor_session_publish_until_anchor_runs(self) -> None: - task = SimpleNamespace(id="task-2", status="split_done") - steps = { - "publish": TaskStep(None, "task-2", "publish", "pending", None, None, 0, None, None), - } - - class _Repo: - def get_task_context(self, task_id): # noqa: ANN001 - return SimpleNamespace(task_id=task_id, session_key="session-1") - - def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 - return [ - SimpleNamespace(task_id="task-1", segment_started_at="2026-04-04T09:23:00+08:00", source_title="part-1"), - SimpleNamespace(task_id="task-2", segment_started_at="2026-04-04T09:25:00+08:00", source_title="part-2"), - ] - - def get_task(self, task_id): # noqa: ANN001 - status = "split_done" - return SimpleNamespace(id=task_id, status=status) - - state = { - "repo": _Repo(), - "settings": { - "comment": {"enabled": True}, - "collection": {"enabled": True}, - "paths": {}, - "publish": {}, - }, - } - - self.assertEqual(next_runnable_step(task, steps, state), (None, None)) - - def test_next_runnable_step_allows_anchor_session_publish_when_all_parts_split_done(self) -> None: - task = SimpleNamespace(id="task-1", status="split_done") - steps = { - "publish": TaskStep(None, "task-1", "publish", "pending", None, None, 0, None, None), - } - - class _Repo: - def get_task_context(self, task_id): # noqa: ANN001 - return SimpleNamespace(task_id=task_id, session_key="session-1") - - def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 - return [ - SimpleNamespace(task_id="task-1", segment_started_at="2026-04-04T09:23:00+08:00", source_title="part-1"), - SimpleNamespace(task_id="task-2", segment_started_at="2026-04-04T09:25:00+08:00", source_title="part-2"), - ] - - def get_task(self, task_id): # noqa: ANN001 - return SimpleNamespace(id=task_id, status="split_done") - - state = { - "repo": _Repo(), - "settings": { - "comment": {"enabled": True}, - "collection": {"enabled": True}, - "paths": {}, - "publish": {}, - }, - } - - self.assertEqual(next_runnable_step(task, steps, state), ("publish", None)) - - -if __name__ == "__main__": - unittest.main() + } + + step_name, waiting_payload = next_runnable_step(task, steps, state) + + self.assertIsNone(step_name) + self.assertIsNotNone(waiting_payload) + self.assertTrue(waiting_payload["waiting_for_retry"]) + self.assertEqual(waiting_payload["step"], "publish") + + def test_next_runnable_step_blocks_non_anchor_session_publish_until_anchor_runs(self) -> None: + task = SimpleNamespace(id="task-2", status="split_done") + steps = { + "publish": TaskStep(None, "task-2", "publish", "pending", None, None, 0, None, None), + } + + class _Repo: + def get_task_context(self, task_id): # noqa: ANN001 + return SimpleNamespace(task_id=task_id, session_key="session-1") + + def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 + return [ + SimpleNamespace(task_id="task-1", segment_started_at="2026-04-04T09:23:00+08:00", source_title="part-1"), + SimpleNamespace(task_id="task-2", segment_started_at="2026-04-04T09:25:00+08:00", source_title="part-2"), + ] + + def get_task(self, task_id): # noqa: ANN001 + status = "split_done" + return SimpleNamespace(id=task_id, status=status) + + state = { + "repo": _Repo(), + "settings": { + "comment": {"enabled": True}, + "collection": {"enabled": True}, + "paths": {}, + "publish": {}, + }, + } + + self.assertEqual(next_runnable_step(task, steps, state), (None, None)) + + def test_next_runnable_step_allows_anchor_session_publish_when_all_parts_split_done(self) -> None: + task = SimpleNamespace(id="task-1", status="split_done") + steps = { + "publish": TaskStep(None, "task-1", "publish", "pending", None, None, 0, None, None), + } + + class _Repo: + def get_task_context(self, task_id): # noqa: ANN001 + return SimpleNamespace(task_id=task_id, session_key="session-1") + + def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 + return [ + SimpleNamespace(task_id="task-1", segment_started_at="2026-04-04T09:23:00+08:00", source_title="part-1"), + SimpleNamespace(task_id="task-2", segment_started_at="2026-04-04T09:25:00+08:00", source_title="part-2"), + ] + + def get_task(self, task_id): # noqa: ANN001 + return SimpleNamespace(id=task_id, status="split_done") + + state = { + "repo": _Repo(), + "settings": { + "comment": {"enabled": True}, + "collection": {"enabled": True}, + "paths": {}, + "publish": {}, + }, + } + + self.assertEqual(next_runnable_step(task, steps, state), ("publish", None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_policies.py b/tests/test_task_policies.py index 97ce40b..ac3a76c 100644 --- a/tests/test_task_policies.py +++ b/tests/test_task_policies.py @@ -1,95 +1,95 @@ -from __future__ import annotations - -import unittest -from types import SimpleNamespace - -from biliup_next.app.task_policies import apply_disabled_step_fallbacks, resolve_failure -from biliup_next.core.errors import ModuleError -from biliup_next.core.models import TaskStep - - -class FakePolicyRepo: - def __init__(self, task, steps: list[TaskStep]) -> None: # type: ignore[no-untyped-def] - self.task = task - self.steps = steps - self.step_updates: list[tuple] = [] - self.task_updates: list[tuple] = [] - - def get_task(self, task_id: str): # type: ignore[no-untyped-def] - return self.task if task_id == self.task.id else None - - def list_steps(self, task_id: str) -> list[TaskStep]: - return list(self.steps) if task_id == self.task.id else [] - - def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # type: ignore[no-untyped-def] - self.step_updates.append((task_id, step_name, status, kwargs)) - - def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: - self.task_updates.append((task_id, status, updated_at)) - - -class TaskPoliciesTests(unittest.TestCase): - def test_apply_disabled_step_fallbacks_marks_collection_done_when_disabled(self) -> None: - task = SimpleNamespace(id="task-1", status="commented") - repo = FakePolicyRepo(task, []) - state = { - "settings": { - "comment": {"enabled": True}, - "collection": {"enabled": False}, - "paths": {}, - "publish": {}, - } - } - - changed = apply_disabled_step_fallbacks(state, task, repo) - - self.assertTrue(changed) - self.assertEqual([update[1] for update in repo.step_updates], ["collection_a", "collection_b"]) - self.assertEqual(repo.task_updates[-1][1], "collection_synced") - - def test_resolve_failure_uses_publish_retry_schedule(self) -> None: - task = SimpleNamespace(id="task-1", status="running") - steps = [ - TaskStep(None, "task-1", "publish", "running", None, None, 0, "2026-01-01T00:00:00+00:00", None), - ] - repo = FakePolicyRepo(task, steps) - state = { - "settings": { - "publish": {"retry_schedule_minutes": [15, 5]}, - "comment": {}, - "paths": {}, - } - } - exc = ModuleError(code="PUBLISH_UPLOAD_FAILED", message="upload failed", retryable=True) - - failure = resolve_failure(task, repo, state, exc) - - self.assertEqual(failure["step_name"], "publish") - self.assertEqual(failure["payload"]["retry_status"], "failed_retryable") - self.assertEqual(failure["payload"]["next_retry_delay_seconds"], 900) - self.assertEqual(repo.step_updates[-1][1], "publish") - self.assertEqual(repo.task_updates[-1][1], "failed_retryable") - - def test_resolve_failure_uses_rate_limit_schedule_for_publish_601(self) -> None: - task = SimpleNamespace(id="task-1", status="running") - steps = [ - TaskStep(None, "task-1", "publish", "running", None, None, 0, "2026-01-01T00:00:00+00:00", None), - ] - repo = FakePolicyRepo(task, steps) - state = { - "settings": { - "publish": {"retry_schedule_minutes": [15, 5], "rate_limit_retry_schedule_minutes": [30, 60]}, - "comment": {}, - "paths": {}, - } - } - exc = ModuleError(code="PUBLISH_RATE_LIMITED", message="rate limited", retryable=True) - - failure = resolve_failure(task, repo, state, exc) - - self.assertEqual(failure["payload"]["next_retry_delay_seconds"], 1800) - self.assertEqual(repo.task_updates[-1][1], "failed_retryable") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from biliup_next.app.task_policies import apply_disabled_step_fallbacks, resolve_failure +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import TaskStep + + +class FakePolicyRepo: + def __init__(self, task, steps: list[TaskStep]) -> None: # type: ignore[no-untyped-def] + self.task = task + self.steps = steps + self.step_updates: list[tuple] = [] + self.task_updates: list[tuple] = [] + + def get_task(self, task_id: str): # type: ignore[no-untyped-def] + return self.task if task_id == self.task.id else None + + def list_steps(self, task_id: str) -> list[TaskStep]: + return list(self.steps) if task_id == self.task.id else [] + + def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # type: ignore[no-untyped-def] + self.step_updates.append((task_id, step_name, status, kwargs)) + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + self.task_updates.append((task_id, status, updated_at)) + + +class TaskPoliciesTests(unittest.TestCase): + def test_apply_disabled_step_fallbacks_marks_collection_done_when_disabled(self) -> None: + task = SimpleNamespace(id="task-1", status="commented") + repo = FakePolicyRepo(task, []) + state = { + "settings": { + "comment": {"enabled": True}, + "collection": {"enabled": False}, + "paths": {}, + "publish": {}, + } + } + + changed = apply_disabled_step_fallbacks(state, task, repo) + + self.assertTrue(changed) + self.assertEqual([update[1] for update in repo.step_updates], ["collection_a", "collection_b"]) + self.assertEqual(repo.task_updates[-1][1], "collection_synced") + + def test_resolve_failure_uses_publish_retry_schedule(self) -> None: + task = SimpleNamespace(id="task-1", status="running") + steps = [ + TaskStep(None, "task-1", "publish", "running", None, None, 0, "2026-01-01T00:00:00+00:00", None), + ] + repo = FakePolicyRepo(task, steps) + state = { + "settings": { + "publish": {"retry_schedule_minutes": [15, 5]}, + "comment": {}, + "paths": {}, + } + } + exc = ModuleError(code="PUBLISH_UPLOAD_FAILED", message="upload failed", retryable=True) + + failure = resolve_failure(task, repo, state, exc) + + self.assertEqual(failure["step_name"], "publish") + self.assertEqual(failure["payload"]["retry_status"], "failed_retryable") + self.assertEqual(failure["payload"]["next_retry_delay_seconds"], 900) + self.assertEqual(repo.step_updates[-1][1], "publish") + self.assertEqual(repo.task_updates[-1][1], "failed_retryable") + + def test_resolve_failure_uses_rate_limit_schedule_for_publish_601(self) -> None: + task = SimpleNamespace(id="task-1", status="running") + steps = [ + TaskStep(None, "task-1", "publish", "running", None, None, 0, "2026-01-01T00:00:00+00:00", None), + ] + repo = FakePolicyRepo(task, steps) + state = { + "settings": { + "publish": {"retry_schedule_minutes": [15, 5], "rate_limit_retry_schedule_minutes": [30, 60]}, + "comment": {}, + "paths": {}, + } + } + exc = ModuleError(code="PUBLISH_RATE_LIMITED", message="rate limited", retryable=True) + + failure = resolve_failure(task, repo, state, exc) + + self.assertEqual(failure["payload"]["next_retry_delay_seconds"], 1800) + self.assertEqual(repo.task_updates[-1][1], "failed_retryable") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_repository_sqlite.py b/tests/test_task_repository_sqlite.py index f0e9e6c..a101709 100644 --- a/tests/test_task_repository_sqlite.py +++ b/tests/test_task_repository_sqlite.py @@ -1,121 +1,121 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from biliup_next.core.models import SessionBinding, Task, TaskContext, TaskStep -from biliup_next.infra.db import Database -from biliup_next.infra.task_repository import TaskRepository - - -class TaskRepositorySqliteTests(unittest.TestCase): - def setUp(self) -> None: - self.tempdir = tempfile.TemporaryDirectory() - db_path = Path(self.tempdir.name) / "test.db" - self.db = Database(db_path) - self.db.initialize() - self.repo = TaskRepository(self.db) - - def tearDown(self) -> None: - self.tempdir.cleanup() - - def test_query_tasks_filters_and_sorts_by_updated_desc(self) -> None: - self.repo.upsert_task(Task("task-1", "local_file", "/tmp/a.mp4", "Alpha", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00")) - self.repo.upsert_task(Task("task-2", "local_file", "/tmp/b.mp4", "Beta", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:03:00+00:00")) - self.repo.upsert_task(Task("task-3", "local_file", "/tmp/c.mp4", "Gamma", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00")) - - items, total = self.repo.query_tasks(status="published", search="a", sort="updated_desc") - - self.assertEqual(total, 2) - self.assertEqual([item.id for item in items], ["task-2", "task-3"]) - - def test_list_task_contexts_and_steps_for_task_ids_returns_batched_maps(self) -> None: - self.repo.upsert_task(Task("task-1", "local_file", "/tmp/a.mp4", "Alpha", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00")) - self.repo.upsert_task(Task("task-2", "local_file", "/tmp/b.mp4", "Beta", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00")) - self.repo.upsert_task_context( - TaskContext( - id=None, - task_id="task-1", - session_key="session-1", - streamer="streamer", - room_id="room", - source_title="Alpha", - segment_started_at="2026-01-01T00:00:00+00:00", - segment_duration_seconds=60.0, - full_video_bvid="BV123", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - ) - self.repo.replace_steps( - "task-1", - [ - TaskStep(None, "task-1", "transcribe", "pending", None, None, 0, None, None), - TaskStep(None, "task-1", "song_detect", "pending", None, None, 0, None, None), - ], - ) - self.repo.replace_steps( - "task-2", - [ - TaskStep(None, "task-2", "transcribe", "running", None, None, 0, "2026-01-01T00:03:00+00:00", None), - ], - ) - - contexts = self.repo.list_task_contexts_for_task_ids(["task-1", "task-2"]) - steps = self.repo.list_steps_for_task_ids(["task-1", "task-2"]) - - self.assertEqual(set(contexts.keys()), {"task-1"}) - self.assertEqual(contexts["task-1"].full_video_bvid, "BV123") - self.assertEqual([step.step_name for step in steps["task-1"]], ["transcribe", "song_detect"]) - self.assertEqual(steps["task-2"][0].status, "running") - - def test_session_binding_supports_upsert_and_source_title_fallback_lookup(self) -> None: - self.repo.upsert_session_binding( - SessionBinding( - id=None, - session_key="session-1", - source_title="Alpha", - streamer="streamer", - room_id="room", - full_video_bvid="BVOLD", - created_at="2026-01-01T00:00:00+00:00", - updated_at="2026-01-01T00:00:00+00:00", - ) - ) - self.repo.upsert_session_binding( - SessionBinding( - id=None, - session_key="session-1", - source_title="Alpha", - streamer="streamer", - room_id="room", - full_video_bvid="BVNEW", - created_at="2026-01-01T00:01:00+00:00", - updated_at="2026-01-01T00:01:00+00:00", - ) - ) - self.repo.upsert_session_binding( - SessionBinding( - id=None, - session_key=None, - source_title="Beta", - streamer="streamer-2", - room_id="room-2", - full_video_bvid="BVBETA", - created_at="2026-01-01T00:02:00+00:00", - updated_at="2026-01-01T00:02:00+00:00", - ) - ) - - binding_by_session = self.repo.get_session_binding(session_key="session-1") - binding_by_title = self.repo.get_session_binding(source_title="Beta") - - self.assertIsNotNone(binding_by_session) - self.assertEqual(binding_by_session.full_video_bvid, "BVNEW") - self.assertIsNotNone(binding_by_title) - self.assertEqual(binding_by_title.full_video_bvid, "BVBETA") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from biliup_next.core.models import SessionBinding, Task, TaskContext, TaskStep +from biliup_next.infra.db import Database +from biliup_next.infra.task_repository import TaskRepository + + +class TaskRepositorySqliteTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + db_path = Path(self.tempdir.name) / "test.db" + self.db = Database(db_path) + self.db.initialize() + self.repo = TaskRepository(self.db) + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def test_query_tasks_filters_and_sorts_by_updated_desc(self) -> None: + self.repo.upsert_task(Task("task-1", "local_file", "/tmp/a.mp4", "Alpha", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00")) + self.repo.upsert_task(Task("task-2", "local_file", "/tmp/b.mp4", "Beta", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:03:00+00:00")) + self.repo.upsert_task(Task("task-3", "local_file", "/tmp/c.mp4", "Gamma", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00")) + + items, total = self.repo.query_tasks(status="published", search="a", sort="updated_desc") + + self.assertEqual(total, 2) + self.assertEqual([item.id for item in items], ["task-2", "task-3"]) + + def test_list_task_contexts_and_steps_for_task_ids_returns_batched_maps(self) -> None: + self.repo.upsert_task(Task("task-1", "local_file", "/tmp/a.mp4", "Alpha", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00")) + self.repo.upsert_task(Task("task-2", "local_file", "/tmp/b.mp4", "Beta", "created", "2026-01-01T00:00:00+00:00", "2026-01-01T00:02:00+00:00")) + self.repo.upsert_task_context( + TaskContext( + id=None, + task_id="task-1", + session_key="session-1", + streamer="streamer", + room_id="room", + source_title="Alpha", + segment_started_at="2026-01-01T00:00:00+00:00", + segment_duration_seconds=60.0, + full_video_bvid="BV123", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + ) + self.repo.replace_steps( + "task-1", + [ + TaskStep(None, "task-1", "transcribe", "pending", None, None, 0, None, None), + TaskStep(None, "task-1", "song_detect", "pending", None, None, 0, None, None), + ], + ) + self.repo.replace_steps( + "task-2", + [ + TaskStep(None, "task-2", "transcribe", "running", None, None, 0, "2026-01-01T00:03:00+00:00", None), + ], + ) + + contexts = self.repo.list_task_contexts_for_task_ids(["task-1", "task-2"]) + steps = self.repo.list_steps_for_task_ids(["task-1", "task-2"]) + + self.assertEqual(set(contexts.keys()), {"task-1"}) + self.assertEqual(contexts["task-1"].full_video_bvid, "BV123") + self.assertEqual([step.step_name for step in steps["task-1"]], ["transcribe", "song_detect"]) + self.assertEqual(steps["task-2"][0].status, "running") + + def test_session_binding_supports_upsert_and_source_title_fallback_lookup(self) -> None: + self.repo.upsert_session_binding( + SessionBinding( + id=None, + session_key="session-1", + source_title="Alpha", + streamer="streamer", + room_id="room", + full_video_bvid="BVOLD", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + ) + self.repo.upsert_session_binding( + SessionBinding( + id=None, + session_key="session-1", + source_title="Alpha", + streamer="streamer", + room_id="room", + full_video_bvid="BVNEW", + created_at="2026-01-01T00:01:00+00:00", + updated_at="2026-01-01T00:01:00+00:00", + ) + ) + self.repo.upsert_session_binding( + SessionBinding( + id=None, + session_key=None, + source_title="Beta", + streamer="streamer-2", + room_id="room-2", + full_video_bvid="BVBETA", + created_at="2026-01-01T00:02:00+00:00", + updated_at="2026-01-01T00:02:00+00:00", + ) + ) + + binding_by_session = self.repo.get_session_binding(session_key="session-1") + binding_by_title = self.repo.get_session_binding(source_title="Beta") + + self.assertIsNotNone(binding_by_session) + self.assertEqual(binding_by_session.full_video_bvid, "BVNEW") + self.assertIsNotNone(binding_by_title) + self.assertEqual(binding_by_title.full_video_bvid, "BVBETA") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_runner.py b/tests/test_task_runner.py index a257f2f..481c1c1 100644 --- a/tests/test_task_runner.py +++ b/tests/test_task_runner.py @@ -1,136 +1,136 @@ -from __future__ import annotations - -import unittest -from types import SimpleNamespace -from unittest.mock import patch - -from biliup_next.core.errors import ModuleError -from biliup_next.app.task_runner import process_task -from biliup_next.core.models import TaskStep - - -class FakeRunnerRepo: - def __init__(self, task, steps: list[TaskStep]) -> None: # type: ignore[no-untyped-def] - self.task = task - self.steps = steps - self.step_updates: list[tuple] = [] - self.task_updates: list[tuple] = [] - self.claims: list[tuple[str, str, str]] = [] - - def get_task(self, task_id: str): # type: ignore[no-untyped-def] - return self.task if task_id == self.task.id else None - - def list_steps(self, task_id: str) -> list[TaskStep]: - return list(self.steps) if task_id == self.task.id else [] - - def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # type: ignore[no-untyped-def] - self.step_updates.append((task_id, step_name, status, kwargs)) - for index, step in enumerate(self.steps): - if step.task_id == task_id and step.step_name == step_name: - self.steps[index] = TaskStep( - step.id, - step.task_id, - step.step_name, - status, - kwargs.get("error_code", step.error_code), - kwargs.get("error_message", step.error_message), - kwargs.get("retry_count", step.retry_count), - kwargs.get("started_at", step.started_at), - kwargs.get("finished_at", step.finished_at), - ) - - def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: - self.task_updates.append((task_id, status, updated_at)) - if task_id == self.task.id: - self.task = SimpleNamespace(**{**self.task.__dict__, "status": status, "updated_at": updated_at}) - - def claim_step_running(self, task_id: str, step_name: str, *, started_at: str) -> bool: - self.claims.append((task_id, step_name, started_at)) - for index, step in enumerate(self.steps): - if step.task_id == task_id and step.step_name == step_name: - self.steps[index] = TaskStep(step.id, step.task_id, step.step_name, "running", None, None, step.retry_count, started_at, None) - return True - - -class TaskRunnerTests(unittest.TestCase): - def test_process_task_reset_step_marks_task_back_to_pre_step_status(self) -> None: - task = SimpleNamespace(id="task-1", status="failed_retryable", updated_at="2026-01-01T00:00:00+00:00") - steps = [ - TaskStep(None, "task-1", "transcribe", "failed_retryable", "ERR", "boom", 1, "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00"), - ] - repo = FakeRunnerRepo(task, steps) - state = { - "repo": repo, - "settings": {"ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {}}, - } - - with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( - "biliup_next.app.task_runner.record_task_action" - ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( - "biliup_next.app.task_runner.next_runnable_step", return_value=(None, None) - ): - result = process_task("task-1", reset_step="transcribe") - - self.assertTrue(result["processed"][0]["reset"]) - self.assertEqual(repo.step_updates[0][1], "transcribe") - self.assertEqual(repo.step_updates[0][2], "pending") - self.assertEqual(repo.task_updates[0][1], "created") - - def test_process_task_sets_task_running_before_execute_step(self) -> None: - task = SimpleNamespace(id="task-1", status="created", updated_at="2026-01-01T00:00:00+00:00") - steps = [ - TaskStep(None, "task-1", "transcribe", "pending", None, None, 0, None, None), - ] - repo = FakeRunnerRepo(task, steps) - state = { - "repo": repo, - "settings": {"ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {}}, - } - - with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( - "biliup_next.app.task_runner.record_task_action" - ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( - "biliup_next.app.task_runner.next_runnable_step", side_effect=[("transcribe", None), (None, None)] - ), patch("biliup_next.app.task_runner.execute_step", return_value={"task_id": "task-1", "step": "transcribe"}): - result = process_task("task-1") - - self.assertEqual(repo.claims[0][1], "transcribe") - self.assertEqual(repo.task_updates[0][1], "running") - self.assertEqual(result["processed"][0]["step"], "transcribe") - - def test_process_task_marks_publish_failed_retryable_on_module_error(self) -> None: - task = SimpleNamespace(id="task-1", status="split_done", updated_at="2026-01-01T00:00:00+00:00") - steps = [ - TaskStep(None, "task-1", "publish", "pending", None, None, 0, None, None), - ] - repo = FakeRunnerRepo(task, steps) - state = { - "repo": repo, - "settings": { - "ingest": {}, - "paths": {}, - "comment": {"enabled": True}, - "collection": {"enabled": True}, - "publish": {"retry_schedule_minutes": [15], "rate_limit_retry_schedule_minutes": [30]}, - }, - } - - with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( - "biliup_next.app.task_runner.record_task_action" - ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( - "biliup_next.app.task_runner.next_runnable_step", return_value=("publish", None) - ), patch( - "biliup_next.app.task_runner.execute_step", - side_effect=ModuleError(code="PUBLISH_RATE_LIMITED", message="rate limited", retryable=True), - ): - result = process_task("task-1") - - self.assertEqual(result["processed"][-1]["retry_status"], "failed_retryable") - self.assertEqual(result["processed"][-1]["next_retry_delay_seconds"], 1800) - self.assertEqual(repo.step_updates[-1][1], "publish") - self.assertEqual(repo.step_updates[-1][2], "failed_retryable") - self.assertEqual(repo.task_updates[-1][1], "failed_retryable") - - -if __name__ == "__main__": - unittest.main() +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from biliup_next.core.errors import ModuleError +from biliup_next.app.task_runner import process_task +from biliup_next.core.models import TaskStep + + +class FakeRunnerRepo: + def __init__(self, task, steps: list[TaskStep]) -> None: # type: ignore[no-untyped-def] + self.task = task + self.steps = steps + self.step_updates: list[tuple] = [] + self.task_updates: list[tuple] = [] + self.claims: list[tuple[str, str, str]] = [] + + def get_task(self, task_id: str): # type: ignore[no-untyped-def] + return self.task if task_id == self.task.id else None + + def list_steps(self, task_id: str) -> list[TaskStep]: + return list(self.steps) if task_id == self.task.id else [] + + def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # type: ignore[no-untyped-def] + self.step_updates.append((task_id, step_name, status, kwargs)) + for index, step in enumerate(self.steps): + if step.task_id == task_id and step.step_name == step_name: + self.steps[index] = TaskStep( + step.id, + step.task_id, + step.step_name, + status, + kwargs.get("error_code", step.error_code), + kwargs.get("error_message", step.error_message), + kwargs.get("retry_count", step.retry_count), + kwargs.get("started_at", step.started_at), + kwargs.get("finished_at", step.finished_at), + ) + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + self.task_updates.append((task_id, status, updated_at)) + if task_id == self.task.id: + self.task = SimpleNamespace(**{**self.task.__dict__, "status": status, "updated_at": updated_at}) + + def claim_step_running(self, task_id: str, step_name: str, *, started_at: str) -> bool: + self.claims.append((task_id, step_name, started_at)) + for index, step in enumerate(self.steps): + if step.task_id == task_id and step.step_name == step_name: + self.steps[index] = TaskStep(step.id, step.task_id, step.step_name, "running", None, None, step.retry_count, started_at, None) + return True + + +class TaskRunnerTests(unittest.TestCase): + def test_process_task_reset_step_marks_task_back_to_pre_step_status(self) -> None: + task = SimpleNamespace(id="task-1", status="failed_retryable", updated_at="2026-01-01T00:00:00+00:00") + steps = [ + TaskStep(None, "task-1", "transcribe", "failed_retryable", "ERR", "boom", 1, "2026-01-01T00:00:00+00:00", "2026-01-01T00:01:00+00:00"), + ] + repo = FakeRunnerRepo(task, steps) + state = { + "repo": repo, + "settings": {"ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {}}, + } + + with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( + "biliup_next.app.task_runner.record_task_action" + ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( + "biliup_next.app.task_runner.next_runnable_step", return_value=(None, None) + ): + result = process_task("task-1", reset_step="transcribe") + + self.assertTrue(result["processed"][0]["reset"]) + self.assertEqual(repo.step_updates[0][1], "transcribe") + self.assertEqual(repo.step_updates[0][2], "pending") + self.assertEqual(repo.task_updates[0][1], "created") + + def test_process_task_sets_task_running_before_execute_step(self) -> None: + task = SimpleNamespace(id="task-1", status="created", updated_at="2026-01-01T00:00:00+00:00") + steps = [ + TaskStep(None, "task-1", "transcribe", "pending", None, None, 0, None, None), + ] + repo = FakeRunnerRepo(task, steps) + state = { + "repo": repo, + "settings": {"ingest": {}, "paths": {}, "comment": {"enabled": True}, "collection": {"enabled": True}, "publish": {}}, + } + + with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( + "biliup_next.app.task_runner.record_task_action" + ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( + "biliup_next.app.task_runner.next_runnable_step", side_effect=[("transcribe", None), (None, None)] + ), patch("biliup_next.app.task_runner.execute_step", return_value={"task_id": "task-1", "step": "transcribe"}): + result = process_task("task-1") + + self.assertEqual(repo.claims[0][1], "transcribe") + self.assertEqual(repo.task_updates[0][1], "running") + self.assertEqual(result["processed"][0]["step"], "transcribe") + + def test_process_task_marks_publish_failed_retryable_on_module_error(self) -> None: + task = SimpleNamespace(id="task-1", status="split_done", updated_at="2026-01-01T00:00:00+00:00") + steps = [ + TaskStep(None, "task-1", "publish", "pending", None, None, 0, None, None), + ] + repo = FakeRunnerRepo(task, steps) + state = { + "repo": repo, + "settings": { + "ingest": {}, + "paths": {}, + "comment": {"enabled": True}, + "collection": {"enabled": True}, + "publish": {"retry_schedule_minutes": [15], "rate_limit_retry_schedule_minutes": [30]}, + }, + } + + with patch("biliup_next.app.task_runner.ensure_initialized", return_value=state), patch( + "biliup_next.app.task_runner.record_task_action" + ), patch("biliup_next.app.task_runner.apply_disabled_step_fallbacks", return_value=False), patch( + "biliup_next.app.task_runner.next_runnable_step", return_value=("publish", None) + ), patch( + "biliup_next.app.task_runner.execute_step", + side_effect=ModuleError(code="PUBLISH_RATE_LIMITED", message="rate limited", retryable=True), + ): + result = process_task("task-1") + + self.assertEqual(result["processed"][-1]["retry_status"], "failed_retryable") + self.assertEqual(result["processed"][-1]["next_retry_delay_seconds"], 1800) + self.assertEqual(repo.step_updates[-1][1], "publish") + self.assertEqual(repo.step_updates[-1][2], "failed_retryable") + self.assertEqual(repo.task_updates[-1][1], "failed_retryable") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transcribe_retry_policy.py b/tests/test_transcribe_retry_policy.py new file mode 100644 index 0000000..ccd76f7 --- /dev/null +++ b/tests/test_transcribe_retry_policy.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from biliup_next.app.retry_meta import retry_meta_for_step +from biliup_next.app.task_engine import next_runnable_step +from biliup_next.app.task_policies import resolve_failure +from biliup_next.core.errors import ModuleError +from biliup_next.core.models import TaskStep + + +class _Repo: + def __init__(self) -> None: + self.steps = [TaskStep(None, "task-1", "transcribe", "running", None, None, 0, None, None)] + self.step_updates: list[tuple] = [] + self.task_updates: list[tuple] = [] + + def list_steps(self, task_id: str): # noqa: ANN001 + return list(self.steps) + + def get_task(self, task_id: str): # noqa: ANN001 + return SimpleNamespace(id=task_id, status="running") + + def update_step_status(self, task_id: str, step_name: str, status: str, **kwargs) -> None: # noqa: ANN001 + self.step_updates.append((task_id, step_name, status, kwargs)) + self.steps = [TaskStep(None, task_id, step_name, status, kwargs.get("error_code"), kwargs.get("error_message"), kwargs.get("retry_count", 0), kwargs.get("started_at"), kwargs.get("finished_at"))] + + def update_task_status(self, task_id: str, status: str, updated_at: str) -> None: + self.task_updates.append((task_id, status, updated_at)) + + +class TranscribeRetryPolicyTests(unittest.TestCase): + def test_retry_meta_reports_wait_window_for_transcribe(self) -> None: + step = TaskStep(None, "task-1", "transcribe", "failed_retryable", "ERR", "boom", 1, None, "2099-01-01T00:00:00+00:00") + + payload = retry_meta_for_step(step, {"transcribe": {"retry_schedule_minutes": [10]}}) + + self.assertIsNotNone(payload) + self.assertFalse(payload["retry_due"]) + self.assertEqual(payload["retry_wait_seconds"], 600) + + def test_next_runnable_step_waits_for_retryable_transcribe(self) -> None: + task = SimpleNamespace(id="task-1", status="failed_retryable") + steps = { + "transcribe": TaskStep(None, "task-1", "transcribe", "failed_retryable", "ERR", "boom", 1, None, "2099-01-01T00:00:00+00:00"), + } + state = { + "settings": { + "transcribe": {"retry_schedule_minutes": [10]}, + "comment": {"enabled": True}, + "collection": {"enabled": True}, + "paths": {}, + "publish": {}, + } + } + + step_name, waiting_payload = next_runnable_step(task, steps, state) + + self.assertIsNone(step_name) + self.assertIsNotNone(waiting_payload) + self.assertEqual(waiting_payload["step"], "transcribe") + + def test_resolve_failure_adds_transcribe_retry_delay(self) -> None: + repo = _Repo() + task = SimpleNamespace(id="task-1", status="running") + state = { + "settings": { + "transcribe": {"retry_schedule_minutes": [5, 10]}, + "publish": {}, + "comment": {}, + "paths": {}, + "collection": {"enabled": True}, + } + } + + result = resolve_failure(task, repo, state, ModuleError(code="GROQ_TRANSCRIBE_FAILED", message="boom", retryable=True)) + + self.assertEqual(result["payload"]["retry_status"], "failed_retryable") + self.assertEqual(result["payload"]["next_retry_delay_seconds"], 300) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_video_links.py b/tests/test_video_links.py new file mode 100644 index 0000000..ac8a95f --- /dev/null +++ b/tests/test_video_links.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import subprocess + +from biliup_next.infra.adapters.full_video_locator import fetch_biliup_list +from biliup_next.infra.video_links import link_context_for_task + + +class VideoLinksTests(unittest.TestCase): + def test_fetch_biliup_list_keeps_pubing_videos(self) -> None: + output = ( + "2026-04-22 15:56:43 INFO biliup_cli::uploader: user: test\n" + "BVREVIEW\t王海颖唱歌录播 04月22日 15时56分\t审核中\n" + "BVPUB\t王海颖唱歌录播 04月20日 22时08分\t开放浏览\n" + "BVPRIVATE\t私密视频\t仅自己可见\n" + ) + with patch( + "biliup_next.infra.adapters.full_video_locator.subprocess.run", + return_value=subprocess.CompletedProcess(["biliup"], 0, stdout=output, stderr=""), + ): + videos = fetch_biliup_list({"biliup_path": "biliup", "cookie_file": "cookies.json"}, max_pages=1) + + self.assertEqual( + videos, + [ + {"bvid": "BVREVIEW", "title": "王海颖唱歌录播 04月22日 15时56分"}, + {"bvid": "BVPUB", "title": "王海颖唱歌录播 04月20日 22时08分"}, + ], + ) + + def test_previous_live_falls_back_to_biliup_list(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + source_path = Path(tmpdir) / "source.mp4" + source_path.write_bytes(b"") + task = SimpleNamespace( + id="task-current", + title="王海颖唱歌录播 04月19日 22时10分", + source_path=str(source_path), + ) + repo = SimpleNamespace(get_task_context=lambda task_id: None) + settings = {"biliup_path": "biliup", "cookie_file": "cookies.json"} + + with patch( + "biliup_next.infra.video_links.fetch_biliup_list", + return_value=[ + {"bvid": "BVPURE", "title": "【王海颖 (歌曲纯享版)】 04月18日 22时06分 共10首歌"}, + {"bvid": "BVNEWER", "title": "王海颖唱歌录播 04月20日 22时00分"}, + {"bvid": "BVPREV", "title": "王海颖唱歌录播 04月18日 22时06分"}, + {"bvid": "BVOLDER", "title": "王海颖唱歌录播 04月17日 22时00分"}, + ], + ): + context = link_context_for_task(task, repo, settings) + + self.assertEqual(context["previous_full_video_bvid"], "BVPREV") + self.assertEqual(context["previous_full_video_link"], "https://www.bilibili.com/video/BVPREV") + self.assertEqual(context["previous_pure_video_bvid"], "BVPURE") + self.assertEqual(context["previous_pure_video_link"], "https://www.bilibili.com/video/BVPURE") + + def test_previous_live_merges_repo_and_biliup_list_links(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + current_path = root / "current" / "source.mp4" + previous_path = root / "previous" / "source.mp4" + current_path.parent.mkdir() + previous_path.parent.mkdir() + current_path.write_bytes(b"") + previous_path.write_bytes(b"") + (previous_path.parent / "full_video_bvid.txt").write_text("BVLOCALFULL", encoding="utf-8") + + task = SimpleNamespace( + id="task-current", + title="王海颖唱歌录播 04月19日 22时10分", + source_path=str(current_path), + ) + previous_task = SimpleNamespace( + id="task-previous", + title="王海颖唱歌录播 04月18日 22时06分", + source_path=str(previous_path), + ) + current_context = SimpleNamespace( + task_id=task.id, + streamer="王海颖", + session_key="王海颖-0419", + segment_started_at="2026-04-19T22:10:00", + ) + previous_context = SimpleNamespace( + task_id=previous_task.id, + streamer="王海颖", + session_key="王海颖-0418", + segment_started_at="2026-04-18T22:06:00", + full_video_bvid="BVLOCALFULL", + ) + tasks = {task.id: task, previous_task.id: previous_task} + contexts = {task.id: current_context, previous_task.id: previous_context} + repo = SimpleNamespace( + get_task_context=lambda task_id: contexts.get(task_id), + get_task=lambda task_id: tasks.get(task_id), + find_recent_task_contexts=lambda streamer, limit=50: [current_context, previous_context], + ) + settings = {"biliup_path": "biliup", "cookie_file": "cookies.json"} + + with patch( + "biliup_next.infra.video_links.fetch_biliup_list", + return_value=[ + {"bvid": "BVPURE", "title": "【王海颖(歌曲纯享版)】04月18日 22时06分 共18首歌"}, + ], + ): + context = link_context_for_task(task, repo, settings) + + self.assertEqual(context["previous_full_video_bvid"], "BVLOCALFULL") + self.assertEqual(context["previous_full_video_link"], "https://www.bilibili.com/video/BVLOCALFULL") + self.assertEqual(context["previous_pure_video_bvid"], "BVPURE") + self.assertEqual(context["previous_pure_video_link"], "https://www.bilibili.com/video/BVPURE") + + def test_previous_live_biliup_list_handles_year_boundary(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + source_path = Path(tmpdir) / "source.mp4" + source_path.write_bytes(b"") + task = SimpleNamespace( + id="task-current", + title="王海颖唱歌录播 01月01日 22时10分", + source_path=str(source_path), + ) + repo = SimpleNamespace(get_task_context=lambda task_id: None) + settings = {"biliup_path": "biliup", "cookie_file": "cookies.json"} + + with patch( + "biliup_next.infra.video_links.fetch_biliup_list", + return_value=[ + {"bvid": "BVPREV", "title": "王海颖唱歌录播 12月31日 22时06分"}, + ], + ): + context = link_context_for_task(task, repo, settings) + + self.assertEqual(context["previous_full_video_bvid"], "BVPREV") + + def test_current_full_video_falls_back_to_biliup_list(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + source_path = Path(tmpdir) / "source.mp4" + source_path.write_bytes(b"") + task = SimpleNamespace( + id="task-current", + title="王海颖唱歌录播 04月22日 15时56分", + source_path=str(source_path), + ) + repo = SimpleNamespace(get_task_context=lambda task_id: None) + settings = {"biliup_path": "biliup", "cookie_file": "cookies.json"} + + with patch( + "biliup_next.infra.adapters.full_video_locator.fetch_biliup_list", + return_value=[ + {"bvid": "BVFULL", "title": "王海颖唱歌录播 04月22日 15时56分"}, + {"bvid": "BVPURE", "title": "【王海颖 (歌曲纯享版)】 04月22日 15时56分 共20首歌"}, + ], + ): + context = link_context_for_task(task, repo, settings) + + self.assertEqual(context["current_full_video_bvid"], "BVFULL") + self.assertEqual(context["current_full_video_link"], "https://www.bilibili.com/video/BVFULL") + self.assertEqual((source_path.parent / "full_video_bvid.txt").read_text(encoding="utf-8"), "BVFULL") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace_cleanup.py b/tests/test_workspace_cleanup.py new file mode 100644 index 0000000..d7a2424 --- /dev/null +++ b/tests/test_workspace_cleanup.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from biliup_next.core.models import Task, utc_now_iso +from biliup_next.infra.workspace_cleanup import WorkspaceCleanupService + + +class _FakeRepo: + def __init__(self, tasks: list[Task], session_key: str | None = None) -> None: + self.tasks = {task.id: task for task in tasks} + self.session_key = session_key + self.deleted_artifacts: list[tuple[str, str]] = [] + self.deleted_artifact_paths: list[tuple[str, str]] = [] + + def get_task(self, task_id: str) -> Task | None: + return self.tasks.get(task_id) + + def get_task_context(self, task_id: str): # noqa: ANN201 + if self.session_key is None or task_id not in self.tasks: + return None + return SimpleNamespace(task_id=task_id, session_key=self.session_key) + + def list_task_contexts_by_session_key(self, session_key: str): # noqa: ANN201 + if session_key != self.session_key: + return [] + return [SimpleNamespace(task_id=task_id, session_key=session_key) for task_id in self.tasks] + + def delete_artifacts(self, task_id: str, artifact_type: str) -> None: + self.deleted_artifacts.append((task_id, artifact_type)) + + def delete_artifact_by_path(self, task_id: str, path: str) -> None: + self.deleted_artifact_paths.append((task_id, path)) + + +def _make_task(task_id: str, root: Path) -> Task: + now = utc_now_iso() + work_dir = root / task_id + work_dir.mkdir(parents=True) + source = work_dir / "source.mp4" + source.write_bytes(b"source") + for dirname in ("split_video", "publish_video"): + video_dir = work_dir / dirname + video_dir.mkdir() + (video_dir / "01_song.mp4").write_bytes(b"clip") + return Task(task_id, "local_file", str(source), task_id, "collection_synced", now, now) + + +class WorkspaceCleanupServiceTests(unittest.TestCase): + def test_cleanup_removes_source_split_and_publish_video_for_single_task(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + task = _make_task("task-1", root) + repo = _FakeRepo([task]) + result = WorkspaceCleanupService(repo).cleanup_task_outputs( + task.id, + { + "delete_source_video_after_collection_synced": True, + "delete_split_videos_after_collection_synced": True, + }, + ) + + work_dir = root / "task-1" + self.assertFalse((work_dir / "source.mp4").exists()) + self.assertFalse((work_dir / "split_video").exists()) + self.assertFalse((work_dir / "publish_video").exists()) + self.assertEqual(result["task_ids"], ["task-1"]) + self.assertEqual(repo.deleted_artifacts, [("task-1", "clip_video")]) + self.assertEqual(repo.deleted_artifact_paths, [("task-1", str((work_dir / "source.mp4").resolve()))]) + + def test_cleanup_removes_all_tasks_in_same_session(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + task_1 = _make_task("task-1", root) + task_2 = _make_task("task-2", root) + repo = _FakeRepo([task_1, task_2], session_key="session-1") + result = WorkspaceCleanupService(repo).cleanup_task_outputs( + task_1.id, + { + "delete_source_video_after_collection_synced": True, + "delete_split_videos_after_collection_synced": True, + }, + ) + + for task_id in ("task-1", "task-2"): + work_dir = root / task_id + self.assertFalse((work_dir / "source.mp4").exists()) + self.assertFalse((work_dir / "split_video").exists()) + self.assertFalse((work_dir / "publish_video").exists()) + self.assertEqual(result["task_ids"], ["task-1", "task-2"]) + self.assertEqual(repo.deleted_artifacts, [("task-1", "clip_video"), ("task-2", "clip_video")]) + + def test_cleanup_skips_missing_source_video(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + task = _make_task("task-1", root) + source = Path(task.source_path) + source.unlink() + repo = _FakeRepo([task]) + result = WorkspaceCleanupService(repo).cleanup_task_outputs( + task.id, + { + "delete_source_video_after_collection_synced": True, + "delete_split_videos_after_collection_synced": False, + }, + ) + + self.assertIn(str(source.resolve()), result["skipped"]) + self.assertEqual(repo.deleted_artifact_paths, []) + + +if __name__ == "__main__": + unittest.main()