diff --git a/.env.example b/.env.example index 047a9e8..1bccb09 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ FFMPEG_TIMEOUT=600 # 如果以后要换存储盘,可以同步修改 docker-compose.yml 里的 volumes 和这里的路径。 STORAGE_ROOT=E:\直播间切片工作流存储 TASKS_DIR=E:\直播间切片工作流存储 +# 浏览器上传超过 1MB 时使用的临时目录;必须放在大容量存储盘。 +UPLOAD_TEMP_DIR=E:\直播间切片工作流存储\_临时上传 AI_DEFAULT_PROVIDER=remote AI_REQUEST_TIMEOUT_SECONDS=120 @@ -57,19 +59,35 @@ AI_LOCAL_PROTOCOL=chat_completions AI_LOCAL_FALLBACK_PROTOCOL= AI_LOCAL_HEALTH_TIMEOUT_SECONDS=30 -# 发送中心 opencli -# Docker 主页面固定使用 8001;Windows opencli 辅助服务由 scripts/start_docker_opencli.ps1 启动。 +# 发送中心旧 opencli 兼容配置(默认关闭) OPENCLI_LOCAL_BASE_URL=http://127.0.0.1:8001 OPENCLI_HOST_BRIDGE_URL=http://host.docker.internal:8765 -# v1.4.0 定时发送与自动发布执行器 -# 默认只导出本地发布包,不调用真实平台 API,不保存账号、密码、cookie 或 token。 +# v1.5.0 统一排期与 Windows Chrome 发布 Worker +APP_TIMEZONE=Asia/Shanghai PUBLISH_SCHEDULER_ENABLED=true -PUBLISH_SCHEDULER_INTERVAL_SECONDS=60 -PUBLISH_SCHEDULER_DEFAULT_PLATFORM=manual_export +PUBLISH_SCHEDULER_INTERVAL_SECONDS=5 +PUBLISH_DEFAULT_MODE=local_browser +PUBLISH_JOB_STALE_MINUTES=30 PUBLISH_SCHEDULER_MAX_RETRY_COUNT=3 -PUBLISH_SCHEDULER_EXPORT_DIR= +PUBLISH_SCHEDULER_EXPORT_DIR=E:\直播间切片工作流存储\_发布包 PUBLISH_SCHEDULER_ALLOW_PUBLISH_WITHOUT_REVIEW=false +PUBLISH_ENABLE_OPENCLI_FALLBACK=false +# 请使用随机长字符串;start_publish_worker.ps1 会在本地 .env 缺失时自动生成。 +PUBLISH_WORKER_TOKEN= +PUBLISH_WORKER_URL=http://host.docker.internal:8765 +PUBLISH_WORKER_TIMEOUT_SECONDS=1800 +PUBLISH_BROWSER_CHANNEL=chrome +PUBLISH_BROWSER_HEADLESS=false +PUBLISH_BROWSER_NAVIGATION_TIMEOUT_MS=60000 +# 页面改版、验证或发送失败时保留 Chrome 的秒数;测试环境可设为 0。 +PUBLISH_BROWSER_FAILURE_HOLD_SECONDS=600 +PUBLISH_BROWSER_PROFILE_DIR= +PUBLISH_BROWSER_ARTIFACT_DIR= +PUBLISH_WORKER_STATE_DIR= +PUBLISH_HOST_PROJECT_ROOT=C:\Users\你的用户名\Documents\New project 2 +# 多个允许目录使用 Windows 分号分隔;留空时默认允许项目、DATA_DIR 和 TASKS_DIR。 +PUBLISH_WORKER_ALLOWED_ROOTS= # 额外 AI 运行默认值 AI_NETWORK_ACCESS=enabled diff --git a/.gitignore b/.gitignore index a79e1a7..4c9c886 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,15 @@ __pycache__/ data/* !data/.gitkeep +# 发布浏览器登录态、执行日志和失败截图(只允许保存在本机) +data/browser_profiles/ +data/publish_worker/ +data/publish_artifacts/ +outputs/publish_packages/ +playwright/.auth/ +*.storage-state.json +cookies*.json + tasks/* !tasks/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index b16e272..426a95d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ - 项目英文名:NiuMa Studio - 项目中文说明:牛马片场,本地 AI 高光生产后台 - 项目目录:`C:\Users\10578\Documents\New project 2` -- 当前版本:1.3 -- 当前状态:Windows 本地后台处理链路 MVP 全流程已实现,历史功能分支已整理并集成 +- 当前版本:1.5.0 +- 当前状态:Windows 本地后台处理链路与抖音/B站统一真实发布架构已实现;真实投稿仍遵守账号登录、平台验证、风控和人工确认边界 ## 协作方式 @@ -27,7 +27,7 @@ - AI 分析:预留 OpenAI-compatible API 或本地大模型接口 - 语音转写:预留服务接口 -## 当前 1.3 范围 +## 当前 1.5.0 范围 - 新建视频处理任务,支持直播录像、综艺访谈和其他长视频素材。 - 上传本地视频或选择 NAS / 本地目录中的已有视频。 @@ -38,9 +38,10 @@ - AI 分析候选短视频片段,支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama。 - 后台查看并编辑候选片段。 - 自动切割视频并输出短视频文件。 -- 自动加字幕并输出带字幕成片。 -- 发送中心生成待发送队列、标题、简介、话题和候选封面帧。 -- 通过 opencli 调用已登录 Chrome,辅助发送到抖音 / B站投稿页。 +- 保留字幕工作台,但全自动流程继续跳过字幕生成、烧录和叠加。 +- 发送中心分为内容准备、排期计划和执行记录,生成标题、简介、话题和候选封面帧。 +- 立即发送与定时发送统一使用 SQLite Scheduler、Publisher Registry 和 Windows Chrome Worker。 +- 抖音与 B站使用各自 Publisher;`manual_export` 仅显式导出本地发布包,`opencli` 仅作默认关闭的兼容模式。 - 展示任务状态、处理进度和异常信息。 ## 当前不做 @@ -48,7 +49,7 @@ - MacBook 自动录屏。 - 自动识别抖音 / B站直播间并检测是否开播。 - 绕过抖音 / B站验证码、登录失效、平台风控或人工确认。 -- 完全无人值守发布到抖音 / B站;当前发送中心必须保留人工确认和失败处理边界。 +- 绕过登录、验证码、短信、二维码、滑块或平台风控;不确定结果必须进入人工复核。 - AI 生图封面和复杂封面模板。 - 多用户权限系统。 diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 7dbbde5..fac230a 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,190 @@ # Development Log +## 2026-08-02 发送中心续接最晚排期 + +- “设置排期”抽屉新增“接在当前平台最晚排期后”:每次点击都实时读取 SQLite,不依赖页面中最多 200 条的缓存列表;抖音和 B站分别计算。 +- 新增 `POST /api/publish/schedules/next-start`。接口只查当前平台仍有效且带未来时间的 `WAITING / SCHEDULED / PUBLISHING` 记录,并排除本次正在调整的已选任务;已发布、失败、取消和另一平台记录不会占用本平台时间线。 +- 续排严格使用当前间隔,并复用跨午夜时段规则:19:00 加 3 小时为 22:00,21:00 加 3 小时为次日 00:00,22:00 加 3 小时超出窗口后顺延到次日 07:00。 +- 发送中心和全自动任务的新默认每日窗口统一为 `07:00 → 00:00`,仍可逐次修改;已有任务配置、已有排期和数据库结构均未改变。 +- 修复全自动 `daily_window` 对跨午夜窗口的旧循环判断,改为复用统一的允许时段计算,不会在 `07:00 → 00:00` 下反复滚动日期。 +- 新增服务层、API、默认值、平台隔离、选中任务排除、无排期、跨午夜、自动流程及浏览器回填测试;相关测试共 `49 passed`,完整测试 `397 passed`,Ruff、Python/JavaScript 语法和 Git 空白检查均通过,测试未触发真实投稿。 + +## 2026-08-02 发送中心封面计数平台隔离修复 + +- 根因确认:发送中心当前停留在抖音时,封面按钮仍统计了页面中隐藏的 B站任务;现场数据库中的 8 条空封面路径全部属于 B站,抖音当前缺失数实际为 0。 +- 封面按钮现在只统计当前平台、当前激活切片且仍处于内容准备范围的 `DRAFT / WAITING / SCHEDULED` 任务;当前平台没有缺失封面时按钮禁用且不再显示错误数字。 +- 按钮文案改为明确的平台范围,例如“补充抖音缺失封面”或“补充B站缺失封面(8)”,切换平台后会立即重新计算。 +- `POST /api/publish/covers/backfill` 新增可选 `platform` 参数;页面调用时始终传入当前平台,服务端同步校验并隔离查询、生成和更新范围,不会再从抖音页面误补 B站任务,也不会修改已经进入人工复核的记录。 +- 新增服务层平台隔离、非法平台拒绝、API 参数透传和浏览器切换计数回归测试。完整测试 `378 passed`,Ruff、JavaScript 语法和 Git 空白检查均通过;真实页面验收为抖音按钮禁用且无数字、B站显示 8。本次未补封面、未修改发布任务数据、未触发真实投稿。 + +## 2026-08-01 康熙笑点优先选片 V2 + +- 新增可选的“综艺笑点优先”模式,现有通用模式保持不变;候选池默认最多 12 条,最终启用目标默认 5 条,质量不足时允许少选。 +- 综艺模式改为三阶段:远程 AI 使用 5 分钟窗口与 60 秒重叠、本地模型使用 3 分钟窗口与 45 秒重叠进行笑点召回;随后读取笑点前后文扩展成 60–150 秒完整片段;最后把全部候选放进同一次全局评审并按话题、时间邻近和重叠范围去重。 +- 新评分由程序统一计算文字质量与轻量音频反应信号。A级要求总分不低于 78、笑点闭环不低于 75、完整度不低于 70;B级只进入审核且默认关闭;C级不进入候选列表。音频只作加分,不会让文字闭环或完整度不达标的内容越过硬门槛。 +- 音频评审从已有 WAV/音频中读取转写笑声词、笑点后音量突增、动态变化、停顿与短句密度;读取失败时自动退回文字评分,不中断整次 AI 分析,不做说话人识别或画面多模态判断。 +- 全自动流水线只使用 `selected_by_default = 1` 的 A 级候选,最多取 `final_clip_target` 条,不再按 AI 自报置信度强行补齐。 +- 片段审核页新增 A/B 等级、总分、笑点/完整度/音频分、入选证据、未自动启用原因,以及“值得发、不好笑、太碎、铺垫不足、重复、拖沓”反馈按钮;近期反馈会作为以后同模式全局复评的口味参考。 +- 数据库仅新增任务选片字段、候选质量字段和 `clip_feedback` 表;内置新增 4 号“康熙笑点优先 V2”Prompt,只有目标槽位为空时才写入,不覆盖已有自定义 Prompt 或历史任务。 +- 两集只读实测:E1810 从旧版 12 条全启用收敛为 12 条候选、6 条 A 级、5 条默认启用;较弱的 E1811 收敛为 4 条候选、3 条 A 级、3 条默认启用。边界加固后对同批范围复验,两集共 16 条均为 60–150 秒;详细记录见 `docs/KANGXI_V2_COMPARISON.md`。 +- 验证结果:专项测试 `16 passed`,全量自动化测试 `376 passed`;Ruff、Python 编译、JavaScript 语法和 Git 空白检查均通过。只有 8 条现有依赖弃用警告,测试未调用真实发布。 + +## 2026-07-29 任务与发送中心切片关联修复 + +- `output_clip.is_active = 1` 现在是发送中心内容准备与排期计划的唯一切片来源;任务级关联状态会一次查询统计当前切片、双平台关联、缺失、主动移出和旧版待处理数量。 +- 新增 `POST /api/publish/tasks/{task_id}/sync`。通用任务默认同步抖音和 B站,重复调用保持幂等;任务级显式同步可以恢复用户主动移出的当前版本内容。 +- 手动生成切片成功后会自动同步到 `WAITING` 内容准备,不设置排期、不触发投稿;同步失败只作为警告返回,不回滚已经成功生成的切片。全自动流水线继续由原有元数据与排期阶段创建发布任务,避免提前生成重复内容。 +- 重新切片时,旧版 `DRAFT / WAITING / SCHEDULED` 记录安全改为 `CANCELLED`,错误码为 `superseded_by_recut`,清空排期并写入事件;`PUBLISHING / NEED_REVIEW / PUBLISHED / EXPORTED / FAILED` 保持原样。 +- 新版发布内容会尽量继承同一候选片段、同一平台的标题、简介、标签、账号和发布方式,但排期始终清空,封面从新视频重新生成。 +- 字幕工作台显式同步会优先使用已完成的带字幕成片;只更新未排期的 `DRAFT / WAITING` 内容。已经排期的内容不会静默更换视频,会提示先取消排期。 +- 任务列表、任务详情、片段审核和字幕工作台新增发送中心关联状态、任务级同步入口与深链;`/publish` 支持 `task_id / platform / tab`,可自动切换页签、展开并定位任务组。 +- 真实任务 `b84227be2b01` 已完成前后对账:12 个激活切片新增抖音 12 条和 B站 12 条 `WAITING` 内容,排期均为空;旧版 11 条 B站 `WAITING` 已安全取消并写入事件,旧的已发布、失败和待复核记录均保留。 +- 新增任务关联回归测试;关联专项 10 项、关联/流水线/版本回滚/API 专项 52 项均通过。完整测试 `358 passed, 2 skipped`;Python/JavaScript 语法检查和本机浏览器页面验收通过。 + +## 2026-07-29 排期中断修复与安全恢复 + +- 根因确认:Docker 中的 FastAPI 与 Windows Worker 曾同时读写挂载的 `data/workflow.sqlite3`;7 月 28 日 09:00 投稿已被平台确认成功,但 Worker 回写终态时出现 `unable to open database file`,异常处理再次写库后使调度后台退出。 +- Windows Worker 现只负责宿主 Chrome、账号检测和独立执行日志,不再导入或调用 `PublishRepository`,也不再直接更新账号或执行阶段;账号状态、平台结果、任务终态和事件统一由 Docker 内的 FastAPI 落库。 +- `PUBLISHED / EXPORTED / FAILED / NEED_REVIEW` 的平台结果、任务状态和事件改为同一 SQLite 事务提交;任务状态已变化时整笔回滚,避免结果表与任务表出现半成功。 +- 调度常驻循环现在隔离单条任务异常,并捕获 SQLite 临时异常;失败后保留循环、记录安全化错误信息并按 5 秒间隔重试,下一轮成功会清零连续失败计数。 +- 启动恢复会主动读取所有 `PUBLISHING` 任务对应的 Worker 执行日志:`confirmed_success` 只补记成功,明确失败或需复核写入对应终态,仍在执行继续等待,无法确认时进入人工复核,禁止自动重复投稿。 +- 调度健康接口和发送中心新增 `last_error_code / last_error_message / last_error_at / consecutive_failures` 展示;异常时显示“异常重试中”。历史记录请求改为单飞并合并刷新,避免 Worker 离线时的慢请求被 5 秒轮询持续作废。 +- 现场恢复前已停用 Docker、Watcher 和 Windows Worker,并使用 SQLite Backup API 创建 `data/backups/workflow-before-scheduler-recovery-20260729-201655.sqlite3`;源库和备份的完整性检查均为 `ok`,未删除数据库、历史、视频、封面或文案。 +- 新增 Worker 数据库隔离、FastAPI 账号落库、执行日志、中断成功幂等恢复、SQLite 自动重试、单条异常隔离、终态原子性和 18 条两小时间隔顺延测试。 +- 现场数据已恢复:任务 `850892d8d68f` 根据执行日志补记为 `PUBLISHED / confirmed_success`,保留平台原始发布时间且没有重新投稿;18 条 `SCHEDULED` 任务按原顺序移动到北京时间 2026-07-30 09:00 至 2026-08-01 15:00,过期排期为 0,每条均保存原时间、新时间和恢复原因事件。 +- 最终验收通过:Ruff、Python 编译、JavaScript 语法检查和完整 `pytest` 均通过,结果为 `355 passed`;恢复后数据库完整性为 `ok`,健康接口连续两轮刷新,调度器 `running=true`、连续失败 `0`、Worker 正常、18 条排期且 0 条发送中。 + +## 2026-07-28 SQLite 迁移备份瘦身与防复发 + +- 定位到 `data/backups` 在约 17 天内生成 95,291 份 SQLite 迁移备份,占用 121.615 GiB;根因是发布迁移检查被高频调用,旧判断还会把无需合并的失败/人工复核记录当成重复任务。 +- 迁移备份现在只针对真正活跃的 `DRAFT / WAITING / SCHEDULED / PUBLISHING` 重复任务;备份和数据修复使用 SQLite `BEGIN IMMEDIATE` 串行化,避免多个进程同时执行迁移。 +- 新备份通过独立只读连接写入唯一临时文件,`PRAGMA quick_check` 通过后才原子改名;24 小时内已有有效快照时不重复生成。 +- 自动保留最近 14 个备份日、每天一份有效快照;新增 `scripts/cleanup_database_backups.py`,默认仅预演,明确添加 `--apply` 才会删除匹配的迁移备份。 +- 清理工具会先检查主数据库和每天拟保留的备份;若主库损坏、某个备份日没有有效副本或预演后目录发生变化,会中止删除。 +- 已删除 95,279 份重复或损坏的 SQLite 备份和 1 个残留 journal,实际释放 121.601 GiB;保留 12 份有效备份共 14.88 MiB,主库和保留备份完整性检查均为 `ok`。 +- 新增备份回退、14 日保留、24 小时冷却、并发迁移和失败回滚测试;专项测试 19 项、完整测试 348 项全部通过。 + +## 2026-07-28 执行日历、失败立即发送与记录安全清理 + +- “执行记录”新增独立北京时间月历,按 `scheduled_at → started_at → finished_at → created_at` 归档;点击日期后,下方分页列表只展示当天任务,抖音与 B站继续严格隔离。 +- 失败记录的“手动重试”改为“立即发送”:发送前检查内容、文件、账号登录和 Windows Worker;原失败记录保留,新任务通过统一调度器立即执行,`NEED_REVIEW` 仍禁止直接重发。 +- 执行记录支持单条和批量“删除记录”,仅允许已发布、失败、已导出或已取消终态;删除只设置 `history_hidden/history_hidden_at` 并写入事件,不删除视频、执行明细、平台作品或重试关系。 +- 新增“已删除记录”分页视图和批量恢复;普通成功、失败记录不再错误显示“重新加入内容准备”,该操作仅属于 `user_removed_from_preparation` 记录。 +- 新增执行月历、分页查询、安全删除与恢复 API,并补充数据库、服务、API 和真实浏览器回归测试;未触发抖音或 B站真实投稿。 +- 最终验收通过:执行月历固定 42 格,桌面端和 720px 窄屏均无横向溢出;Ruff、Python 编译、两份 JavaScript 语法检查和全量自动化测试全部通过,结果为 `342 passed`(仅有 8 条现有依赖弃用警告)。 +- 修复失败任务重发后偶发 `Internal Server Error`:迁移备份原先把“原失败记录 + 新需复核重试记录”误判为重复活动任务,导致调度器每 5 秒反复备份 SQLite;现在备份检测与重复任务清理统一只统计 `DRAFT / WAITING / SCHEDULED / PUBLISHING`。 +- 本机数据库使用报错前 1 秒的完整备份安全恢复,恢复前损坏原件保存在 `data/recovery/malformed-20260728-010322/`;恢复后的 `PRAGMA integrity_check` 为 `ok`,页面、发布任务接口和调度器均恢复正常。 +- 用户首次点击已经创建的重试任务保留为 `NEED_REVIEW`,原因是内容风险标记“引战夸张”;未再次创建任务,也未触发真实平台投稿。 +- 修复后 Ruff、Python 编译、JavaScript 语法检查和全量测试均通过,结果为 `343 passed`;浏览器实页确认执行日历、需复核操作和响应式布局正常,控制台无错误。 + +## 2026-07-23 Docker 项目与 Windows 发布 Worker 自动联动 + +- 新增 `NiuMa Studio Docker Watcher`:后台严格核对容器名、Compose 项目、`workflow` 服务和项目工作目录,只有当前牛马片场容器运行时才启动 Windows Worker。 +- 容器停止或 Docker 不可用连续 15 秒后,只关闭命令行路径属于当前项目的 Worker;端口被其他项目或程序占用时保持原有保护,不会误关。 +- Worker 保留本机公开 `/health`,新增带 Bearer Token 的 `/v1/health`;Docker 调度器使用鉴权健康接口,首次 Token 不同步时只重建 `workflow` 容器,不删除 SQLite、任务文件或 Chrome 登录资料。 +- 新增观察器安装/卸载脚本。新任务验证成功后才移除当前指向已删除脚本的旧 `NiuMa Studio OpenCLI Host Bridge` 任务。 +- 发送中心移除手动运行 PowerShell 的连接修复提示,改为等待 Docker 自动联动、重新检测或在 Docker Desktop 中重新运行项目。 +- 本机验收通过:停止 `workflow` 后 Worker 自动关闭,重新运行容器后 Worker 与发送中心自动恢复;强制结束当前项目 Worker 后约 6 秒自动拉起新进程。PowerShell 5.1、Ruff、Python 编译、JavaScript、Docker Compose 和全量测试均通过,测试结果 `313 passed`,未执行真实投稿。 + +## 2026-07-22 发送中心按原始任务归类与安全移出 + +- “内容准备”从平铺发布卡片调整为按原始处理任务分组:组头展示任务名称、原视频文件名、任务创建时间、当前平台待准备数量和任务详情入口。 +- 任务组按创建时间从新到旧排列,当前平台最新任务默认展开,其余折叠;切换抖音 / B站后重新统计数量并隐藏当前平台没有内容的分组。 +- 每条内容新增“移出内容准备”:只把当前发布任务软取消为 `CANCELLED + user_removed_from_preparation`,同时清除排期并写入事件;不删除原视频、裁剪成片、字幕、封面或另一个平台的记录。 +- “补充缺失任务”和全自动发布任务创建会识别用户主动移除标记,不会重新创建;执行记录提供“重新加入内容准备”,恢复前会检查同片段、同平台是否已有有效任务。 +- 新增 `POST /api/publish/jobs/{job_id}/dismiss` 与 `POST /api/publish/jobs/{job_id}/restore`,直接复用现有字段与事件表,没有新增数据库表或破坏性迁移。 +- 新增 5 项专项测试并更新浏览器级折叠断言;全量自动化测试 `308 passed`。Ruff、Python 编译和浏览器实页检查通过,页面控制台无错误,未对真实发布记录执行移出或投稿。 + +## 2026-07-19 发送中心旧批量发送入口与冗余代码清理 + +- 删除内容准备/排期共用底部批量栏中的旧“立即发送”;批量栏只保留当前平台账号设置、AI 文案补齐、设置排期和取消选择,避免继续进入已停用的批量直发方式。 +- 保留排期任务右侧的单条“立即发送 / 转换并发送 / 立即导出”;现有 `POST /api/publish/jobs/{job_id}/publish-now → PublishScheduler → Registry → Windows Worker` 成功链路没有改动。 +- 删除 `publish-center.js` 中旧批量逐条调用 `publish-now` 的监听器,并清理全局 `app.js` 内已没有模板引用的上一版发布配置、发送卡片、批量队列和投稿预览代码。 +- 删除只服务旧页面的 `POST /api/publish/jobs/{job_id}/send`、`POST /api/publish/send/start`、`PublishSendStart` 及对应批量兼容服务;排期、失败重试、人工复核、账号登录和当前单条发送接口全部保留。 +- 新增回归测试,固定检查旧按钮、旧前端监听器和旧路由已消失,同时当前设置排期、批量账号/AI 和单条 `publish-now` 入口仍存在。 +- 已验证:发送中心相关测试 `32 passed`,全量测试 `303 passed`,Ruff 与两份 JavaScript 语法检查通过;Docker `8001/publish` 实页勾选检查通过,Worker 正常且页面控制台无错误,未触发真实投稿。 + +## 2026-07-19 抖音立即发送上传判断与失败窗口修复 + +- 根因确认:抖音视频仍处于“文件解析中,0%”时,旧逻辑把常驻的“作品描述 / 发布设置”误判为上传完成,随后触发 `douyin_preview_not_ready` 并关闭 Chrome。 +- 新流程每秒读取上传进度、忙碌文案、失败文案和真实视频预览;只有真实预览连续两次稳定且没有未完成信号时才进入标题、正文、话题和封面步骤。 +- 标题、正文/话题、第一张有效 AI 推荐封面和“公开 / 好友可见 / 仅自己可见”全部增加写后校验;发布按钮改为同步精确点击,仅平台成功提示或作品管理中的准确标题可以写入 `PUBLISHED`。 +- 失败、验证或页面改版时保存诊断证据,Chrome 默认保留 10 分钟并显示暂停原因;实时阶段同步到发送中心,此类任务进入 `NEED_REVIEW`,不自动重复投稿。 +- 失败重试接口支持覆盖新任务的可见范围,原失败记录保持不变;新增 `.\scripts\start_niuma_studio.ps1`,统一启动 Worker、Docker 和发送中心,健康 Worker 会直接复用,未知端口占用不会被结束。 +- 增加真实 Chrome DOM 夹具,覆盖 0%、100% 无预览、真实预览、说明性“上传中”提示、上传失败和三种可见范围。 +- 真实页面继续发现并修复两处边界:页面底部“点击发布后,如作品还在上传中”不能算上传状态;Windows Worker 的 `caption / hashtags` 必须映射为完整正文和 `#话题`,不能退化为只填标题。 +- 修复历史任务保护:重复活动任务清理只处理 `DRAFT / WAITING / SCHEDULED / PUBLISHING`,不再把 `FAILED / NEED_REVIEW` 历史改成 `CANCELLED`;Worker 已结束但应用热重载时会立即按执行日志回收终态,不必等待过期时间。 +- 一键启动脚本现在同时确认 Scheduler `running=true`;页面正常但调度循环退出时,只重启本项目 `workflow` 服务。Worker 启动脚本增加显式 `-Restart`,仍只会重启已确认属于本项目的进程。 +- 2026-07-19 15:34 完成真实私密灰度:任务 `pub_92aeb980cd804fe3883e3539d33ad5fe` 依次通过上传稳定、标题、完整正文与 4 个话题、AI 推荐封面、可见范围和精确发布点击,抖音返回“发布成功”,本地状态为 `PUBLISHED / confirmed_success`。 +- 同账号作品管理页已找到准确标题,作品卡片明确显示锁和“私密”;机器校验 `title_found=true`、`private_found=true`。作品按约定未自动删除。 + +## 2026-07-18 发送中心平台隔离、登录同步与真实发布流程修复 + +- 发送中心新增统一“抖音 / B站”平台上下文;内容准备、排期计划、执行记录、账号管理、补充任务和批量操作只处理当前平台,切换平台会立即清空选择。 +- 发布任务的平台创建后不可修改;单条、批量目标更新和批量排期会拒绝跨平台或混合平台请求,并返回 HTTP 409 中文原因。补充任务接口增加明确 `platform` 参数。 +- 账号登录启动后立即显示“等待登录完成”,页面每 5 秒读取只读账号接口自动同步数据库;登录正常时显示“打开创作者中心 / 重新登录”,账号忙碌不会再被误判为登录失效。 +- 调度器扫描到旧 `SCHEDULED + opencli_publish` 时统一转为 `NEED_REVIEW`,错误码为 `legacy_schedule_requires_confirmation`,不上传、不静默跳过。逐条“转换并发送”会保留旧记录并创建新的同平台 `local_browser` 任务;重复点击不会重复创建替代任务。 +- Windows Playwright Publisher 已复用旧兼容流程中经过真实页面修正的 DOM 脚本:抖音恢复简介与话题、平台推荐封面、精确发布按钮和成功/风控判断;B站恢复草稿提示、上传完成、推荐封面、创作声明、分区、简介评分、默认标签和成功证据判断。 +- 没有平台成功提示或作品管理证据时绝不写入 `PUBLISHED`;验证码、风控和结果不确定继续进入人工复核,不绕过平台限制。 +- 本机 7 条过期抖音排期已安全转为 `NEED_REVIEW + legacy_schedule_requires_confirmation`,每条只记录 1 个复核事件,未创建替代任务;B站当前没有 `SCHEDULED / PUBLISHING` 任务。 +- 单条验收目标 `e2f91ef6e5eb`(“胡瓜老婆丁柔安搭捷运公车…”)已预设为“仅自己可见”,仍保持 `NEED_REVIEW`,没有创建替代任务或触发上传。 +- 未执行抖音或 B站最终投稿。全量测试 `290 passed`;Ruff、Python 编译、Node 语法、PowerShell 语法、Docker Compose 配置和本机 Chrome 页面验收均通过。 + +## 2026-07-18 Windows 发布 Worker 重复启动端口修复 + +- 修复 `start_publish_worker.ps1` 重复运行时偶发 `WinError 10048`:停止旧 Worker 后会等待 Windows 完全释放监听端口,再启动新进程。 +- Worker 改为只监听本机 `127.0.0.1`,避免 Chrome 临时把 `8765` 用作出站连接源端口时与 `0.0.0.0:8765` 冲突;Docker Desktop 仍可通过 `host.docker.internal:8765` 正常访问,同时减少局域网暴露。 +- 同一 PID 即使同时存在多个监听记录也只停止一次;如果端口被无关程序占用,仍保持安全退出,不会误关其他程序。 +- Worker 启动检查改为最多等待约 10 秒并同时检查新进程是否提前退出,避免固定等待 2 秒造成慢启动误判或旧进程健康检查误判。 +- 已验证:PowerShell 5.1 语法检查通过;连续两次运行启动脚本均成功;Worker 在 `127.0.0.1:8765` 返回健康状态;Docker 发送中心健康接口返回 `worker_available: true`;Ruff、Python 编译和 Docker Compose 配置检查通过;全量测试 `279 passed`。 + +## 2026-07-16 “立即发送”就绪检查与旧任务安全恢复 + +- 新增统一 `send_readiness` 计算:立即发送、单条排期、批量排期预览/保存和调度器领取复用同一组账号、登录态、平台、内容文件和 Worker 校验。 +- 旧 `opencli_publish` 任务不再被调度器领取;真正执行前会改用 `local_browser`。同平台恰好一个账号时自动选择,没有账号时引导创建,多个账号时要求手动选择。 +- 条件不足时 `POST /api/publish/jobs/{id}/publish-now` 返回 HTTP 409 和结构化阻塞原因,不会先把任务写入 `SCHEDULED`;Worker 离线时既不入队,也不会收到 `/v1/publish`。 +- 排期卡片现在显示“未登录 / 没有账号 / 需选择账号 / 内容不完整 / Worker 未连接 / 旧任务待转换”等具体原因;条件不足时主按钮改为“打开登录窗口”“新增账号”“选择账号”“完善内容”或“连接 Worker”。 +- 新增安全恢复接口 `POST /api/publish/jobs/{id}/repair-and-publish`:仅允许明确在上传前因 `opencli_fallback_disabled` 进入 `NEED_REVIEW` 的任务;原记录保持不变,创建新的 `local_browser` 替代任务。上传阶段已开始、已有远端结果或结果不确定的记录仍禁止自动重试。 +- 保持 OpenCLI 兼容开关关闭;`manual_export` 继续是显式本地导出,不要求账号或 Worker,也不会被标记为平台已发布。 +- 真实页面验证:Worker 正常;旧任务 `e2f91ef6e5eb` 显示账号“康熙来了”尚未登录和“打开登录窗口”,不再显示“立即发送”;页面控制台无 JavaScript 错误。未点击登录、未执行真实投稿。 +- 已验证:`ruff check` 通过;Python/JavaScript 语法检查通过;全量 `279 passed`,包含浏览器排期预览与导出测试,以及无账号、唯一/多个账号、未登录、平台不匹配、Worker 离线、旧任务安全恢复和不确定结果禁重试测试。 + +## 2026-07-16 发送中心平台月历与 Worker 连接修复 + +- 排期计划新增“抖音排期 / B站排期”双卡片切换,分别统计待排期和已排期任务;平台切换只影响当前清单与月历,不改任务数据。 +- 新增北京时间月历,按周一到周日展示当月 42 格日期和已排期任务;支持上月、下月、回到本月及点击日历任务定位清单。 +- 重做中窄屏排期任务卡片布局,避免账号、状态和操作按钮被挤成竖排;移动端继续使用单列卡片。 +- 调度器卡片新增 Worker 自动刷新、重新检测和一键启动说明;账号登录连接失败时返回可直接照做的中文处理步骤。 +- `start_publish_worker.ps1` 现在会在启动 Worker 后自动同步正在运行的 Docker Web 容器,使新生成的本地 Token 和 Worker 地址立即生效;不会删除 SQLite、任务视频或账号记录。 +- 本机验证 Worker 已监听 `0.0.0.0:8765`,Docker 已取得 Worker 地址和 Token,发送中心健康接口返回正常;账号“登录 / 重新登录”已成功打开独立 Chrome,未执行平台登录或最终投稿。 + +## 2026-07-15 v1.5.0 统一真实发布重构 + +- 新增 `app/services/publishers/` 分层和 Registry:`local_browser`、`manual_export`、显式 `opencli_publish` 兼容模式与抖音/B站平台 Publisher 职责分离;未知平台直接失败,不回退导出包。 +- 实现 `LocalBrowserPublisher`:任务校验、平台注册、账号登录预检、Windows Worker 调用、统一结果转换和脱敏写回。 +- 新增 `scripts/publish_host_worker.py` 和 `start_publish_worker.ps1`:使用系统 Chrome 持久化上下文、每个平台/账号独立目录、同账号串行锁、路径白名单、Docker `/workspace/tasks` 到 Windows `TASKS_DIR` 的映射、Bearer Token 与执行阶段日志。 +- 实现抖音和 B站 Playwright 投稿流程,包含视频/内容校验、登录态、上传、平台表单、封面、发布按钮和成功证据判断;不绕过二维码、短信、滑块、验证码或风控。 +- 立即发送和未来排期统一为 `SCHEDULED → PublishScheduler → Registry → Publisher`;SQLite 使用 `BEGIN IMMEDIATE` 和条件更新原子领取。 +- 新增上传前安全重试和上传后禁止自动重试规则;重启时读取 Worker 执行日志,未知旧 `PUBLISHING` 进入 `NEED_REVIEW`。 +- 手动重试会创建带 `retry_of_job_id` 的新任务;人工标记已发布只允许 `NEED_REVIEW` 且必须填写对应平台作品链接。 +- 数据库兼容新增 Worker、阶段、时区、复核、时间和重试字段;账号增加登录态字段;新增 `publish_job_events` 事件表。迁移不删除旧字段和历史数据。 +- 发送中心重构为“内容准备 / 排期计划 / 执行记录”,新增账号抽屉、精确排期预览确认、状态筛选和人工复核操作,保留 Jinja2 + 原生 JavaScript。 +- 默认配置改为 `APP_TIMEZONE=Asia/Shanghai`、`PUBLISH_DEFAULT_MODE=local_browser`、5 秒 Scheduler;浏览器 Profile、Worker 日志、storage state 和发布产物加入 Git 忽略。 +- 新增 37 项专项测试;全量结果 `272 passed, 0 failed, 0 skipped`。同时通过 `python -m compileall -q app scripts`、`node --check app/static/js/publish-center.js`、`ruff check app scripts tests` 和 `docker compose config --quiet`。 +- 未执行真实平台最终投稿:当前没有本轮用户明确授权的账号和测试素材;必须按 `NEXT_STEPS.md` 逐平台单条灰度并在最终点击前确认。 + +## 2026-07-11 真实发布排期闭环与发送中心重构 +- 分离目标平台与执行方式:`platform` 仅允许 `douyin` / `bilibili`,`publish_mode` 独立表示 opencli、发布包、API 或未实现的本地浏览器执行器。 +- 全自动流水线直接创建最终 `opencli_publish` 任务,不再依赖“刷新发送队列”生成第二套记录;刷新操作只补缺,并尊重已有 `manual_export` / `local_browser` 任务。 +- 新增统一 `execute_publish_job(job_id, force=False)` 入口;调度器使用条件更新原子抢占,opencli/API 成功进入 `PUBLISHED`,发布包成功进入 `EXPORTED`,失败进入 `FAILED`。 +- `NEED_REVIEW` 可以保存排期但不能执行;“立即发送”支持 `WAITING`、`SCHEDULED`、`FAILED`,会先把 UTC 排期改为当前时间。 +- 排期请求改为 `start_at_local + timezone + interval_minutes`;新增 `POST /api/publish/schedules/preview`,预览和保存复用同一计算函数,跨日按用户时区的每日窗口顺延。 +- 数据库新增 `schedule_timezone` 与有效任务唯一索引;迁移旧平台/执行方式或重复任务前自动创建 SQLite 备份,保留已发布历史,只取消较旧的未发布重复项并记录原因。 +- 调度器只恢复超过 `PUBLISH_JOB_STALE_MINUTES` 且没有成功结果的陈旧 `PUBLISHING`;阻塞执行使用工作线程;新增 `GET /api/publish/scheduler/health`。 +- 发送中心改为“待安排 / 已排期 / 发送记录”三页签,使用紧凑列表、单一选择语义、底部批量栏和右侧排期抽屉;排期、取消和编辑均局部更新,不再整页刷新。 +- 新增统一 `apiFetch`,运行时自动携带本地管理员 token;真实 token 不写入静态 JavaScript。 +- 已验证:指定 36 项发布测试通过;全量 235 项 pytest 通过;Node 检查通过;真实 Chrome 浏览器集成测试与隔离页面可视化检查通过。 + ## 2026-06-25 全自动切片修复与发送中心批量排期 - 修复 AI 分析完成后候选片段“先写入、随后又被全部删除”的事务顺序错误;候选片段现在在单个 SQLite 事务内替换,任一新片段写入失败都会自动回滚并保留旧结果。 - 修复历史全自动任务卡在 AI 分析后的问题:任务可从最近一次 AI 分析历史恢复候选片段,并从自动选片阶段继续,不需要重新消耗一次 AI 分析。 @@ -540,7 +725,7 @@ - 已把 `/publish` 从“发布中心”改为“发送中心”,页面不再展示抖音 / B站开放平台 API 配置、Client Key、Access Token、OAuth 和账号表单。 - 新页面以待发送队列为主:从已完成切片读取切好的原片、封面帧、AI 标题、AI 话题和平台状态,默认生成抖音 + B站双平台队列。 -- 新增发送队列接口:`POST /api/publish/queue/refresh` 可从已完成切片生成 opencli 发送任务;`POST /api/publish/send/start` 可按队列逐条发送;`POST /api/publish/jobs/{job_id}/send` 可发送单条任务。 +- 新增发送队列接口:`POST /api/publish/queue/refresh` 可从已完成切片生成 opencli 发送任务;当时使用的 `POST /api/publish/send/start` 和 `POST /api/publish/jobs/{job_id}/send` 已在 2026-07-19 随旧页面清理移除,当前统一使用单条 `publish-now` 与排期接口。 - 封面逻辑改为“从视频中选一帧”:新增 `POST /api/publish/covers/frames` 生成多张候选帧,页面可手动切换并保存,不再默认叠加标题大字。 - AI 元数据补齐已接入:优先使用切片标题,话题和简介可由 AI 根据标题、摘要、推荐理由和转写片段生成;缺失时页面可一键重新生成。 - 自动发送改为 opencli 网页自动化:抖音打开 `creator.douyin.com` 投稿页,B站打开创作中心投稿页;发送批次一次只执行一条,避免平台窗口互相抢焦点。 @@ -697,3 +882,42 @@ - 抖音发送链路新增发布结果确认步骤:点击发布后必须等到“发布成功 / 已提交审核 / 审核中 / 投稿成功”等平台提示,或在作品管理看到对应标题,才会把本地任务标记为已发布。 - 如果页面出现验证码、登录失效、发布失败、风控等提示,会返回 `douyin_publish_blocked`;如果超时没有成功信号,会返回 `douyin_publish_not_confirmed`。 - 抖音简介填写改为优先定位“作品描述 / 简介 / 描述”附近的编辑框,避开标题框,并在写入后检查重复内容,减少平台简介区出现重影或重复粘贴。 + +## 2026-07-27 全自动 AI 封面秒数与一键补齐 + +- 修复全自动流水线创建 Windows Chrome 发布任务时没有生成本地封面的问题。根因是 AI 候选片段结构没有封面秒数,而且全自动发布任务直接把 `cover_file_path` 留空。 +- AI 候选片段新增 `cover_time_seconds`,含义为“相对于裁剪后短视频开头的封面秒数”。程序会在所有 Prompt 后追加字段约束,不覆盖用户已保存的自定义 Prompt;缺失、负数或超出片段时长时统一使用片段中点。 +- SQLite 的 `clip_candidates` 新增可空 `cover_time_seconds` 字段,旧任务保持空值,不删除或改写历史候选片段。 +- 全自动元数据阶段会按 AI 秒数调用 FFmpeg 生成 JPG;同一切片的抖音和 B站任务复用同一张封面。截帧失败会明确中止元数据步骤,不再静默创建缺封面的新发布任务。 +- 发送中心新增“一键补充所有封面”,覆盖全部平台中尚未发布且封面为空的任务;按切片分组生成,旧任务使用 50% 中点,已有封面、发布中、已发布、失败和取消记录均不会被覆盖。 +- 批量补充完成后页面原地更新封面预览、封面路径和内容完整状态,不刷新页面,因此不会丢失尚未保存的标题或简介。 +- 新增 AI 兼容、数据库迁移、全自动双平台复用、批量补充与浏览器交互测试。 + +## 2026-07-27 跨午夜排期与新任务默认值调整 + +- 修复发送中心把 `06:00 → 00:00` 判定为非法时段的问题;每日结束早于开始现在表示跨午夜,开始和结束相同表示全天。 +- 批量排期的第 1 条严格使用用户选择的北京时间,不再被每日时段静默改写;后续任务按间隔计算,落入禁发空档时顺延到下一个每日开始时间。 +- 排期抽屉把“起始时间”改为“第 1 条发布时间”,补充跨午夜说明,并在抽屉内显示预览/保存的处理中、成功和失败信息;参数变化后旧预览立即失效。 +- 新任务的单条切片最长默认值由 5 分钟改为 10 分钟,候选片段数量由 5 条改为 12 条;JSON、上传表单、页面和运行时兜底保持一致。 +- 现有任务数据没有批量更新;历史任务已保存的 5 分钟、5 条等自定义值继续保留。 +- 新增跨午夜、首条精确时间、全天窗口、新建默认值、上传默认值、历史值保护及 10 条浏览器排期测试。 +- 已验证专项测试 28 项、完整测试 `333 passed`;Python 编译、两个 JavaScript 文件语法检查和 Ruff 均通过。 + +## 2026-07-27 取消发送返回内容准备 + +- 修复排期任务点击“取消”后从发送中心消失的问题。根因是普通取消被写成终止状态 `CANCELLED`,而“内容准备”只展示仍可编辑和排期的任务。 +- 普通“取消发送”现在把任务恢复为 `WAITING`,清空已选排期、调度占用和本次执行错误,但保留视频、标题、简介、话题、封面、平台和账号等准备内容。 +- 排期计划中的按钮改为“取消发送并返回准备”;操作成功后页面会自动切回“内容准备”、展开对应任务组并滚动到返回的任务,不需要刷新页面。 +- “移出内容准备”仍是用户主动隐藏记录,“跳过任务”仍是终止当前任务;二者继续使用 `CANCELLED`,不会被普通取消逻辑误恢复。 +- 兼容历史数据:数据库初始化时,只把旧版错误标记为“用户取消任务”的最后一条记录安全恢复到 `WAITING`;若同一切片和平台已经有活跃替代任务,则不会制造重复任务。 +- 新增状态机、历史数据恢复和浏览器交互回归测试;专项测试 `19 passed`、完整测试 `336 passed`,Python 编译、两个 JavaScript 文件语法检查、Ruff 和差异检查均通过。 + +## 2026-08-02 E 盘统一视频存储与任务永久删除 + +- 新增 `UPLOAD_TEMP_DIR`,并在应用启动时把当前进程的 `TEMP`、`TMP` 和 Python 临时目录指向 E 盘;大于 1 MB 的浏览器上传不再先写入 C 盘系统临时目录。 +- 手动发布包默认目录从项目 `outputs/publish_packages` 调整到 `E:\直播间切片工作流存储\_发布包`;任务原片、音频、切片、字幕和封面继续统一使用 `TASKS_DIR`。 +- 任务列表“移入回收站”改为“永久删除”:只删除系统托管目录,外部 NAS / E 盘原片保留,数据库历史隐藏保留;运行中的处理和发布任务禁止删除。 +- 新增 `scripts/purge_deleted_task_media.py`,默认只预览;`--apply` 会先创建 SQLite 元数据备份,再清理已隐藏任务的 E 盘目录、发布包和精确匹配的旧版 C 盘任务目录。 +- 新增大文件 multipart 临时目录、失败上传回滚、外部原片保护、路径越界、运行中拦截、删除失败回滚、幂等删除和旧任务清理测试。 +- 已对真实数据先预演再执行清理:15 条已隐藏任务共删除 16 个托管目录,释放 `6,213,311,934` 字节(约 6.21 GB);4 条有效任务目录清理前后均完整,外部测试原片保留,清理后再次预演为 0 个残留目录。 +- 清理前 SQLite 元数据备份完整性检查为 `ok`;最终完整测试 `395 passed`,Python 编译、Ruff、JavaScript 语法和差异检查均通过。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 89f56d4..0a40b2d 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,156 @@ # Next Steps +## 2026-08-02 续接最晚排期使用与检查 + +1. 打开 `http://127.0.0.1:8001/publish` 并按 `Ctrl + F5`,进入“内容准备”或“排期计划”,勾选同一平台需要安排的内容后点击“设置排期”。 +2. 抽屉中的每日开始、结束时间应默认显示 `07:00` 和 `00:00`;这是新默认值,不会改动以前已经保存的排期,也可以为本次排期手动修改。 +3. 先选择发布间隔,再点击“接在当前平台最晚排期后”。页面应显示“当前最晚”和“本次第 1 条”时间;例如当前最晚 19:00、间隔 3 小时,本次应为 22:00。 +4. 如果加上间隔后落在 00:00 至 07:00 的不可发布区间,系统会自动顺延到 07:00;正好得到 00:00 时仍允许排期。 +5. 当前平台没有其他未来排期时,页面会提示手动选择,不会覆盖第 1 条时间。调整时间、间隔或每日窗口后,必须重新点击“预览排期”再确认应用。 +6. 抖音和 B站分别续排;切换平台后需重新勾选。验证此功能只需要预览,不必点击立即发送,也不会绕过账号登录、验证码或平台风控。 + +## 2026-08-02 发送中心封面计数修复后的检查方法 + +1. 打开 `http://127.0.0.1:8001/publish` 并刷新页面;当前抖音没有缺失封面时,应显示禁用的“补充抖音缺失封面”,不再出现错误的“(8)”。 +2. 切换到 B站后,按钮会按 B站自身数据重新显示缺失数量;现场数据目前为 8 条,这 8 条确实没有记录封面路径,并非抖音任务缺失。 +3. 只有确认当前平台确实需要自动补封面时才点击按钮。点击后只会处理当前平台,不会跨平台修改另一侧任务,也不会自动排期或投稿。 +4. 本次修复只校正统计和批量处理边界,没有替 B站自动生成这 8 张封面;如需补齐,可先在 B站内容准备区核对对应任务,再手动点击 B站封面按钮。 +5. 真实投稿前仍需逐条核对账号、标题、简介、封面、可见范围和排期;二维码、验证码、平台风控及人工确认边界保持不变。 + +## 2026-08-01 康熙笑点优先 V2 使用与验收 + +1. 新建康熙任务时,把“选片模式”设为“综艺笑点优先”,候选池保持 12 条、最终启用目标保持 5 条;质量不足时页面出现少于 5 条属于正常结果。 +2. 旧任务可以在任务详情的 AI 分析区切换模式并保存。现有自定义 Prompt 会继续保留;如需使用内置规则,可选择 4 号“康熙笑点优先 V2”。 +3. AI 分析完成后,在片段审核页优先检查:是否为 A 级、是否 60–150 秒、是否明确包含铺垫/笑点或反转/补刀或现场反应/收尾,以及是否与相邻候选重复。 +4. B 级候选默认关闭,只供人工复核;C 级不会显示。认为 AI 判断不合口味时,点击“不好笑、太碎、铺垫不足、重复、拖沓”之一;确实值得发布则点击“值得发”。 +5. 第一次真实素材验收建议直接调用 AI 分析而不要启动全自动发布:分别记录旧版 12 条与 V2 候选的数量、时长、重复话题和至少 4 条“值得发布”人工判断。确认后再生成切片。 +6. 音频证据是轻量代理信号;音频不存在或 FFmpeg 读取失败时会显示降级原因,但文字评审仍会继续。它不代表精确笑声识别或说话人识别。 +7. 本轮测试没有调用抖音/B站投稿。真实发布前仍需人工核对账号、标题、封面、可见范围和排期,验证码与平台风控继续由人工处理。 + +## 2026-07-29 任务与发送中心关联后的检查方法 + +1. 打开任务列表,目标任务的“后续工作流”应显示 `已关联 24/24`;“字幕”会进入该任务专属地址,“发送中心”会进入该任务的内容准备分组。 +2. 当前任务 `b84227be2b01` 已经同步完成:12 条新切片各有抖音和 B站内容,全部为 `WAITING` 且没有排期,不会因为本次同步自动投稿。 +3. 重新切片后如果页面显示“待同步”,点击“同步发送中心”即可补齐;重复点击不会创建重复任务。 +4. 如果某条内容曾由用户主动“移出内容准备”,全局补充仍尊重移出标记;只有任务页或字幕页的明确任务级同步会恢复它,恢复后需要重新确认账号和排期。 +5. 字幕完成后从字幕工作台同步,会把尚未排期的内容改为带字幕视频;已经排期的内容必须先取消发送并返回内容准备,系统不会静默替换视频。 +6. 发送中心内容准备和排期计划只展示当前激活切片;旧版取消、已发布、失败和待复核证据继续在执行记录中保留。 +7. 真实投稿前仍要逐条确认平台、账号、可见范围、标题、简介、封面和排期;二维码、短信、验证码、滑块和风控继续由人工处理。 + +## 2026-07-29 排期故障恢复后的检查 + +1. 首条已确认投稿成功的 7 月 28 日 09:00 任务已经补记为 `PUBLISHED`,不得再次点击立即发送;平台未返回视频 ID 或链接不代表可以重发。 +2. 剩余 18 条排期已按原顺序移动到北京时间每日 `09:00 / 11:00 / 13:00 / 15:00 / 17:00 / 19:00 / 21:00`,实际范围为 7 月 30 日 09:00 至 8 月 1 日 15:00;每条事件均保留原时间、新时间和故障恢复原因。 +3. 打开 `http://127.0.0.1:8001/publish`,健康卡应显示调度器“正常”、Windows Worker“正常”;如果出现“异常重试中”,等待下一轮自动恢复并把安全化原因交给开发助手检查。 +4. 7 月 30 日 09:00 首个恢复时段后,只接受 `PUBLISHED`、`NEED_REVIEW` 或明确 `FAILED`;如果仍为 `PUBLISHING`,先查 Worker 执行日志,禁止直接重复投稿。 +5. 二维码、短信、验证码、滑块、平台风控和人工确认仍必须人工处理,本次修复不会绕过这些边界。 +6. 当前工作区含大量既有未提交修改,本次不会执行 `git add .`、提交、推送或创建 PR;整理工作区后再把本次修复单独提交。 + +## SQLite 备份维护 + +1. 已完成首次清理并释放 121.601 GiB;接下来连续观察至少 14 天,确认 `data/backups` 每天最多保留一份有效迁移快照,目录体积不再异常增长。 +2. 需要人工复核时,在项目根目录执行 `.venv\Scripts\python.exe scripts\cleanup_database_backups.py`;该命令只预演,不会删除文件。 +3. 只有确认预演清单后才使用 `.venv\Scripts\python.exe scripts\cleanup_database_backups.py --apply`;脚本不会触碰 `data/workflow.sqlite3`、任务素材或其他文件。 +4. 当前工作区还有发布功能的既有未提交修改;待这些修改整理完成后,再单独提交本次备份防护代码并创建 PR。 + +## 2026-07-28 执行记录日历与安全删除检查方法 + +1. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 后进入“执行记录”;顶部应出现当前平台的 42 格执行月历。 +2. 有排期的任务按计划日期归档;立即发送但没有排期的旧记录按开始时间归档。点击某一天后,下方列表只显示当天记录,点击“查看全部日期”恢复完整列表。 +3. 失败记录应显示“立即发送”和可见范围,不再显示“重新加入内容准备”。只有先前从内容准备主动移出的记录才有“重新加入内容准备”。 +4. “立即发送”会保留原失败记录并创建新的调度任务;如果账号未登录、视频文件缺失或 Worker 未连接,页面会先提示修复,不会直接投稿。 +5. 已发布、失败、已导出和已取消记录可以单条或批量“删除记录”。确认框会说明:只从正常列表和日历隐藏,不删除视频、执行明细、数据库历史或平台作品。 +6. 点击“已删除记录”可查看并恢复安全删除的记录;正在发送、待执行和需人工复核记录不能删除。 +7. 真实投稿仍需人工处理二维码、短信、验证码、滑块和风控;验证日历或删除功能时不需要点击真实发送。 +8. 本次出现 500 的任务已经成功创建过一条重试记录,并因“引战夸张”风险标记进入“需人工复核”;不要再次重发,先打开创作者中心确认平台没有作品,再按实际结果选择“标记已发布”或“标记失败”。 + +## 2026-07-23 Docker 自动联动后的日常检查 + +1. 日常只需打开 Docker Desktop 并运行 `niuma-studio`,不再输入 Worker 或一键启动命令。 +2. 打开 `http://127.0.0.1:8001/publish`,等待“Windows Worker”显示“正常”;刚启动时可点击“重新检测”。 +3. 在 Docker Desktop 中停止项目后,Worker 会在 15 秒后关闭;重新运行项目会再次自动启动。 +4. 如果持续未连接,先在 Docker Desktop 中停止并重新运行本项目;仍未恢复时交给开发助手检查观察器和 Worker 日志。 +5. 真实投稿仍必须人工完成登录、二维码、短信、验证码和风控确认;自动联动只负责准备发送环境,不会绕过平台验证。 + +## 2026-07-22 任务归类与安全移出后的检查方法 + +1. 打开 `http://127.0.0.1:8001/publish` 并按 `Ctrl + F5`,确认“内容准备”最外层按任务名称分组,组头能看到原视频文件名、创建时间和当前平台待准备数量。 +2. 最新任务应默认展开,其余任务默认折叠;点击“展开 / 收起”只改变当前任务组,不影响标题、简介、账号或排期数据。 +3. 分别切换抖音和 B站,确认分组数量会跟随当前平台变化;没有当前平台内容的任务组应隐藏,切换平台仍会清空已勾选任务。 +4. 如需验证移出功能,只选择一条确认不再发送的测试内容,点击“移出内容准备”并阅读确认提示;原视频、裁剪文件、字幕和另一个平台内容都应保留。 +5. 被移出的记录应进入“执行记录”并显示“重新加入内容准备”;恢复后状态为等待处理,不会自动恢复原排期或触发真实投稿。 +6. 点击“补充缺失任务”或重跑全自动流程时,主动移除的当前平台内容不应重新出现;如需重新使用,必须从执行记录明确恢复。 + +## 2026-07-19 发送中心旧入口清理后的检查方法 + +1. 打开 `http://127.0.0.1:8001/publish` 并按 `Ctrl + F5`,在“内容准备”勾选一条任务;底部批量栏不再出现“立即发送”。 +2. 底部批量栏仍应显示当前平台、账号选择、“批量设置当前平台账号”“批量 AI 补齐”“设置排期”和“取消选择”。 +3. 点击“设置排期”,确认仍能预览并保存北京时间排期;这一步不会触发真实投稿。 +4. 如需验证已经成功的真实发送步骤,进入“排期计划”,只使用任务右侧的单条“立即发送 / 转换并发送”,并继续先选“仅自己可见”做灰度。 +5. 若出现二维码、短信、验证码、滑块、风控或结果不确定,任务应进入 `NEED_REVIEW`,先人工核对,不能连续重试。 + +## 2026-07-19 抖音立即发送真实验收结果与下一步 + +1. “电影院递纸巾”真实私密灰度已经完成,成功任务为 `pub_92aeb980cd804fe3883e3539d33ad5fe`,状态是 `PUBLISHED / confirmed_success`。 +2. 抖音作品管理页已找到准确标题,卡片明确显示锁和“私密”;作品没有自动删除,请用户自行决定保留或删除。 +3. 原失败记录及两次点击前停止的诊断记录均保留,用于追溯;结果不确定任务 `pub_1138f3bb8d07445ea42004493e61de66` 仍未触碰。 +4. 当前日常启动方式已经更新为 Docker 自动联动;本段中的真实灰度结果仍保留,启动操作以文档顶部 2026-07-23 说明为准。 +5. 后续正式发送仍先用“仅自己可见”做单条灰度;遇到二维码、短信、验证码、滑块、风控或结果不确定时,窗口保留 10 分钟且任务进入 `NEED_REVIEW`,人工核对前不得重试。 + +## 2026-07-18 发送中心修复后的单条抖音验收 + +1. 打开 `http://127.0.0.1:8001/publish` 并按 `Ctrl + F5`,确认顶部“当前只处理抖音”,内容准备、排期、执行记录和账号抽屉均只显示抖音数据。 +2. 打开账号管理;“康熙来了”如果数据库登录正常,应自动显示“正常”和“打开创作者中心”,不需要反复刷新或点击检查。登录窗口打开后会先显示“等待登录完成”,完成登录后自动变回“正常”。 +3. 7 条旧排期应显示在抖音“执行记录”的“需人工复核”中,错误码为 `legacy_schedule_requires_confirmation`;它们没有被上传,也不会自动补发。 +4. 真实验收只选择标题包含“胡瓜老婆丁柔安搭捷运公车”的抖音任务,先在内容准备中把可见范围设为“仅自己可见”并保存;不要选择 B站任务。 +5. 核对确认框明确写“抖音”、账号为“康熙来了”、标题和可见范围正确后,再由用户本人逐条点击“转换并发送”。旧记录会保留,新任务才会进入 Windows Chrome Worker。 +6. 如果出现二维码、短信、验证码、滑块、风控或结果不确定,停止自动流程并保留 `NEED_REVIEW`;先到抖音创作者中心人工核对,不能直接重复发送。 +7. B站必须切换到“B站”后单独操作;名为“抖音主账号”的旧记录实际属于 B站,只会显示在 B站区域,本次不要领取或发送。 + +## 2026-07-18 Worker 端口修复后的操作顺序 + +1. 在项目目录运行 `.\scripts\start_publish_worker.ps1`;重复运行时,脚本会安全停止旧 Worker、等待 `8765` 端口释放,再启动新 Worker。 +2. 成功时应看到“发布 Worker 已启动”和“Docker 已同步完成”,不应再出现 `WinError 10048`。 +3. 打开 `http://127.0.0.1:8001/publish`,点击“重新检测”,确认“Windows Worker”显示“正常”。 +4. 后续如果提示端口被“其他程序”占用,请保留完整报错;脚本不会自动关闭无法确认身份的程序。 + +## 2026-07-16 立即发送修复后的操作顺序 + +1. 打开 `http://127.0.0.1:8001/publish`,进入“执行记录”。本次旧任务会显示“账号‘康熙来了’尚未登录”,不会再显示可直接执行的“立即发送”。 +2. 点击“打开登录窗口”,在弹出的账号专属 Chrome 中人工完成抖音登录;二维码、短信、验证码或平台风控必须由用户本人处理。 +3. 登录完成后回到“账号管理”,点击该账号的“检查状态”。只有状态变成“正常”,旧任务才会显示“修复并发送”。 +4. 点击“修复并发送”时,系统会保留原 `NEED_REVIEW` 历史,另建一条 Windows Chrome 投稿任务。不要手动删除原记录,它用于避免重复投稿和追溯失败原因。 +5. 先用单条、确认可投稿的短视频做灰度;抖音建议设为“仅自己可见”。状态应按 `SCHEDULED → PUBLISHING → PUBLISHED` 推进,并在执行记录出现平台链接。 +6. B站目前没有账号,因此 B站任务会显示“新增账号”;需要先创建 B站账号并完成独立登录,不能复用抖音账号。 +7. 如果 Worker 显示未连接,页面主按钮会改为“连接 Worker”。在项目目录运行 `.\scripts\start_publish_worker.ps1`,回到页面点击“重新检测”,任务状态不会因这次失败而改变。 + +## 2026-07-16 平台月历与 Worker 修复后的下一步 + +1. Windows Worker 与 Docker 当前已经连接;平时如果重启电脑,只需在项目目录运行 `.\scripts\start_publish_worker.ps1`,脚本会自动同步正在运行的 Docker 页面,不再需要额外重启命令。 +2. 打开 `http://127.0.0.1:8001/publish`,确认调度器卡片显示“Windows Worker:正常”;如果未连接,按卡片黄色区域中的命令启动,再点击“重新检测”。 +3. 进入“排期计划”,分别点击“抖音排期”和“B站排期”,确认月历与下方任务清单同步切换;点击月历中的任务会定位到对应清单卡片。 +4. 当前只验证了账号登录窗口可以正常打开,尚未替用户完成平台登录。下一步由用户在专属 Chrome 中人工完成二维码、短信或平台验证,再回到账号管理点击“检查状态”。 +5. 真实投稿仍先做单条灰度:抖音建议选择“仅自己可见”;B站测试稿可能进入审核或展示,最终投稿前必须人工确认。 + +## 2026-07-15 v1.5.0 真实单条灰度发布(下一步优先做) + +1. 在项目根目录运行 `\.\scripts\start_publish_worker.ps1`。成功时会显示 Worker 地址和健康检查结果;脚本生成的 Token 只写本地 `.env`,不会提交 Git。 +2. 如果 Docker 页面已经运行,脚本会自动同步连接配置;只有首次安装或镜像依赖变化时才需要运行 `docker compose up --build`。 +3. 打开 `http://127.0.0.1:8001/publish`,进入“内容准备 → 账号管理”,为抖音和 B站分别新增账号。 +4. 点击“打开登录窗口”,在弹出的专属 Chrome 中人工完成二维码、短信或平台验证;回到页面点击“检查登录”,应显示“正常”。 +5. 先选一条用户确认可投稿的短测试片。抖音建议设置“仅自己可见”;B站稿件即使测试也可能进入审核或公开展示,点击最终投稿前必须再次确认。 +6. 核对标题、正文/简介、话题/标签、封面和账号;B站还要核对分区、原创/转载,转载必须填来源。 +7. 点击“立即发送”,预期状态为 `WAITING → SCHEDULED → PUBLISHING → PUBLISHED`;平台链接应出现在执行记录。 +8. 再用另一条测试片预览未来北京时间排期,确认逐条时间后应用;重启应用,任务仍应保留并在到点后走同一个 Publisher。 +9. 如果出现验证码、登录失效、风控或结果不确定,正确状态是 `NEED_REVIEW`。先打开平台创作者中心核对,确认未发布后才能标记失败并创建重试任务。 +10. `manual_export` 只能显式选择,正确结果是 `EXPORTED`;它不会变成 `PUBLISHED`,真实发送失败也不会自动导出发布包。 + +## 后续风险收敛 +1. 用抖音、B站各做一次单条灰度,记录平台页面选择器因改版产生的失败点。 +2. 继续观察调度器健康状态的 5 秒前端刷新是否稳定;当前已提供异常提示、启动命令和手动重新检测。 +3. 观察 Windows Worker 的 `execution_phase` 和本地日志,确认上传前连接重试与上传后禁止重试符合真实网络环境。 +4. 观察历史数据库迁移备份与重复任务取消结果,确认后再考虑更严格的数据清理工具;不要删除已发布记录。 + ## 2026-06-25 全自动流程修复后怎么测试 1. 打开 `http://127.0.0.1:8001/tasks/new`,按 `Ctrl + F5` 强制刷新一次。 2. 选择视频,填写“单条切片最长”和“候选片段数量”,勾选“新建后自动跑完整流水线”。 @@ -15,18 +166,19 @@ 1. 在项目目录启动后台:`.\.venv\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8001`。 2. 打开 `http://127.0.0.1:8001/tasks/new`,新建任务并勾选“新建后自动跑完整流水线”。 3. 等任务自动完成准备视频、转写/读取文本、AI 分析、自动选片、原片切割、生成标题文案和创建待发送任务。 -4. 到发送中心批量设置发布时间;到点后调度器会自动扫描 `SCHEDULED` 任务并执行 `manual_export`,也可以手动运行一次:`.\.venv\Scripts\python.exe -m app.publish_scheduler run-once`。 +4. 该段是 v1.4.0 历史测试记录。当时默认 `manual_export`;v1.5.0 当前默认 `local_browser`,到点后会调用 Windows Worker 真实投稿。手动扫描命令仍是 `.\.venv\Scripts\python.exe -m app.publish_scheduler run-once`。 5. 发布包默认在 `outputs/publish_packages/{task_id}/{clip_id}/`,应能看到 `clip.mp4`、`title.txt`、`caption.txt`、`hashtags.txt`、`cover_text.txt`、`publish_plan.json`、`metadata.json`。 6. 打开 `/publish`,在发布记录里查看 `SCHEDULED`、`PUBLISHING`、`PUBLISHED`、`FAILED`、`NEED_REVIEW` 等状态;也可以访问 `/api/publish/queue/snapshot` 查看队列快照。 7. 失败任务可以调用 `POST /api/publish/jobs/{job_id}/retry` 重试;立即发布可以调用 `POST /api/publish/jobs/{job_id}/publish-now`;取消和跳过分别调用 `/cancel`、`/skip`。 8. `NEED_REVIEW` 表示任务带风险标记或需要人工复核,不会自动发布;复核后调用 `POST /api/publish/jobs/{job_id}/approve-review` 可回到 `SCHEDULED`。 9. 本轮仍然跳过加字幕、烧录字幕和字幕叠加,自动发布使用 `05_clips/` 的原片切割结果。 -## v1.4.0 后续真实平台发布还差什么 -1. 抖音 / B站真实发布器需要明确账号授权方式、上传接口、发布接口、审核回调或查询方式。 -2. 需要确定 token、cookie、账号授权等敏感信息只走本地 `.env` 或本机安全存储,不写入代码和 Git。 -3. 浏览器自动化版需要选择 Playwright、Selenium 或继续 opencli,并保留验证码、登录失效、风控和人工确认边界。 -4. 真实平台发布前需要加入单条灰度测试、失败重试上限、重复发布确认和人工撤销机制。 +## v1.5.0 仍需真实环境确认 + +1. 自动测试已经覆盖 Registry、状态机、时间、Worker 客户端和页面流程,但没有使用真实账号点击平台最终投稿。 +2. 抖音/B站平台页面会持续改版,真实灰度时若选择器变化,必须按错误阶段修正,不能伪造成功。 +3. 作品审核、限流、账号权限与平台规则属于外部状态;系统只在取得成功证据时写 `PUBLISHED`。 +4. opencli 仅作为显式兼容模式保留,默认关闭,不再作为立即发送专用链路。 ## 2026-06-23 v1.3.0 全自动流水线怎么测试 1. 启动本地后台后打开 `http://127.0.0.1:8001/tasks/new`。 @@ -673,3 +825,43 @@ 4. 观察简介框是否只有一份正文和话题,AI 推荐封面是否真正设为封面。 5. 发布后等待页面出现成功 / 审核中提示,再去作品管理确认是否有对应标题。 - 下一步:如果失败,请把新的红色错误完整发回来;这次错误里会明确区分“没确认发布成功”和“平台验证码 / 风控 / 登录问题”。 + +## 2026-07-27 本次功能:全自动封面与一键补齐 + +- 新建的全自动任务会让 AI 为每条候选短视频返回 `cover_time_seconds`,完成切片后自动从对应秒数生成独立 JPG 封面,并同时写入抖音和 B站发布任务。 +- 旧任务没有 AI 封面秒数时,发送中心的“一键补充所有封面”会从短视频中间 50% 位置截帧;同一切片的双平台任务只生成一次。 +- 按钮只补尚未发布且封面为空的任务,不覆盖人工已经生成或更换的封面,也不改 MP4 视频内容。 +- 下一步手动验收: + 1. 重启本地后台后打开 `http://127.0.0.1:8002/publish`,按 `Ctrl + F5` 强制刷新。 + 2. 在“内容准备”标题右侧确认能看到“一键补充所有封面(数量)”。 + 3. 点击一次并等待完成提示;当前历史数据预计补齐 29 条发布任务,对应约 20 张不同切片封面。 + 4. 抽查同一切片的抖音和 B站卡片,确认两边都显示封面;已经有封面的卡片应保持不变。 + 5. 新建一条全自动测试任务,完成后直接进入发送中心,确认不再显示“缺少:封面”。 +- 如果个别切片显示失败,请保留页面完整错误信息;批量流程会继续处理其他切片,不需要逐条重新点击。 + +## 2026-07-27 跨午夜排期与新任务默认值验收 + +1. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新,再进入“排期计划”。 +2. 选择 10 条同平台任务,点击“设置排期”,把第 1 条设为次日 06:00、间隔设为 3 小时、每日时段设为 06:00 → 00:00。 +3. 点击“预览排期”,应依次看到当天 06、09、12、15、18、21 点,次日 00、06、09、12 点;`00:00` 不应再报错。 +4. 修改任一时间后,“确认应用具体时间”应立即禁用;重新预览成功后才能确认,避免保存旧时间。 +5. 打开“新建任务”,确认“单条切片最长”为 10 分钟、“候选片段数量”为 12 条;这两个值只影响新任务,不会修改历史任务。 +6. 本次验收只需检查预览和新建表单,不需要点击“立即发送”,不会触发抖音或 B站真实投稿。 + +## 2026-07-27 取消发送返回内容准备验收 + +1. 重启本地后台后打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新。 +2. 在“排期计划”选择一条测试任务,点击“取消发送并返回准备”并确认。 +3. 页面应自动切换到“内容准备”,对应视频、标题、简介、话题和封面都应保留;排期时间应变为“未排期”。 +4. 之前由旧版“取消任务”产生、错误信息为“用户取消任务”的记录,会在数据库初始化时自动恢复;如果同一切片和平台已经存在新的活跃任务,则保留新的任务且不重复恢复。 +5. 继续确认“移出内容准备”仍会隐藏任务,并可在执行记录中恢复;“跳过任务”仍保持终止状态,不会自动回到准备区。 +6. 本次验收不要点击“立即发送”,不会触发抖音或 B站真实投稿。 + +## 2026-08-02 E 盘存储与永久删除验收 + +1. 重启本地后台,打开“系统状态”,确认“视频临时与导出目录”显示“E 盘就绪”,路径分别是 `_临时上传` 和 `_发布包`。 +2. 新建一个测试任务并上传视频,确认任务原片和后续切片只出现在 `E:\直播间切片工作流存储\任务名` 下;C 盘项目目录不应出现新的生产视频副本。 +3. 在任务列表点击“永久删除”,确认提示明确说明无法恢复且外部原片保留;删除后对应 E 盘任务目录和发布包应消失。 +4. 对 NAS 或 E 盘其他目录的引用任务执行删除时,只删除任务生成物,外部唯一原片必须仍然存在。 +5. 转写、切片或真实发布进行中时,删除应被拒绝并显示原因;等待任务结束后再删除。 +6. `scripts/purge_deleted_task_media.py` 默认只输出清单,只有显式带 `--apply` 才会永久清理,并在 `data/backups` 留下 SQLite 元数据备份。 diff --git a/README.md b/README.md index af69aab..312434b 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,61 @@ 牛马片场是一个运行在 Windows 本地的 AI 高光生产后台,用来把直播录像、综艺访谈、长视频素材整理成可转写、可分析、可审核、可切割、可加字幕、可进入发送中心的短视频生产任务。 -当前版本:`1.4.0`。 +当前版本:`1.5.0`。 + +v1.5.0 将抖音和 B站的立即发送、定时发送统一到 `PublishScheduler → Registry → LocalBrowserPublisher → Windows Worker → 平台 Publisher`。FastAPI 或 Docker 负责排期,Windows Worker 使用系统 Chrome 的独立账号目录执行真实投稿。只有读取到平台作品 ID、稿件 ID 或明确成功链接才进入 `PUBLISHED`;登录失效、验证码、风控和结果不确定进入 `NEED_REVIEW`,不会自动重复上传。 + +## 定时发送快速说明 + +- `platform` 只表示目标平台:`douyin` / `bilibili`。 +- `publish_mode` 表示执行方式:默认 `local_browser`;`manual_export` 只能显式选择;旧 `opencli_publish` 只有设置 `PUBLISH_ENABLE_OPENCLI_FALLBACK=true` 才能执行。 +- `local_browser` 会按 `platform` 选择 `DouyinPublisher` 或 `BilibiliPublisher`,失败时绝不静默回退到 `manual_export`。 +- 浏览器取得平台成功证据才进入 `PUBLISHED`;本地发布包导出成功进入 `EXPORTED`,两者含义不同。 +- 前端固定显示北京时间;无时区输入按 `Asia/Shanghai` 解释,数据库统一保存带 `+00:00` 的 UTC ISO 8601。 +- “立即发送”只写入当前时间并设为 `SCHEDULED`;到点后与未来排期使用同一个 Scheduler 和 Publisher。 +- 调度器健康状态:`GET /api/publish/scheduler/health`。 +- 浏览器账号使用 `data/browser_profiles/{platform}/{account_id}` 独立目录;Cookie、storage state、截图和 Worker 日志均被 Git 忽略。 + +### 单条真实灰度发布 + +1. 打开 Docker Desktop 并运行 `niuma-studio` 项目;后台观察器会在容器运行后自动启动 Windows Worker,发送中心显示“Windows Worker:正常”后即可继续。 +2. 打开 `/publish` 的“内容准备 → 账号管理”,分别新增抖音和 B站账号,再点击“打开登录窗口”。 +3. 在系统 Chrome 独立窗口内完成二维码、短信或平台要求的人工验证,然后回到页面点击“检查登录”。 +4. 只选择一条用户确认可发布的短测试视频,核对标题、正文、话题、封面、平台账号和可见范围。 +5. 点击“立即发送”后,任务先进入 `SCHEDULED`,再由 Scheduler 领取为 `PUBLISHING`;平台确认成功后进入 `PUBLISHED`。 +6. 若出现登录、验证码、风控或结果不确定,任务应进入 `NEED_REVIEW`,先打开平台创作者中心核对,不能直接重试。 +7. 抖音会等待真实上传和解析完成后再填写内容;失败窗口默认保留 10 分钟,方便查看原因或人工处理。 + +### 排期 API + +预览与保存使用同一个请求体: + +```json +{ + "job_ids": ["job-a", "job-b"], + "action": "apply", + "start_at_local": "2026-07-12T09:00", + "timezone": "Asia/Shanghai", + "interval_minutes": 180, + "daily_start_time": "09:00", + "daily_end_time": "21:00", + "confirmed_schedule": [] +} +``` -v1.4.0 已实现定时发送与自动发布执行器:系统会按 `publish_jobs.scheduled_at` 扫描到点任务,默认使用 `manual_export` 生成本地发布包,不调用真实平台 API。 +- 先调用 `POST /api/publish/schedules/preview`,读取每条任务的 `scheduled_at_local`、`scheduled_at_local_display` 和 `scheduled_at_utc`。 +- 用户确认后,将预览返回的精确时间列表作为 `confirmed_schedule` 调用 `PATCH /api/publish/jobs/schedule-batch`,后端逐条校验后写库。 +- 清除排期时提交 `action=clear`;普通任务回到 `WAITING`,`FAILED` 保持失败,`NEED_REVIEW` 保持复核状态。 ## 当前状态 -- 后端:FastAPI 可启动,当前 API 版本为 `1.4.0`。 +- 后端:FastAPI 可启动,当前 API 版本为 `1.5.0`。 - 前端:HTML + CSS + JavaScript + Jinja2 后台页面,已完成 Apple 风格全页面美化。 - 数据库:SQLite,保存任务、候选片段、输出片段、字幕任务、发送任务和 AI 配置等信息。 - 视频处理:已接入 FFmpeg / FFprobe,用于音频提取、切片、封面帧和字幕成片。 - 转写:支持火山引擎远程转写和本地 faster-whisper。 - AI 分析:支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama;长视频会按小段分析再合并候选片段。 -- 发送中心:支持生成抖音 / B站待发送队列、AI 标题 / 简介 / 话题、候选封面帧,并通过 opencli 调用已登录 Chrome 辅助投稿。 +- 发送中心:分为内容准备、排期计划、执行记录;抖音 / B站真实发布由统一 Scheduler 和 Windows Chrome Worker 执行。 - 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会保存账号密码、cookie 或真实 API Key。 - 配置安全:真实 `.env` 已被 Git 忽略,不会提交真实 API Key。 - 品牌说明:当前页面主名为“牛马片场”,英文代号为 `NiuMa Studio`,Docker 技术名为 `niuma-studio`。 @@ -74,11 +116,13 @@ http://127.0.0.1:8001 --- -## v1.4.0 定时发送 +## v1.5.0 统一真实发布 -- 应用启动时会自动启动 `PublishScheduler`,默认每 60 秒扫描一次 `publish_jobs`。 -- 默认发布方式是 `manual_export`,会把发布包导出到 `outputs/publish_packages/{task_id}/{clip_id}/`。 -- 发布包包含 `clip.mp4`、`title.txt`、`caption.txt`、`hashtags.txt`、`cover_text.txt`、`publish_plan.json` 和 `metadata.json`。 +- 应用启动时会自动启动 `PublishScheduler`,默认每 5 秒扫描一次 `publish_jobs`。 +- 默认发布方式是 `local_browser`;Docker 中的 FastAPI 通过 `PUBLISH_WORKER_URL=http://host.docker.internal:8765` 调用 Windows Worker。 +- 日常启动只需打开 Docker Desktop 并运行 `niuma-studio`;Windows 后台观察器只在该容器运行时启动 Worker,项目停止 15 秒后自动关闭 Worker。 +- `.\scripts\start_niuma_studio.ps1` 和 `.\scripts\start_publish_worker.ps1` 继续保留为开发、诊断或手动维护工具,不再是日常启动必需步骤。 +- 可选的 `manual_export` 会把发布包导出到 `outputs/publish_packages/{task_id}/{clip_id}/`;发布包包含 `clip.mp4`、`title.txt`、`caption.txt`、`hashtags.txt`、`cover_text.txt`、`publish_plan.json` 和 `metadata.json`,成功状态为 `EXPORTED`。 - 手动执行一次扫描: ```powershell @@ -91,9 +135,9 @@ http://127.0.0.1:8001 .\.venv\Scripts\python.exe -m app.publish_scheduler run ``` -- `NEED_REVIEW` 表示文案或风险标记需要人工复核,不会自动发布;复核通过后可通过发布 API 或页面操作重新进入 `SCHEDULED` 队列。 +- `NEED_REVIEW` 表示登录、验证、风控或平台结果不确定;必须先核对平台结果。确认未发布后标记失败,再创建新的重试任务。 - 当前仍然跳过加字幕、烧录字幕和字幕叠加,自动发布使用原片切割结果。 -- 真实平台发布器仍是预留扩展点,后续需要平台账号授权、上传接口、风控边界和人工确认策略后才能接入。 +- 平台页面可能改版,真实灰度发布前必须用单条、低风险测试素材人工确认;自动测试全部使用 Mock,不会打开真实浏览器。 --- @@ -121,11 +165,7 @@ pytest --cov=app --cov-report=term-missing ## Docker 启动 -推荐启动方式:Docker 一键启动。 - -```powershell -docker compose up --build -``` +推荐启动方式:打开 Docker Desktop,在 Containers 中运行 `niuma-studio`。Windows 后台观察器检测到牛马片场的 `workflow` 容器后,会自动准备真实发送所需的 Worker;无需打开 PowerShell。 启动后在浏览器打开: @@ -141,6 +181,8 @@ docker compose down Docker 启动说明: - 容器内 Python 3.12,已预装 FFmpeg +- Windows Worker 必须在宿主机运行,容器不会保存 Chrome 登录态 +- `NiuMa Studio Docker Watcher` 只负责等待 Docker 项目;Windows Worker 不会在项目停止时常驻 - `.env` 文件会被自动加载(如果存在) - 存储目录 `E:\直播间切片工作流存储` 会自动挂载到容器内 - 代码目录和 prompts 目录以 volume 方式挂载,支持热更新 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..183fb12 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,24 @@ +# 第三方参考与许可证说明 + +牛马片场 v1.5.0 的发布模块在设计阶段参考了以下开源项目和官方文档。项目没有整仓复制这些外部代码,平台 Publisher、Registry、调度器和 Windows Worker 均按牛马片场现有 FastAPI / SQLite 架构独立实现。 + +## social-auto-upload-web-ui + +- 项目:https://github.com/DevilJie/social-auto-upload-web-ui +- 参考范围:平台注册、统一接口、任务队列、发布状态、历史记录和批量排期的设计模式。 +- 许可证:请以该项目仓库当前提供的许可证文件为准。 + +## social-auto-upload + +- 项目:https://github.com/dreammis/social-auto-upload +- 参考范围:抖音与 B站投稿步骤、登录态检查、视频校验、表单填写、上传完成与异常处理思路。 +- 许可证:请以该项目仓库当前提供的许可证文件为准。 +- 本项目未把该仓库作为运行时黑盒依赖,也未复制整个 uploader 目录。 + +## Playwright for Python + +- 项目与文档:https://playwright.dev/python/ +- 用途:Windows Worker 启动系统 Chrome 持久化上下文,并为每个平台/账号使用独立用户目录。 +- 许可证:Apache License 2.0(以 Playwright 官方仓库许可证为准)。 + +第三方网站和平台名称、商标及页面属于各自权利人。使用本项目投稿时,用户仍需遵守抖音、哔哩哔哩及浏览器相关服务条款,不得使用本项目绕过验证或平台风控。 diff --git a/VERSION b/VERSION index 88c5fb8..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/app/core/config.py b/app/core/config.py index 6d89e08..383d705 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -60,6 +60,10 @@ class Settings: data_dir: Path = _env_path("DATA_DIR", PROJECT_ROOT / "data") storage_root: Path = _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT) tasks_dir: Path = _env_path("TASKS_DIR", _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT)) + upload_temp_dir: Path = _env_path( + "UPLOAD_TEMP_DIR", + _env_path("TASKS_DIR", _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT)) / "_临时上传", + ) database_path: Path = _env_path( "DATABASE_PATH", _env_path("DATA_DIR", PROJECT_ROOT / "data") / "workflow.sqlite3", @@ -161,13 +165,46 @@ class Settings: ai_local_health_timeout_seconds: int = int(_env("AI_LOCAL_HEALTH_TIMEOUT_SECONDS", "30")) opencli_local_base_url: str = _env("OPENCLI_LOCAL_BASE_URL", "http://127.0.0.1:8001") opencli_host_bridge_url: str = _env("OPENCLI_HOST_BRIDGE_URL", "") + app_timezone: str = _env("APP_TIMEZONE", "Asia/Shanghai") publish_scheduler_enabled: bool = _env_bool("PUBLISH_SCHEDULER_ENABLED", True) - publish_scheduler_interval_seconds: int = int(_env("PUBLISH_SCHEDULER_INTERVAL_SECONDS", "60")) - publish_scheduler_default_platform: str = _env("PUBLISH_SCHEDULER_DEFAULT_PLATFORM", "manual_export") + publish_scheduler_interval_seconds: int = int(_env("PUBLISH_SCHEDULER_INTERVAL_SECONDS", "5")) + publish_default_mode: str = _env("PUBLISH_DEFAULT_MODE", "local_browser") + # 已废弃:仅保留读取能力,旧值不再覆盖 publish_jobs.platform。 + publish_scheduler_default_platform: str = _env("PUBLISH_SCHEDULER_DEFAULT_PLATFORM", "douyin") + publish_job_stale_minutes: int = int(_env("PUBLISH_JOB_STALE_MINUTES", "30")) publish_scheduler_max_retry_count: int = int(_env("PUBLISH_SCHEDULER_MAX_RETRY_COUNT", "3")) + publish_enable_opencli_fallback: bool = _env_bool("PUBLISH_ENABLE_OPENCLI_FALLBACK", False) + publish_worker_url: str = _env( + "PUBLISH_WORKER_URL", + "http://127.0.0.1:8765" if os.name == "nt" else "http://host.docker.internal:8765", + ) + publish_worker_token: str = _env("PUBLISH_WORKER_TOKEN", "") + publish_worker_timeout_seconds: int = int(_env("PUBLISH_WORKER_TIMEOUT_SECONDS", "1800")) + publish_browser_channel: str = _env("PUBLISH_BROWSER_CHANNEL", "chrome") + publish_browser_headless: bool = _env_bool("PUBLISH_BROWSER_HEADLESS", False) + publish_browser_navigation_timeout_ms: int = int( + _env("PUBLISH_BROWSER_NAVIGATION_TIMEOUT_MS", "60000") + ) + publish_browser_failure_hold_seconds: int = int( + _env("PUBLISH_BROWSER_FAILURE_HOLD_SECONDS", "600") + ) + publish_browser_profile_dir: Path = _env_path( + "PUBLISH_BROWSER_PROFILE_DIR", + _env_path("DATA_DIR", PROJECT_ROOT / "data") / "browser_profiles", + ) + publish_browser_artifact_dir: Path = _env_path( + "PUBLISH_BROWSER_ARTIFACT_DIR", + _env_path("DATA_DIR", PROJECT_ROOT / "data") / "publish_artifacts", + ) + publish_worker_state_dir: Path = _env_path( + "PUBLISH_WORKER_STATE_DIR", + _env_path("DATA_DIR", PROJECT_ROOT / "data") / "publish_worker", + ) + publish_host_project_root: Path = _env_path("PUBLISH_HOST_PROJECT_ROOT", PROJECT_ROOT) + publish_worker_allowed_roots: str = _env("PUBLISH_WORKER_ALLOWED_ROOTS", "") publish_scheduler_export_dir: Path = _env_path( "PUBLISH_SCHEDULER_EXPORT_DIR", - PROJECT_ROOT / "outputs" / "publish_packages", + _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT) / "_发布包", ) publish_scheduler_allow_publish_without_review: bool = _env_bool( "PUBLISH_SCHEDULER_ALLOW_PUBLISH_WITHOUT_REVIEW", diff --git a/app/db/database.py b/app/db/database.py index 2fb2849..aa9f677 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -1,14 +1,17 @@ +import json import sqlite3 from datetime import datetime from collections.abc import Iterator from contextlib import contextmanager from app.core.config import settings +from app.services.database_backup_service import create_publish_migration_backup DEFAULT_AI_PROMPT_PRESET_ID = "preset_001" DEFAULT_AI_PROMPT_PATH = settings.project_root / "prompts" / "default_ai_prompt_preset_001.txt" VARIETY_AI_PROMPT_PATH = settings.project_root / "prompts" / "variety_interview_prompt_preset_002.txt" +COMEDY_V2_AI_PROMPT_PATH = settings.project_root / "prompts" / "variety_comedy_v2_prompt.txt" @contextmanager @@ -40,8 +43,10 @@ def init_db() -> None: platform TEXT NOT NULL DEFAULT 'general', original_video_path TEXT, nas_file_path TEXT, - max_clip_duration INTEGER NOT NULL DEFAULT 5, - candidate_clip_count INTEGER NOT NULL DEFAULT 5, + max_clip_duration INTEGER NOT NULL DEFAULT 10, + candidate_clip_count INTEGER NOT NULL DEFAULT 12, + selection_profile TEXT NOT NULL DEFAULT 'general', + final_clip_target INTEGER NOT NULL DEFAULT 5, ai_preference TEXT, ai_prompt_preset_id TEXT NOT NULL DEFAULT 'preset_001', auto_mode INTEGER NOT NULL DEFAULT 0, @@ -64,12 +69,23 @@ def init_db() -> None: start_time TEXT NOT NULL, end_time TEXT NOT NULL, duration_seconds INTEGER NOT NULL, + cover_time_seconds REAL, summary TEXT, reason TEXT, highlight_reason TEXT, spread_value TEXT, suggested_editing TEXT, confidence_score REAL NOT NULL DEFAULT 0, + quality_tier TEXT NOT NULL DEFAULT '', + quality_score REAL NOT NULL DEFAULT 0, + text_quality_score REAL NOT NULL DEFAULT 0, + humor_score REAL NOT NULL DEFAULT 0, + completeness_score REAL NOT NULL DEFAULT 0, + audio_reaction_score REAL NOT NULL DEFAULT 0, + topic_key TEXT, + key_moment_time TEXT, + quality_evidence_json TEXT, + rejection_reason TEXT, selected_by_default INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, reviewed INTEGER NOT NULL DEFAULT 0, @@ -184,6 +200,11 @@ def init_db() -> None: token_expires_at TEXT, refresh_expires_at TEXT, authorization_status TEXT NOT NULL DEFAULT 'manual', + auth_type TEXT NOT NULL DEFAULT 'browser_profile', + login_status TEXT NOT NULL DEFAULT 'login_required', + login_checked_at TEXT, + login_message TEXT, + last_login_at TEXT, scopes TEXT, remark TEXT, created_at TEXT NOT NULL, @@ -217,6 +238,9 @@ def init_db() -> None: bilibili_source TEXT, cover_file_path TEXT, scheduled_at TEXT, + schedule_timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai', + timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai', + next_attempt_at TEXT, status TEXT NOT NULL DEFAULT 'SCHEDULED', audit_status TEXT NOT NULL DEFAULT 'not_submitted', platform_item_id TEXT, @@ -229,7 +253,19 @@ def init_db() -> None: publish_result TEXT, retry_count INTEGER NOT NULL DEFAULT 0, attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + claimed_at TEXT, + started_at TEXT, + finished_at TEXT, + worker_id TEXT, + execution_id TEXT, + execution_phase TEXT, + retry_of_job_id TEXT, + platform_url TEXT, + needs_manual_review INTEGER NOT NULL DEFAULT 0, published_at TEXT, + history_hidden INTEGER NOT NULL DEFAULT 0, + history_hidden_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id), @@ -237,6 +273,20 @@ def init_db() -> None: FOREIGN KEY(account_id) REFERENCES publish_accounts(id) ); + CREATE TABLE IF NOT EXISTS publish_job_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + event_type TEXT NOT NULL, + from_status TEXT, + to_status TEXT, + worker_id TEXT, + error_code TEXT, + message TEXT, + payload TEXT, + occurred_at TEXT NOT NULL, + FOREIGN KEY(job_id) REFERENCES publish_jobs(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS oauth_states ( state TEXT PRIMARY KEY, platform TEXT NOT NULL, @@ -272,6 +322,24 @@ def init_db() -> None: updated_at TEXT NOT NULL, FOREIGN KEY(task_id) REFERENCES tasks(id) ); + + CREATE TABLE IF NOT EXISTS clip_feedback ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + clip_candidate_id TEXT NOT NULL, + analysis_run_id TEXT, + selection_profile TEXT NOT NULL DEFAULT 'general', + decision TEXT NOT NULL, + reason_code TEXT NOT NULL, + note TEXT, + title_snapshot TEXT, + summary_snapshot TEXT, + start_time TEXT, + end_time TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id), + FOREIGN KEY(analysis_run_id) REFERENCES ai_analysis_runs(id) + ); """ ) _migrate_tasks_table(connection) @@ -283,6 +351,8 @@ def init_db() -> None: _migrate_publish_platform_configs_table(connection) _migrate_publish_accounts_table(connection) _migrate_publish_jobs_table(connection) + _migrate_publish_job_events_table(connection) + _restore_legacy_user_cancelled_publish_jobs(connection) _migrate_workflow_jobs_table(connection) _migrate_cut_runs_table(connection) _seed_ai_prompt_presets(connection) @@ -299,6 +369,8 @@ def _get_table_columns(connection: sqlite3.Connection, table_name: str) -> set[s def _create_indexes(connection: sqlite3.Connection) -> None: """创建常用查询索引(IF NOT EXISTS 语法兼容 SQLite 3.27+)。""" + # v1.4 的索引把 FAILED 也视为活动任务,导致无法为失败记录创建新的重试任务。 + connection.execute("DROP INDEX IF EXISTS uq_publish_jobs_active_clip_platform_mode") indexes = [ # 任务列表与状态筛选 "CREATE INDEX IF NOT EXISTS idx_tasks_status_created ON tasks(status, created_at)", @@ -309,12 +381,22 @@ def _create_indexes(connection: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_output_clip_task_status ON output_clip(task_id, status)", # AI 分析(按任务、创建时间;ai_analysis_runs 表无 status 列) "CREATE INDEX IF NOT EXISTS idx_ai_analysis_runs_task_created ON ai_analysis_runs(task_id, created_at)", + "CREATE INDEX IF NOT EXISTS idx_clip_feedback_profile_created ON clip_feedback(selection_profile, created_at)", + "CREATE INDEX IF NOT EXISTS idx_clip_feedback_task_clip ON clip_feedback(task_id, clip_candidate_id)", # 字幕任务(按任务、输出切片、状态) "CREATE INDEX IF NOT EXISTS idx_subtitle_jobs_task_output_status ON subtitle_jobs(task_id, output_clip_id, status)", # 发布任务(按状态、平台、时间;按任务、输出切片) "CREATE INDEX IF NOT EXISTS idx_publish_jobs_status_platform_created ON publish_jobs(status, platform, created_at)", "CREATE INDEX IF NOT EXISTS idx_publish_jobs_task_output ON publish_jobs(task_id, output_clip_id)", "CREATE INDEX IF NOT EXISTS idx_publish_jobs_status_scheduled ON publish_jobs(status, scheduled_at)", + "CREATE INDEX IF NOT EXISTS idx_publish_jobs_due_retry ON publish_jobs(status, next_attempt_at, scheduled_at)", + "CREATE INDEX IF NOT EXISTS idx_publish_jobs_execution ON publish_jobs(execution_id)", + "CREATE INDEX IF NOT EXISTS idx_publish_jobs_history_visibility ON publish_jobs(history_hidden, platform, status, created_at)", + "CREATE INDEX IF NOT EXISTS idx_publish_job_events_job_time ON publish_job_events(job_id, occurred_at)", + """CREATE UNIQUE INDEX IF NOT EXISTS uq_publish_jobs_active_clip_platform_mode + ON publish_jobs(output_clip_id, platform, publish_mode) + WHERE status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING', 'NEED_REVIEW') + AND output_clip_id IS NOT NULL AND output_clip_id <> ''""", # OAuth state 过期清理 "CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)", ] @@ -334,8 +416,10 @@ def _migrate_tasks_table(connection: sqlite3.Connection) -> None: "platform": "ALTER TABLE tasks ADD COLUMN platform TEXT NOT NULL DEFAULT 'general'", "original_video_path": "ALTER TABLE tasks ADD COLUMN original_video_path TEXT", "nas_file_path": "ALTER TABLE tasks ADD COLUMN nas_file_path TEXT", - "max_clip_duration": "ALTER TABLE tasks ADD COLUMN max_clip_duration INTEGER NOT NULL DEFAULT 5", - "candidate_clip_count": "ALTER TABLE tasks ADD COLUMN candidate_clip_count INTEGER NOT NULL DEFAULT 5", + "max_clip_duration": "ALTER TABLE tasks ADD COLUMN max_clip_duration INTEGER NOT NULL DEFAULT 10", + "candidate_clip_count": "ALTER TABLE tasks ADD COLUMN candidate_clip_count INTEGER NOT NULL DEFAULT 12", + "selection_profile": "ALTER TABLE tasks ADD COLUMN selection_profile TEXT NOT NULL DEFAULT 'general'", + "final_clip_target": "ALTER TABLE tasks ADD COLUMN final_clip_target INTEGER NOT NULL DEFAULT 5", "ai_preference": "ALTER TABLE tasks ADD COLUMN ai_preference TEXT", "ai_prompt_preset_id": "ALTER TABLE tasks ADD COLUMN ai_prompt_preset_id TEXT NOT NULL DEFAULT 'preset_001'", "auto_mode": "ALTER TABLE tasks ADD COLUMN auto_mode INTEGER NOT NULL DEFAULT 0", @@ -406,6 +490,8 @@ def _migrate_tasks_table(connection: sqlite3.Connection) -> None: UPDATE tasks SET task_dir_name = id WHERE task_dir_name IS NULL OR task_dir_name = ''; UPDATE tasks SET is_deleted = 0 WHERE is_deleted IS NULL; UPDATE tasks SET ai_prompt_preset_id = 'preset_001' WHERE ai_prompt_preset_id IS NULL OR ai_prompt_preset_id = ''; + UPDATE tasks SET selection_profile = 'general' WHERE selection_profile NOT IN ('general', 'variety_comedy') OR selection_profile IS NULL OR selection_profile = ''; + UPDATE tasks SET final_clip_target = 5 WHERE final_clip_target IS NULL OR final_clip_target < 1 OR final_clip_target > 12; UPDATE tasks SET source_type = 'upload' WHERE source_type NOT IN ('upload', 'nas') OR source_type IS NULL OR source_type = ''; UPDATE tasks SET platform = 'douyin' WHERE platform IN ('抖音', 'douyin'); @@ -446,7 +532,18 @@ def _migrate_clip_candidates_table(connection: sqlite3.Connection) -> None: "clip_key": "ALTER TABLE clip_candidates ADD COLUMN clip_key TEXT", "highlight_reason": "ALTER TABLE clip_candidates ADD COLUMN highlight_reason TEXT", "suggested_editing": "ALTER TABLE clip_candidates ADD COLUMN suggested_editing TEXT", + "cover_time_seconds": "ALTER TABLE clip_candidates ADD COLUMN cover_time_seconds REAL", "confidence_score": "ALTER TABLE clip_candidates ADD COLUMN confidence_score REAL NOT NULL DEFAULT 0", + "quality_tier": "ALTER TABLE clip_candidates ADD COLUMN quality_tier TEXT NOT NULL DEFAULT ''", + "quality_score": "ALTER TABLE clip_candidates ADD COLUMN quality_score REAL NOT NULL DEFAULT 0", + "text_quality_score": "ALTER TABLE clip_candidates ADD COLUMN text_quality_score REAL NOT NULL DEFAULT 0", + "humor_score": "ALTER TABLE clip_candidates ADD COLUMN humor_score REAL NOT NULL DEFAULT 0", + "completeness_score": "ALTER TABLE clip_candidates ADD COLUMN completeness_score REAL NOT NULL DEFAULT 0", + "audio_reaction_score": "ALTER TABLE clip_candidates ADD COLUMN audio_reaction_score REAL NOT NULL DEFAULT 0", + "topic_key": "ALTER TABLE clip_candidates ADD COLUMN topic_key TEXT", + "key_moment_time": "ALTER TABLE clip_candidates ADD COLUMN key_moment_time TEXT", + "quality_evidence_json": "ALTER TABLE clip_candidates ADD COLUMN quality_evidence_json TEXT", + "rejection_reason": "ALTER TABLE clip_candidates ADD COLUMN rejection_reason TEXT", "selected_by_default": "ALTER TABLE clip_candidates ADD COLUMN selected_by_default INTEGER NOT NULL DEFAULT 1", "reviewed": "ALTER TABLE clip_candidates ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0", "is_deleted": "ALTER TABLE clip_candidates ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0", @@ -647,6 +744,11 @@ def _migrate_publish_accounts_table(connection: sqlite3.Connection) -> None: "token_expires_at": "ALTER TABLE publish_accounts ADD COLUMN token_expires_at TEXT", "refresh_expires_at": "ALTER TABLE publish_accounts ADD COLUMN refresh_expires_at TEXT", "authorization_status": "ALTER TABLE publish_accounts ADD COLUMN authorization_status TEXT NOT NULL DEFAULT 'manual'", + "auth_type": "ALTER TABLE publish_accounts ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'browser_profile'", + "login_status": "ALTER TABLE publish_accounts ADD COLUMN login_status TEXT NOT NULL DEFAULT 'login_required'", + "login_checked_at": "ALTER TABLE publish_accounts ADD COLUMN login_checked_at TEXT", + "login_message": "ALTER TABLE publish_accounts ADD COLUMN login_message TEXT", + "last_login_at": "ALTER TABLE publish_accounts ADD COLUMN last_login_at TEXT", "scopes": "ALTER TABLE publish_accounts ADD COLUMN scopes TEXT", "remark": "ALTER TABLE publish_accounts ADD COLUMN remark TEXT", "created_at": "ALTER TABLE publish_accounts ADD COLUMN created_at TEXT NOT NULL DEFAULT ''", @@ -689,6 +791,9 @@ def _migrate_publish_jobs_table(connection: sqlite3.Connection) -> None: "bilibili_source": "ALTER TABLE publish_jobs ADD COLUMN bilibili_source TEXT", "cover_file_path": "ALTER TABLE publish_jobs ADD COLUMN cover_file_path TEXT", "scheduled_at": "ALTER TABLE publish_jobs ADD COLUMN scheduled_at TEXT", + "schedule_timezone": "ALTER TABLE publish_jobs ADD COLUMN schedule_timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai'", + "timezone": "ALTER TABLE publish_jobs ADD COLUMN timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai'", + "next_attempt_at": "ALTER TABLE publish_jobs ADD COLUMN next_attempt_at TEXT", "status": "ALTER TABLE publish_jobs ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'", "audit_status": "ALTER TABLE publish_jobs ADD COLUMN audit_status TEXT NOT NULL DEFAULT 'not_submitted'", "platform_item_id": "ALTER TABLE publish_jobs ADD COLUMN platform_item_id TEXT", @@ -701,7 +806,19 @@ def _migrate_publish_jobs_table(connection: sqlite3.Connection) -> None: "publish_result": "ALTER TABLE publish_jobs ADD COLUMN publish_result TEXT", "retry_count": "ALTER TABLE publish_jobs ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0", "attempt_count": "ALTER TABLE publish_jobs ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0", + "max_attempts": "ALTER TABLE publish_jobs ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 3", + "claimed_at": "ALTER TABLE publish_jobs ADD COLUMN claimed_at TEXT", + "started_at": "ALTER TABLE publish_jobs ADD COLUMN started_at TEXT", + "finished_at": "ALTER TABLE publish_jobs ADD COLUMN finished_at TEXT", + "worker_id": "ALTER TABLE publish_jobs ADD COLUMN worker_id TEXT", + "execution_id": "ALTER TABLE publish_jobs ADD COLUMN execution_id TEXT", + "execution_phase": "ALTER TABLE publish_jobs ADD COLUMN execution_phase TEXT", + "retry_of_job_id": "ALTER TABLE publish_jobs ADD COLUMN retry_of_job_id TEXT", + "platform_url": "ALTER TABLE publish_jobs ADD COLUMN platform_url TEXT", + "needs_manual_review": "ALTER TABLE publish_jobs ADD COLUMN needs_manual_review INTEGER NOT NULL DEFAULT 0", "published_at": "ALTER TABLE publish_jobs ADD COLUMN published_at TEXT", + "history_hidden": "ALTER TABLE publish_jobs ADD COLUMN history_hidden INTEGER NOT NULL DEFAULT 0", + "history_hidden_at": "ALTER TABLE publish_jobs ADD COLUMN history_hidden_at TEXT", "created_at": "ALTER TABLE publish_jobs ADD COLUMN created_at TEXT NOT NULL DEFAULT ''", "updated_at": "ALTER TABLE publish_jobs ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''", } @@ -710,6 +827,29 @@ def _migrate_publish_jobs_table(connection: sqlite3.Connection) -> None: connection.execute(statement) columns = _get_table_columns(connection, "publish_jobs") + requires_serialized_migration = _publish_database_requires_data_migration(connection) + if requires_serialized_migration: + # 提交上方的加列操作,再用 SQLite 写锁串行化“备份 + 数据修复”。 + # 备份服务会使用独立只读连接,因此快照仍是数据修复前的完整状态。 + connection.commit() + connection.execute("BEGIN IMMEDIATE") + try: + _run_publish_jobs_data_migrations(connection, columns) + except Exception: + if requires_serialized_migration: + connection.rollback() + raise + else: + if requires_serialized_migration: + connection.commit() + + +def _run_publish_jobs_data_migrations( + connection: sqlite3.Connection, + columns: set[str], +) -> None: + _backup_publish_database_before_data_migration(connection) + _migrate_publish_platform_and_mode_values(connection) if {"clip_id", "output_clip_id"}.issubset(columns): connection.execute("UPDATE publish_jobs SET clip_id = output_clip_id WHERE clip_id IS NULL OR clip_id = ''") if {"video_path", "video_file_path"}.issubset(columns): @@ -730,30 +870,252 @@ def _migrate_publish_jobs_table(connection: sqlite3.Connection) -> None: connection.execute( "UPDATE publish_jobs SET attempt_count = retry_count WHERE attempt_count IS NULL OR attempt_count = 0" ) + if "history_hidden" in columns: + connection.execute("UPDATE publish_jobs SET history_hidden = 0 WHERE history_hidden IS NULL") if "status" in columns: - connection.executescript( + status_migrations = ( + "UPDATE publish_jobs SET status = 'DRAFT' WHERE status = 'draft'", + "UPDATE publish_jobs SET status = 'SCHEDULED' WHERE status IN ('ready', 'scheduled')", + "UPDATE publish_jobs SET status = 'PUBLISHING' WHERE status = 'publishing'", + "UPDATE publish_jobs SET status = 'PUBLISHED' WHERE status = 'published'", + "UPDATE publish_jobs SET status = 'EXPORTED' WHERE status = 'exported'", + "UPDATE publish_jobs SET status = 'FAILED' WHERE status = 'failed'", + "UPDATE publish_jobs SET status = 'CANCELLED' WHERE status = 'cancelled'", + "UPDATE publish_jobs SET status = 'NEED_REVIEW' WHERE status = 'need_review'", """ - UPDATE publish_jobs SET status = 'DRAFT' WHERE status = 'draft'; - UPDATE publish_jobs SET status = 'SCHEDULED' WHERE status IN ('ready', 'scheduled'); - UPDATE publish_jobs SET status = 'PUBLISHING' WHERE status = 'publishing'; - UPDATE publish_jobs SET status = 'PUBLISHED' WHERE status = 'published'; - UPDATE publish_jobs SET status = 'FAILED' WHERE status = 'failed'; - UPDATE publish_jobs SET status = 'CANCELLED' WHERE status = 'cancelled'; - UPDATE publish_jobs SET status = 'NEED_REVIEW' WHERE status = 'need_review'; UPDATE publish_jobs SET status = 'WAITING' - WHERE status = 'SCHEDULED' AND (scheduled_at IS NULL OR scheduled_at = ''); + WHERE status = 'SCHEDULED' AND (scheduled_at IS NULL OR scheduled_at = '') + """, + """ UPDATE publish_jobs SET status = 'SCHEDULED' WHERE status IS NULL OR status = '' OR status NOT IN ( 'DRAFT', 'SCHEDULED', 'WAITING', 'PUBLISHING', - 'PUBLISHED', 'FAILED', 'CANCELLED', 'NEED_REVIEW' - ); + 'PUBLISHED', 'EXPORTED', 'FAILED', 'CANCELLED', 'NEED_REVIEW' + ) + """, + ) + for statement in status_migrations: + connection.execute(statement) + _cancel_duplicate_active_publish_jobs(connection) + + +def _migrate_publish_job_events_table(connection: sqlite3.Connection) -> None: + columns = _get_table_columns(connection, "publish_job_events") + if not columns: + return + migrations = { + "job_id": "ALTER TABLE publish_job_events ADD COLUMN job_id TEXT NOT NULL DEFAULT ''", + "event_type": "ALTER TABLE publish_job_events ADD COLUMN event_type TEXT NOT NULL DEFAULT ''", + "from_status": "ALTER TABLE publish_job_events ADD COLUMN from_status TEXT", + "to_status": "ALTER TABLE publish_job_events ADD COLUMN to_status TEXT", + "worker_id": "ALTER TABLE publish_job_events ADD COLUMN worker_id TEXT", + "error_code": "ALTER TABLE publish_job_events ADD COLUMN error_code TEXT", + "message": "ALTER TABLE publish_job_events ADD COLUMN message TEXT", + "payload": "ALTER TABLE publish_job_events ADD COLUMN payload TEXT", + "occurred_at": "ALTER TABLE publish_job_events ADD COLUMN occurred_at TEXT NOT NULL DEFAULT ''", + } + for column, statement in migrations.items(): + if column not in columns: + connection.execute(statement) + + +def _restore_legacy_user_cancelled_publish_jobs(connection: sqlite3.Connection) -> None: + """旧版“取消任务”应按新语义回到内容准备,系统取消和主动移出保持不变。""" + rows = connection.execute( + """ + SELECT cancelled.id, cancelled.status + FROM publish_jobs AS cancelled + WHERE cancelled.status = 'CANCELLED' + AND ( + cancelled.error_message = '用户取消任务' + OR cancelled.last_error = '用户取消任务' + ) + AND cancelled.id = ( + SELECT candidate.id + FROM publish_jobs AS candidate + WHERE candidate.output_clip_id = cancelled.output_clip_id + AND candidate.platform = cancelled.platform + AND candidate.status = 'CANCELLED' + AND ( + candidate.error_message = '用户取消任务' + OR candidate.last_error = '用户取消任务' + ) + ORDER BY COALESCE(NULLIF(candidate.updated_at, ''), candidate.created_at) DESC, + candidate.created_at DESC, + candidate.id DESC + LIMIT 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM publish_jobs AS active + WHERE active.id <> cancelled.id + AND active.output_clip_id = cancelled.output_clip_id + AND active.platform = cancelled.platform + AND active.status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING', 'NEED_REVIEW') + ) + """ + ).fetchall() + if not rows: + return + + now = datetime.now().astimezone().isoformat(timespec="seconds") + for row in rows: + cursor = connection.execute( """ + UPDATE publish_jobs + SET status = 'WAITING', scheduled_at = '', next_attempt_at = NULL, + claimed_at = NULL, started_at = NULL, finished_at = NULL, + worker_id = NULL, execution_id = NULL, execution_phase = '', + error_code = '', error_message = '', last_error = '', + needs_manual_review = 0, updated_at = ? + WHERE id = ? AND status = 'CANCELLED' + AND (error_message = '用户取消任务' OR last_error = '用户取消任务') + """, + (now, row["id"]), + ) + if cursor.rowcount: + connection.execute( + """ + INSERT INTO publish_job_events ( + job_id, event_type, from_status, to_status, message, payload, occurred_at + ) VALUES (?, 'legacy_cancel_restored', 'CANCELLED', 'WAITING', ?, ?, ?) + """, + ( + row["id"], + "旧版取消发送记录已自动返回内容准备", + json.dumps( + {"scheduled_at_cleared": True, "files_deleted": False}, + ensure_ascii=False, + ), + now, + ), + ) + + +def _publish_database_requires_data_migration(connection: sqlite3.Connection) -> bool: + legacy_count = connection.execute( + """ + SELECT COUNT(*) + FROM publish_jobs + WHERE platform NOT IN ('douyin', 'bilibili') + OR publish_mode NOT IN ('opencli_publish', 'manual_export', 'api_publish', 'local_browser') + """ + ).fetchone()[0] + duplicate_count = connection.execute( + """ + SELECT COUNT(*) FROM ( + SELECT output_clip_id, platform, publish_mode + FROM publish_jobs + WHERE status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING') + GROUP BY output_clip_id, platform, publish_mode + HAVING COUNT(*) > 1 + ) + """ + ).fetchone()[0] + return bool(legacy_count or duplicate_count) + + +def _backup_publish_database_before_data_migration(connection: sqlite3.Connection) -> None: + """仅在发现旧值或有效重复任务时创建受限频率的完整迁移前备份。""" + if not _publish_database_requires_data_migration(connection): + return + database_path = settings.database_path + if not database_path.exists(): + return + backup_dir = settings.data_dir / "backups" + create_publish_migration_backup(database_path, backup_dir) + + +def _provider_target_platform(raw_value: str | None) -> str: + try: + payload = json.loads(raw_value or "{}") + except (json.JSONDecodeError, TypeError): + return "" + if not isinstance(payload, dict): + return "" + target = str(payload.get("target_platform") or "").strip().lower() + return target if target in {"douyin", "bilibili"} else "" + + +def _migrate_publish_platform_and_mode_values(connection: sqlite3.Connection) -> None: + default_mode = str(settings.publish_default_mode or "opencli_publish").strip().lower() + if default_mode not in {"opencli_publish", "manual_export", "api_publish", "local_browser"}: + default_mode = "opencli_publish" + rows = connection.execute( + """ + SELECT publish_jobs.id, publish_jobs.platform, publish_jobs.publish_mode, + publish_jobs.provider_response, tasks.platform AS task_platform + FROM publish_jobs + LEFT JOIN tasks ON tasks.id = publish_jobs.task_id + WHERE publish_jobs.platform NOT IN ('douyin', 'bilibili') + OR publish_jobs.publish_mode NOT IN ('opencli_publish', 'manual_export', 'api_publish', 'local_browser') + """ + ).fetchall() + now = datetime.now().astimezone().isoformat(timespec="seconds") + for row in rows: + platform = _provider_target_platform(row["provider_response"]) + if not platform: + task_platform = str(row["task_platform"] or "").strip().lower() + platform = task_platform if task_platform in {"douyin", "bilibili"} else "douyin" + old_platform = str(row["platform"] or "").strip().lower() + old_mode = str(row["publish_mode"] or "").strip().lower() + mode = old_mode if old_mode in {"opencli_publish", "manual_export", "api_publish", "local_browser"} else default_mode + if old_platform in {"manual_export", "local_browser"}: + mode = default_mode + connection.execute( + "UPDATE publish_jobs SET platform = ?, publish_mode = ?, updated_at = ? WHERE id = ?", + (platform, mode, now, row["id"]), ) +def _cancel_duplicate_active_publish_jobs(connection: sqlite3.Connection) -> None: + groups = connection.execute( + """ + SELECT output_clip_id, platform, publish_mode + FROM publish_jobs + WHERE status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING') + GROUP BY output_clip_id, platform, publish_mode + HAVING COUNT(*) > 1 + """ + ).fetchall() + now = datetime.now().astimezone().isoformat(timespec="seconds") + for group in groups: + rows = connection.execute( + """ + SELECT id, provider_response + FROM publish_jobs + WHERE output_clip_id = ? AND platform = ? AND publish_mode = ? + AND status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING') + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC, created_at DESC, id DESC + """, + (group["output_clip_id"], group["platform"], group["publish_mode"]), + ).fetchall() + for duplicate in rows[1:]: + migration_payload = { + "migration_reason": "duplicate_active_publish_job", + "message": "迁移时发现同一切片、平台和执行方式的重复未发布任务,已保留最新一条。", + "previous_provider_response": duplicate["provider_response"] or "", + } + connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', error_code = 'migration_duplicate_cancelled', + error_message = ?, last_error = ?, provider_response = ?, updated_at = ? + WHERE id = ? + """, + ( + migration_payload["message"], + migration_payload["message"], + json.dumps(migration_payload, ensure_ascii=False), + now, + duplicate["id"], + ), + ) + + def _migrate_workflow_jobs_table(connection: sqlite3.Connection) -> None: columns = _get_table_columns(connection, "workflow_jobs") if not columns: @@ -809,11 +1171,15 @@ def _seed_ai_prompt_presets(connection: sqlite3.Connection) -> None: variety_prompt = "" if VARIETY_AI_PROMPT_PATH.exists(): variety_prompt = VARIETY_AI_PROMPT_PATH.read_text(encoding="utf-8") + comedy_v2_prompt = "" + if COMEDY_V2_AI_PROMPT_PATH.exists(): + comedy_v2_prompt = COMEDY_V2_AI_PROMPT_PATH.read_text(encoding="utf-8") presets = [ (DEFAULT_AI_PROMPT_PRESET_ID, 1, "默认直播切片分析专家", default_prompt, 1), ("preset_002", 2, "综艺访谈完整上下文专家", variety_prompt, 0), ("preset_003", 3, "3号方案", "", 0), + ("preset_004", 4, "康熙笑点优先 V2", comedy_v2_prompt, 0), ] for preset_id, slot, name, prompt_text, is_default in presets: existing = connection.execute( diff --git a/app/main.py b/app/main.py index 2b4286f..d826e7f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ from contextlib import asynccontextmanager +import os +import tempfile from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse, JSONResponse @@ -8,6 +10,7 @@ from app.db.database import init_db from app.routers import ai_prompts, files, media, pages, publish, settings as settings_router, tasks from app.services.publish_scheduler import start_scheduler_background +from app.services.storage_service import configure_runtime_media_storage # /media 和 /static 的 Origin 白名单 @@ -43,6 +46,9 @@ def _build_allow_origin_header(origin: str) -> str: @asynccontextmanager async def lifespan(app: FastAPI): + previous_temp = tempfile.tempdir + previous_temp_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + app.state.media_storage = configure_runtime_media_storage() init_db() scheduler = await start_scheduler_background() app.state.publish_scheduler = scheduler @@ -51,12 +57,18 @@ async def lifespan(app: FastAPI): finally: if scheduler: scheduler.stop() + tempfile.tempdir = previous_temp + for name, value in previous_temp_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value app = FastAPI( title=settings.app_name, description=settings.app_description, - version="1.4.0", + version="1.5.0", lifespan=lifespan, ) diff --git a/app/models/task.py b/app/models/task.py index e71bb48..0ec6f43 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Literal, Optional +from typing import Any, Literal, Optional from pydantic import BaseModel, Field, validator @@ -43,8 +43,10 @@ class TaskCreate(BaseModel): platform: Literal["douyin", "bilibili", "general"] = "general" original_video_path: Optional[str] = None nas_file_path: Optional[str] = None - max_clip_duration: int = Field(default=5, ge=1, le=60) - candidate_clip_count: int = Field(default=5, ge=1, le=50) + max_clip_duration: int = Field(default=10, ge=1, le=60) + candidate_clip_count: int = Field(default=12, ge=1, le=50) + selection_profile: Literal["general", "variety_comedy"] = "general" + final_clip_target: int = Field(default=5, ge=1, le=12) ai_preference: Optional[str] = None auto_mode: bool = False auto_clip_count: str = Field(default="auto", max_length=10) @@ -53,8 +55,8 @@ class TaskCreate(BaseModel): auto_schedule_mode: Literal["default", "immediate", "interval", "daily_window"] = "default" auto_schedule_start_at: Optional[str] = Field(default="", max_length=80) auto_schedule_interval_hours: int = Field(default=3, ge=1, le=168) - auto_schedule_daily_start_time: str = Field(default="09:00", max_length=5) - auto_schedule_daily_end_time: str = Field(default="21:00", max_length=5) + auto_schedule_daily_start_time: str = Field(default="07:00", max_length=5) + auto_schedule_daily_end_time: str = Field(default="00:00", max_length=5) auto_metadata_use_ai: bool = False @validator("auto_clip_count") @@ -101,7 +103,26 @@ class TaskAIPromptPresetUpdate(BaseModel): class TaskCandidateClipCountUpdate(BaseModel): - candidate_clip_count: int = Field(default=5, ge=1, le=50) + candidate_clip_count: int = Field(default=12, ge=1, le=50) + + +class TaskSelectionSettingsUpdate(BaseModel): + selection_profile: Literal["general", "variety_comedy"] = "general" + final_clip_target: int = Field(default=5, ge=1, le=12) + + +class ClipFeedbackCreate(BaseModel): + decision: Literal["keep", "reject"] + reason_code: Literal[ + "worth_publishing", + "not_funny", + "fragmented", + "missing_setup", + "duplicate", + "dragging", + "other", + ] + note: Optional[str] = Field(default="", max_length=500) class SubtitleStyleUpdate(BaseModel): @@ -144,9 +165,9 @@ class PublishAccountCreate(BaseModel): class PublishJobCreate(BaseModel): task_id: str = Field(..., min_length=1, max_length=80) output_clip_id: str = Field(..., min_length=1, max_length=80) - platform: Literal["douyin", "bilibili", "manual_export", "local_browser"] + platform: Literal["douyin", "bilibili"] account_id: Optional[str] = Field(default="", max_length=80) - publish_mode: Literal["draft", "manual_review", "manual_export", "local_browser", "api_publish", "opencli_publish"] = "manual_review" + publish_mode: Literal["manual_export", "local_browser", "api_publish", "opencli_publish"] = "local_browser" video_source: Literal["original", "subtitled"] = "original" title: str = Field(..., min_length=1, max_length=120) description: Optional[str] = Field(default="", max_length=2000) @@ -168,11 +189,14 @@ class PublishJobScheduleUpdate(BaseModel): class PublishBatchScheduleUpdate(BaseModel): job_ids: list[str] = Field(default_factory=list) + platform: Optional[Literal["douyin", "bilibili"]] = None action: Literal["apply", "clear"] = "apply" - start_at: Optional[str] = Field(default="", max_length=80) - interval_hours: int = Field(default=3, ge=1, le=168) - daily_start_time: str = Field(default="09:00", min_length=5, max_length=5) - daily_end_time: str = Field(default="21:00", min_length=5, max_length=5) + start_at_local: Optional[str] = Field(default="", max_length=80) + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=80) + interval_minutes: int = Field(default=180, ge=1, le=10080) + daily_start_time: str = Field(default="07:00", min_length=5, max_length=5) + daily_end_time: str = Field(default="00:00", min_length=5, max_length=5) + confirmed_schedule: list[dict[str, str]] = Field(default_factory=list) @validator("job_ids") def validate_schedule_job_ids(cls, value: list[str]) -> list[str]: @@ -182,6 +206,22 @@ def validate_schedule_job_ids(cls, value: list[str]) -> list[str]: return normalized +class PublishScheduleNextStartRequest(BaseModel): + job_ids: list[str] = Field(default_factory=list) + platform: Literal["douyin", "bilibili"] + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=80) + interval_minutes: int = Field(default=180, ge=1, le=10080) + daily_start_time: str = Field(default="07:00", min_length=5, max_length=5) + daily_end_time: str = Field(default="00:00", min_length=5, max_length=5) + + @validator("job_ids") + def validate_next_start_job_ids(cls, value: list[str]) -> list[str]: + normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + if not normalized: + raise ValueError("至少选择一条发布任务") + return normalized + + class PublishJobContentUpdate(BaseModel): title: str = Field(..., min_length=1, max_length=120) caption: str = Field(..., min_length=1, max_length=2000) @@ -194,7 +234,7 @@ class PublishBatchJobCreate(BaseModel): output_clip_ids: list[str] = Field(default_factory=list) platform: Literal["douyin", "bilibili"] account_id: Optional[str] = Field(default="", max_length=80) - publish_mode: Literal["draft", "manual_review"] = "manual_review" + publish_mode: Literal["manual_export", "local_browser", "api_publish", "opencli_publish"] = "local_browser" video_source: Literal["original", "subtitled"] = "original" title_prefix: Optional[str] = Field(default="", max_length=80) description: Optional[str] = Field(default="", max_length=2000) @@ -228,9 +268,45 @@ class PublishSendJobUpdate(BaseModel): bilibili_source: Optional[str] = Field(default="", max_length=300) -class PublishSendStart(BaseModel): +class PublishRetryRequest(BaseModel): + scheduled_at: Optional[str] = Field(default="", max_length=80) + visibility: Optional[Literal["public", "friends", "private"]] = None + + +class PublishHistoryRecordBatchUpdate(BaseModel): + platform: Literal["douyin", "bilibili"] job_ids: list[str] = Field(default_factory=list) + @validator("job_ids") + def validate_history_job_ids(cls, value: list[str]) -> list[str]: + normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + if not normalized: + raise ValueError("至少选择一条执行记录") + if len(normalized) > 100: + raise ValueError("每次最多处理 100 条执行记录") + return normalized + + +class PublishMarkPublishedRequest(BaseModel): + platform_url: str = Field(..., min_length=8, max_length=2000) + + +class PublishJobTargetUpdate(BaseModel): + platform: Literal["douyin", "bilibili"] + account_id: Optional[str] = Field(default="", max_length=80) + publish_mode: Literal["manual_export", "local_browser"] = "local_browser" + + +class PublishBatchTargetUpdate(PublishJobTargetUpdate): + job_ids: list[str] = Field(default_factory=list) + + @validator("job_ids") + def validate_target_job_ids(cls, value: list[str]) -> list[str]: + normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + if not normalized: + raise ValueError("至少选择一条发布任务") + return normalized + class PublishCoverFrameBatchCreate(BaseModel): task_id: str = Field(..., min_length=1, max_length=80) @@ -248,6 +324,7 @@ class ClipCandidate(BaseModel): start_time: str end_time: str duration_seconds: int + cover_time_seconds: Optional[float] = Field(default=None, ge=0) summary: str = "" highlight_reason: str = "" spread_value: str = "" @@ -282,12 +359,23 @@ class AIClipItem(BaseModel): start_time: str = Field(..., min_length=1, max_length=16) end_time: str = Field(..., min_length=1, max_length=16) duration_seconds: int = Field(..., ge=1) + cover_time_seconds: float = Field(..., ge=0) summary: str = Field(..., min_length=1, max_length=1000) highlight_reason: str = Field(..., min_length=1, max_length=1000) spread_value: str = Field(..., min_length=1, max_length=40) suggested_editing: str = Field(..., min_length=1, max_length=1000) confidence_score: float = Field(..., ge=0, le=1) selected_by_default: bool = True + quality_tier: str = Field(default="", max_length=8) + quality_score: float = Field(default=0, ge=0, le=100) + text_quality_score: float = Field(default=0, ge=0, le=100) + humor_score: float = Field(default=0, ge=0, le=100) + completeness_score: float = Field(default=0, ge=0, le=100) + audio_reaction_score: float = Field(default=0, ge=0, le=100) + topic_key: str = Field(default="", max_length=120) + key_moment_time: str = Field(default="", max_length=16) + quality_evidence: dict[str, Any] = Field(default_factory=dict) + rejection_reason: str = Field(default="", max_length=1000) @validator("start_time", "end_time") def validate_time_text(cls, value: str) -> str: diff --git a/app/routers/pages.py b/app/routers/pages.py index 0f8586c..467cbcb 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -5,7 +5,11 @@ from app.core.config import settings from app.services.ai_prompt_preset_service import list_ai_prompt_presets -from app.services.publish_service import get_publish_center_context +from app.services.publish_service import ( + get_publish_center_context, + get_publish_link_states, + get_task_publish_link_state, +) from app.services.task_query_service import ( get_clips_overview_context, get_dashboard_context, @@ -48,6 +52,10 @@ async def dashboard(request: Request): @router.get("/tasks") async def tasks_page(request: Request): + tasks = list_tasks() + link_states = get_publish_link_states([task["id"] for task in tasks]) + for task in tasks: + task["publish_link_state"] = link_states.get(task["id"], {}) return templates.TemplateResponse( name="tasks.html", request=request, @@ -55,7 +63,7 @@ async def tasks_page(request: Request): "request": request, "active_page": "tasks", "settings": settings, - "tasks": list_tasks(), + "tasks": tasks, }, ) @@ -89,6 +97,7 @@ async def task_detail_page(request: Request, task_id: str): "active_page": "tasks", "settings": settings, "task": task, + "publish_link_state": get_task_publish_link_state(task_id), "workflow_steps": get_task_workflow_steps(task), "transcript_lines": get_transcript_preview(task_id), "output_clips": list_output_clips(task_id), @@ -132,12 +141,18 @@ def _filter_and_sort_clips(clips: list[dict], clip_filter: str, sort_by: str) -> clips = [ clip for clip in clips - if "高" in clip.get("spread_value", "") or clip.get("spread_value", "").lower() == "high" + if clip.get("quality_tier") == "A" + or "高" in clip.get("spread_value", "") + or clip.get("spread_value", "").lower() == "high" ] if sort_by == "time": return sorted(clips, key=lambda clip: clip.get("start_seconds", 0)) - return sorted(clips, key=lambda clip: clip.get("confidence_score", 0), reverse=True) + return sorted( + clips, + key=lambda clip: clip.get("quality_score") or clip.get("confidence_score", 0), + reverse=True, + ) async def _render_clip_review_page( @@ -166,6 +181,7 @@ async def _render_clip_review_page( "clip_filter": clip_filter, "sort_by": sort_by, "output_clips": list_output_clips(task_id), + "publish_link_state": get_task_publish_link_state(task_id), }, ) @@ -234,6 +250,7 @@ async def subtitle_task_page(request: Request, task_id: str): "active_page": "subtitles", "settings": settings, "subtitle_task_mode": True, + "publish_link_state": get_task_publish_link_state(task_id), **context, }, ) @@ -241,6 +258,7 @@ async def subtitle_task_page(request: Request, task_id: str): @router.get("/publish") async def publish_center_page(request: Request): + focus_task_id = request.query_params.get("task_id", "") return templates.TemplateResponse( name="publish.html", request=request, @@ -249,7 +267,10 @@ async def publish_center_page(request: Request): "active_page": "publish", "settings": settings, "publish_message": request.query_params.get("publish_message", ""), - **get_publish_center_context(), + "focus_task_id": focus_task_id, + "focus_platform": request.query_params.get("platform", ""), + "focus_tab": request.query_params.get("tab", ""), + **get_publish_center_context(focus_task_id=focus_task_id), }, ) diff --git a/app/routers/publish.py b/app/routers/publish.py index 4d07d4c..b23573c 100644 --- a/app/routers/publish.py +++ b/app/routers/publish.py @@ -7,17 +7,24 @@ PublishAccountCreate, PublishBatchJobCreate, PublishBatchScheduleUpdate, + PublishBatchTargetUpdate, PublishCoverCreate, PublishCoverFrameBatchCreate, + PublishHistoryRecordBatchUpdate, PublishJobContentUpdate, PublishJobCreate, PublishJobScheduleUpdate, + PublishJobTargetUpdate, + PublishMarkPublishedRequest, PublishPlatformConfigUpdate, + PublishRetryRequest, + PublishScheduleNextStartRequest, PublishSendJobUpdate, - PublishSendStart, ) from app.services import publish_service -from app.services.publish_scheduler import PublishScheduler, queue_snapshot +from app.services.publish_readiness import PublishPlatformIsolationBlocked, SendReadinessBlocked +from app.services.publish_scheduler import PublishScheduler, queue_snapshot, scheduler_health +from app.services.publishers.base import PublishError router = APIRouter(prefix="/api/publish", tags=["publish"]) @@ -76,11 +83,98 @@ async def create_account(payload: PublishAccountCreate) -> dict: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/accounts/{account_id}/login", status_code=202) +async def login_browser_account(account_id: str) -> dict: + try: + return publish_service.start_browser_account_login(account_id) + except (ValueError, PublishError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/accounts/{account_id}/check") +async def check_browser_account(account_id: str) -> dict: + try: + return publish_service.check_browser_account(account_id) + except (ValueError, PublishError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/accounts/{account_id}/open-center", status_code=202) +async def open_browser_creator_center(account_id: str) -> dict: + try: + return publish_service.open_browser_creator_center(account_id) + except (ValueError, PublishError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/jobs") async def list_publish_jobs() -> dict: return {"jobs": publish_service.list_publish_jobs()} +@router.get("/history/calendar") +async def get_publish_history_calendar( + platform: str = Query(default="douyin"), + month: str = Query(..., min_length=7, max_length=7), +) -> dict: + try: + return publish_service.get_publish_history_calendar(platform, month) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/history/records") +async def list_publish_history_records( + platform: str = Query(default="douyin"), + date: str = Query(default="", max_length=10), + status: str = Query(default="all", max_length=20), + deleted: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=50), +) -> dict: + try: + return publish_service.list_publish_history_records( + platform=platform, + date=date, + status=status, + deleted=deleted, + page=page, + page_size=page_size, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/history/records/hide") +async def hide_publish_history_records(payload: PublishHistoryRecordBatchUpdate) -> dict: + try: + return publish_service.hide_publish_history_records(payload.job_ids, platform=payload.platform) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/history/records/restore") +async def restore_publish_history_records(payload: PublishHistoryRecordBatchUpdate) -> dict: + try: + return publish_service.restore_publish_history_records(payload.job_ids, platform=payload.platform) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/jobs/{job_id}/events") +async def list_publish_job_events(job_id: str) -> dict: + from app.services.publish_repository import PublishRepository + + repository = PublishRepository() + if not repository.get_job(job_id): + raise HTTPException(status_code=404, detail="发布任务不存在") + return {"events": repository.list_events(job_id)} + + @router.get("/queue") async def get_send_queue() -> dict: return publish_service.get_publish_center_context() @@ -93,13 +187,46 @@ async def get_publish_queue_snapshot(task_id: str | None = None) -> dict: @router.post("/scheduler/run-once") async def run_publish_scheduler_once() -> dict: - return PublishScheduler().run_once() + import asyncio + + return await asyncio.to_thread(PublishScheduler().run_once) + + +@router.get("/scheduler/health") +async def get_publish_scheduler_health() -> dict: + return scheduler_health() @router.post("/queue/refresh") -async def refresh_send_queue(use_ai: bool = Query(default=False)) -> dict: +async def refresh_send_queue( + use_ai: bool = Query(default=False), + platform: str | None = Query(default=None), +) -> dict: try: - return publish_service.refresh_send_queue(use_ai=use_ai) + return publish_service.refresh_send_queue(use_ai=use_ai, platform=platform) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/tasks/{task_id}/link-state") +async def get_task_publish_link_state(task_id: str) -> dict: + try: + return publish_service.get_task_publish_link_state(task_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/tasks/{task_id}/sync") +async def sync_task_publish_jobs( + task_id: str, + prefer_subtitled: bool = Query(default=True), +) -> dict: + try: + return publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=prefer_subtitled, + restore_removed=True, + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -128,6 +255,16 @@ async def generate_publish_cover(payload: PublishCoverCreate) -> dict: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/covers/backfill") +async def backfill_missing_publish_covers(platform: str | None = Query(default=None)) -> dict: + import asyncio + + try: + return await asyncio.to_thread(publish_service.backfill_missing_publish_covers, platform) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/covers/frames") async def generate_publish_cover_frames(payload: PublishCoverFrameBatchCreate) -> dict: try: @@ -160,37 +297,55 @@ async def regenerate_send_job_metadata(job_id: str, use_ai: bool = Query(default raise HTTPException(status_code=400, detail=str(exc)) from exc -@router.post("/jobs/{job_id}/send") -async def send_publish_job(job_id: str, background_tasks: BackgroundTasks) -> dict: +@router.post("/jobs/{job_id}/retry") +async def retry_publish_job( + job_id: str, + background_tasks: BackgroundTasks, + payload: PublishRetryRequest | None = None, +) -> dict: try: - return publish_service.start_opencli_send_batch( - PublishSendStart(job_ids=[job_id]), - background_tasks=background_tasks, + result = PublishScheduler().retry_failed( + job_id, + (payload.scheduled_at if payload else "") or None, + visibility=(payload.visibility if payload else None), ) + background_tasks.add_task(PublishScheduler().run_once) + return result + except SendReadinessBlocked as exc: + raise HTTPException(status_code=409, detail=exc.readiness) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc -@router.post("/send/start") -async def start_send_queue(payload: PublishSendStart, background_tasks: BackgroundTasks) -> dict: - try: - return publish_service.start_opencli_send_batch(payload, background_tasks=background_tasks) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.post("/jobs/{job_id}/retry") -async def retry_publish_job(job_id: str) -> dict: +@router.post("/jobs/{job_id}/publish-now") +async def publish_job_now(job_id: str, background_tasks: BackgroundTasks) -> dict: try: - return publish_service.retry_publish_job(job_id) + result = PublishScheduler().publish_now(job_id) + background_tasks.add_task(PublishScheduler().run_once) + return result + except SendReadinessBlocked as exc: + raise HTTPException(status_code=409, detail=exc.readiness) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc -@router.post("/jobs/{job_id}/publish-now") -async def publish_job_now(job_id: str) -> dict: +@router.post("/jobs/{job_id}/repair-and-publish") +async def repair_and_publish_job( + job_id: str, + background_tasks: BackgroundTasks, + account_id: str = Query(default=""), + visibility: str = Query(default=""), +) -> dict: try: - return PublishScheduler().publish_now(job_id) + result = PublishScheduler().repair_and_publish( + job_id, + account_id=account_id, + visibility=visibility, + ) + background_tasks.add_task(PublishScheduler().run_once) + return result + except SendReadinessBlocked as exc: + raise HTTPException(status_code=409, detail=exc.readiness) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -204,9 +359,9 @@ async def skip_publish_job(job_id: str) -> dict: @router.post("/jobs/{job_id}/approve-review") -async def approve_review_publish_job(job_id: str) -> dict: +async def approve_review_publish_job(job_id: str, payload: PublishMarkPublishedRequest) -> dict: try: - return PublishScheduler().approve_review(job_id) + return PublishScheduler().approve_review(job_id, payload.platform_url) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -215,6 +370,8 @@ async def approve_review_publish_job(job_id: str) -> dict: async def update_publish_job_schedule(job_id: str, payload: PublishJobScheduleUpdate) -> dict: try: return publish_service.update_publish_job_schedule(job_id, payload) + except SendReadinessBlocked as exc: + raise HTTPException(status_code=409, detail=exc.readiness) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -224,12 +381,58 @@ async def update_publish_jobs_schedule_batch(payload: PublishBatchScheduleUpdate try: return PublishScheduler().update_batch_schedule( payload.job_ids, + platform=payload.platform, action=payload.action, - start_at=payload.start_at or "", - interval_hours=payload.interval_hours, + start_at_local=payload.start_at_local or "", + timezone_name=payload.timezone, + interval_minutes=payload.interval_minutes, daily_start_time=payload.daily_start_time, daily_end_time=payload.daily_end_time, + confirmed_schedule=payload.confirmed_schedule, ) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except SendReadinessBlocked as exc: + raise HTTPException(status_code=409, detail=exc.readiness) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/schedules/preview") +async def preview_publish_jobs_schedule(payload: PublishBatchScheduleUpdate) -> dict: + if payload.action != "apply": + raise HTTPException(status_code=400, detail="排期预览只支持 apply") + try: + return PublishScheduler().preview_batch_schedule( + payload.job_ids, + platform=payload.platform, + start_at_local=payload.start_at_local or "", + timezone_name=payload.timezone, + interval_minutes=payload.interval_minutes, + daily_start_time=payload.daily_start_time, + daily_end_time=payload.daily_end_time, + ) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except SendReadinessBlocked as exc: + raise HTTPException(status_code=409, detail=exc.readiness) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/schedules/next-start") +async def get_publish_jobs_next_schedule_start(payload: PublishScheduleNextStartRequest) -> dict: + try: + return PublishScheduler().next_batch_schedule_start( + payload.job_ids, + platform=payload.platform, + timezone_name=payload.timezone, + interval_minutes=payload.interval_minutes, + daily_start_time=payload.daily_start_time, + daily_end_time=payload.daily_end_time, + ) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -242,10 +445,46 @@ async def update_publish_job_content(job_id: str, payload: PublishJobContentUpda raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.patch("/jobs/{job_id}/target") +async def update_publish_job_target(job_id: str, payload: PublishJobTargetUpdate) -> dict: + try: + return publish_service.update_publish_job_target(job_id, payload) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/jobs/target-batch") +async def update_publish_jobs_target_batch(payload: PublishBatchTargetUpdate) -> dict: + try: + return publish_service.update_publish_jobs_target_batch(payload) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/jobs/{job_id}/dismiss") +async def dismiss_publish_job(job_id: str) -> dict: + try: + return publish_service.dismiss_publish_job(job_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/jobs/{job_id}/restore") +async def restore_publish_job(job_id: str) -> dict: + try: + return publish_service.restore_publish_job(job_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/jobs/{job_id}/mark-published") -async def mark_publish_job_published(job_id: str) -> dict: +async def mark_publish_job_published(job_id: str, payload: PublishMarkPublishedRequest) -> dict: try: - return publish_service.update_publish_job_status(job_id, "published") + return PublishScheduler().mark_published_manually(job_id, payload.platform_url) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -253,7 +492,7 @@ async def mark_publish_job_published(job_id: str) -> dict: @router.post("/jobs/{job_id}/mark-failed") async def mark_publish_job_failed(job_id: str) -> dict: try: - return publish_service.update_publish_job_status(job_id, "failed", "人工标记失败") + return PublishScheduler().mark_failed_manually(job_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/app/routers/tasks.py b/app/routers/tasks.py index eeded68..193c18d 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -5,19 +5,27 @@ from app.models.task import ( ClipCandidateBatchUpdate, + ClipFeedbackCreate, ClipCandidateUpdate, SubtitleStyleUpdate, TaskAIPreferenceUpdate, TaskAIPromptPresetUpdate, TaskCandidateClipCountUpdate, TaskCreate, + TaskSelectionSettingsUpdate, TaskStatus, TaskStatusUpdate, ) from app.services import task_service from app.services.ai_prompt_preset_service import update_task_ai_prompt_preset from app.services.pipeline_engine import start_auto_pipeline -from app.services.storage_service import allocate_task_dir_name, save_uploaded_video +from app.services.storage_service import ( + allocate_task_dir_name, + remove_failed_task_directory, + save_uploaded_video, + StorageSafetyError, +) +from app.services.task_lifecycle_service import TaskDeletionConflictError from app.services import job_service from app.services import job_worker @@ -46,8 +54,10 @@ async def create_upload_task( background_tasks: BackgroundTasks, task_name: str = Form(...), platform: str = Form("general"), - max_clip_duration: int = Form(5), - candidate_clip_count: int = Form(5), + max_clip_duration: int = Form(10), + candidate_clip_count: int = Form(12), + selection_profile: str = Form("general"), + final_clip_target: int = Form(5), ai_preference: str | None = Form(None), auto_mode: bool = Form(False), auto_clip_count: str = Form("auto"), @@ -56,45 +66,58 @@ async def create_upload_task( auto_schedule_mode: str = Form("default"), auto_schedule_start_at: str | None = Form(""), auto_schedule_interval_hours: int = Form(3), - auto_schedule_daily_start_time: str = Form("09:00"), - auto_schedule_daily_end_time: str = Form("21:00"), + auto_schedule_daily_start_time: str = Form("07:00"), + auto_schedule_daily_end_time: str = Form("00:00"), auto_metadata_use_ai: bool = Form(False), video_file: UploadFile = File(...), ) -> dict: task_id = uuid4().hex[:12] task_dir_name = allocate_task_dir_name(task_name, exclude_task_id=task_id) - saved_path = save_uploaded_video( - task_id, - video_file.filename or "source_video.mp4", - video_file.file, - task_dir_name=task_dir_name, - ) - payload = TaskCreate( - task_name=task_name, - source_type="upload", - platform=platform, - original_video_path=str(saved_path), - max_clip_duration=max_clip_duration, - candidate_clip_count=candidate_clip_count, - ai_preference=ai_preference, - auto_mode=auto_mode, - auto_clip_count=auto_clip_count, - auto_min_clip_seconds=auto_min_clip_seconds, - auto_max_clip_seconds=auto_max_clip_seconds, - auto_schedule_mode=auto_schedule_mode, - auto_schedule_start_at=auto_schedule_start_at, - auto_schedule_interval_hours=auto_schedule_interval_hours, - auto_schedule_daily_start_time=auto_schedule_daily_start_time, - auto_schedule_daily_end_time=auto_schedule_daily_end_time, - auto_metadata_use_ai=auto_metadata_use_ai, - ) + task_record_created = False try: + saved_path = await run_in_threadpool( + save_uploaded_video, + task_id, + video_file.filename or "source_video.mp4", + video_file.file, + task_dir_name, + ) + payload = TaskCreate( + task_name=task_name, + source_type="upload", + platform=platform, + original_video_path=str(saved_path), + max_clip_duration=max_clip_duration, + candidate_clip_count=candidate_clip_count, + selection_profile=selection_profile, + final_clip_target=final_clip_target, + ai_preference=ai_preference, + auto_mode=auto_mode, + auto_clip_count=auto_clip_count, + auto_min_clip_seconds=auto_min_clip_seconds, + auto_max_clip_seconds=auto_max_clip_seconds, + auto_schedule_mode=auto_schedule_mode, + auto_schedule_start_at=auto_schedule_start_at, + auto_schedule_interval_hours=auto_schedule_interval_hours, + auto_schedule_daily_start_time=auto_schedule_daily_start_time, + auto_schedule_daily_end_time=auto_schedule_daily_end_time, + auto_metadata_use_ai=auto_metadata_use_ai, + ) result = task_service.create_task_record(payload, task_id=task_id, task_dir_name=task_dir_name) + task_record_created = True if payload.auto_mode: result["auto_pipeline"] = start_auto_pipeline(task_id, background_tasks=background_tasks) return result except ValueError as exc: + if not task_record_created: + await run_in_threadpool(remove_failed_task_directory, task_id, task_dir_name) raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + if not task_record_created: + await run_in_threadpool(remove_failed_task_directory, task_id, task_dir_name) + raise + finally: + await video_file.close() @router.get("/{task_id}") @@ -125,8 +148,14 @@ async def get_ai_analysis_status(task_id: str) -> dict: async def delete_task(task_id: str) -> dict: try: return task_service.soft_delete_task(task_id) + except TaskDeletionConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except StorageSafetyError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.patch("/{task_id}/status") @@ -161,6 +190,18 @@ async def patch_task_candidate_clip_count(task_id: str, payload: TaskCandidateCl raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.patch("/{task_id}/selection-settings") +async def patch_task_selection_settings(task_id: str, payload: TaskSelectionSettingsUpdate) -> dict: + try: + return task_service.update_task_selection_settings( + task_id, + payload.selection_profile, + payload.final_clip_target, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/{task_id}/process/audio") async def process_audio(task_id: str) -> dict: try: @@ -275,6 +316,23 @@ async def batch_update_clip_candidates( raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/{task_id}/clips/sync-publish") +async def sync_reviewed_clips_to_publish_center( + task_id: str, + payload: ClipCandidateBatchUpdate, +) -> dict: + try: + return await run_in_threadpool( + task_service.sync_reviewed_clips_to_publish_center, + task_id, + payload.clips, + ) + except (ValueError, FileNotFoundError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @router.get("/{task_id}/clips/{clip_id}/transcript-excerpt") async def get_clip_transcript_excerpt( task_id: str, @@ -288,6 +346,14 @@ async def get_clip_transcript_excerpt( raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.post("/{task_id}/clips/{clip_id}/feedback") +async def save_clip_feedback(task_id: str, clip_id: str, payload: ClipFeedbackCreate) -> dict: + try: + return task_service.save_clip_feedback(task_id, clip_id, payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/{task_id}/process/cuts") async def process_video_cuts(task_id: str) -> dict: try: diff --git a/app/services/ai/ai_clip_analyzer.py b/app/services/ai/ai_clip_analyzer.py index 7c1d15b..2217979 100644 --- a/app/services/ai/ai_clip_analyzer.py +++ b/app/services/ai/ai_clip_analyzer.py @@ -22,6 +22,14 @@ ANALYSIS_MAX_CONTEXT_CHARS = 4500 LOCAL_ANALYSIS_CHUNK_SECONDS = ANALYSIS_CHUNK_SECONDS LOCAL_ANALYSIS_MAX_CONTEXT_CHARS = ANALYSIS_MAX_CONTEXT_CHARS +COVER_TIME_PROMPT_REQUIREMENT = """ +【程序必填字段补充要求】 +每个 clips 项必须额外包含 cover_time_seconds。 +cover_time_seconds 表示相对于该条短视频开头的封面画面秒数,必须是数字,满足 0 <= cover_time_seconds < duration_seconds。 +请选择最能代表核心观点、笑点、冲突或人物反应的时刻,避免使用明显的片头、片尾、寒暄或空白画面。 +示例:片段从原视频 00:12:10 开始,适合的封面画面位于原视频 00:12:25,则 cover_time_seconds 应填写 15。 +只返回严格 JSON,不要解释这个补充要求。 +""".strip() class AIAnalysisError(RuntimeError): @@ -107,8 +115,9 @@ def _analyze_task_transcript_in_chunks( retry_instruction = ( "上一次输出无法被程序解析或校验。请重新输出严格 JSON," "不要 Markdown,不要解释文字。每个 clips 项必须包含:" - "clip_id、title、start_time、end_time、duration_seconds、summary、" + "clip_id、title、start_time、end_time、duration_seconds、cover_time_seconds、summary、" "highlight_reason、spread_value、suggested_editing、confidence_score、selected_by_default。" + "cover_time_seconds 是相对于短视频开头的秒数,必须大于或等于 0 且小于 duration_seconds。" "spread_value 只能是“高”“中”“低”。片段时长不能超限。" ) raw_text = provider.generate_json(prompt, retry_instruction=retry_instruction) @@ -239,7 +248,7 @@ def _render_prompt( prompt = template for key, value in replacements.items(): prompt = prompt.replace(key, value) - return prompt + return f"{prompt.rstrip()}\n\n{COVER_TIME_PROMPT_REQUIREMENT}" def _build_local_analysis_chunks(request: AnalysisRequest, transcript_text: str) -> list[TranscriptChunk]: @@ -433,6 +442,11 @@ def _normalize_ai_clip_item(clip: dict[str, Any], index: int) -> dict[str, Any]: _copy_first_text(item, "spread_value", ("viral_value", "share_value", "virality", "shareability")) _copy_first_text(item, "suggested_editing", ("editing_suggestion", "edit_suggestion", "suggestion")) _copy_first_text(item, "confidence_score", ("confidence", "score")) + _copy_first_text( + item, + "cover_time_seconds", + ("cover_second", "cover_seconds", "cover_time", "cover_timestamp_seconds", "thumbnail_time_seconds"), + ) if not _has_text(item.get("clip_id")): item["clip_id"] = f"clip_{index:03d}" @@ -454,6 +468,10 @@ def _normalize_ai_clip_item(clip: dict[str, Any], index: int) -> dict[str, Any]: duration_seconds = _duration_seconds_from_clip(item) if duration_seconds is not None: item["duration_seconds"] = duration_seconds + item["cover_time_seconds"] = _normalize_cover_time_seconds( + item.get("cover_time_seconds"), + duration_seconds, + ) item["clip_id"] = _limit_text(item["clip_id"], 80) item["title"] = _limit_text(item["title"], 160) @@ -503,6 +521,30 @@ def _normalize_confidence_score(value: Any) -> float: return min(1, max(0, score)) +def _midpoint_cover_time_seconds(duration_seconds: int | float | None) -> float: + try: + duration = float(duration_seconds or 0) + except (TypeError, ValueError): + duration = 0 + if not math.isfinite(duration) or duration <= 0: + return 0.0 + return round(max(0.0, min(duration - 0.001, duration / 2)), 3) + + +def _normalize_cover_time_seconds(value: Any, duration_seconds: int | float | None) -> float: + fallback = _midpoint_cover_time_seconds(duration_seconds) + try: + seconds = float(value) + duration = float(duration_seconds or 0) + except (TypeError, ValueError): + return fallback + if not math.isfinite(seconds) or not math.isfinite(duration): + return fallback + if seconds < 0 or duration <= 0 or seconds >= duration: + return fallback + return round(seconds, 3) + + def _normalize_spread_value(value: Any) -> str: text = str(value or "").strip().lower() if text in {"高", "中", "低"}: @@ -790,6 +832,8 @@ def _validate_clip_constraints( raise AIAnalysisError(f"{clip.clip_id} 超过用户设置的单条最长时长") if abs(real_duration - clip.duration_seconds) > 3: raise AIAnalysisError(f"{clip.clip_id} 的 duration_seconds 与起止时间不一致") + if clip.cover_time_seconds < 0 or clip.cover_time_seconds >= real_duration: + raise AIAnalysisError(f"{clip.clip_id} 的 cover_time_seconds 必须位于片段时长范围内") if start_seconds < transcript_start or end_seconds > transcript_end: raise AIAnalysisError(f"{clip.clip_id} 的起止时间超出转写文本时间范围") diff --git a/app/services/ai/variety_comedy_analyzer.py b/app/services/ai/variety_comedy_analyzer.py new file mode 100644 index 0000000..e8cb720 --- /dev/null +++ b/app/services/ai/variety_comedy_analyzer.py @@ -0,0 +1,731 @@ +"""《康熙来了》类综艺的质量优先三阶段选片。""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import re +from typing import Any + +from app.models.task import AIClipAnalysisResult +from app.services.ai.base import AIProvider +from app.services.ai.ai_clip_analyzer import ( + AIAnalysisError, + TranscriptRow, + _extract_transcript_rows, + _loads_ai_json, + _read_transcript, + _seconds_to_time, + _time_to_seconds, + build_provider, +) +from app.services.audio_reaction_service import analyze_audio_reaction +from app.services.clip_feedback_service import list_recent_feedback_context + + +REMOTE_WINDOW_SECONDS = 300 +REMOTE_WINDOW_OVERLAP_SECONDS = 60 +REMOTE_TRANSCRIPT_CHAR_BUDGET = 8_000 +LOCAL_WINDOW_SECONDS = 180 +LOCAL_WINDOW_OVERLAP_SECONDS = 45 +LOCAL_TRANSCRIPT_CHAR_BUDGET = 4_000 +RECALL_LIMIT_PER_WINDOW = 3 +EXPANSION_BATCH_SIZE_REMOTE = 3 +MAX_PRELIMINARY_MOMENTS = 18 +PREFERRED_MIN_CLIP_SECONDS = 60 +MIN_ACCEPTED_CLIP_SECONDS = 45 +MAX_COMEDY_CLIP_SECONDS = 150 +QUALITY_A_THRESHOLD = 78 +QUALITY_B_THRESHOLD = 65 +HUMOR_HARD_GATE = 75 +COMPLETENESS_HARD_GATE = 70 + + +@dataclass(frozen=True) +class ComedyAnalysisRequest: + task_id: str + transcript_path: Path + audio_path: Path + candidate_pool_limit: int + final_clip_target: int + ai_preference: str + provider_name: str + prompt_template: str | None = None + + +@dataclass(frozen=True) +class ComedyTranscriptWindow: + index: int + total: int + start_seconds: int + end_seconds: int + rows: tuple[TranscriptRow, ...] + text: str + + +def analyze_variety_comedy(request: ComedyAnalysisRequest) -> AIClipAnalysisResult: + transcript_text = _read_transcript(request.transcript_path) + rows = _extract_transcript_rows(transcript_text) + if not rows: + raise AIAnalysisError("综艺笑点分析失败:转写中没有可识别的逐句时间戳") + + provider = build_provider(request.provider_name) + windows = build_comedy_windows(rows, provider_name=request.provider_name) + preference = _preference_summary(request.prompt_template or "", request.ai_preference) + moments, recall_failures = _recall_moments(provider, windows, preference) + moments = dedupe_recall_moments(moments)[:MAX_PRELIMINARY_MOMENTS] + if not moments: + detail = ";".join(recall_failures[:3]) or "没有召回达到条件的笑点时刻" + raise AIAnalysisError(f"综艺笑点分析没有召回可用内容:{detail}") + + expanded, expansion_failures = _expand_moments( + provider, + rows, + moments, + preference, + provider_name=request.provider_name, + ) + expanded = dedupe_expanded_candidates(expanded)[:MAX_PRELIMINARY_MOMENTS] + if not expanded: + detail = ";".join(expansion_failures[:3]) or "没有形成完整的 60–150 秒内容闭环" + raise AIAnalysisError(f"综艺笑点分析没有形成完整候选:{detail}") + + for candidate in expanded: + start_seconds = _time_to_seconds(candidate["start_time"]) + end_seconds = _time_to_seconds(candidate["end_time"]) + key_seconds = _time_to_seconds(candidate["key_moment_time"]) + candidate_rows = _rows_in_range(rows, start_seconds, end_seconds) + candidate["audio_evidence"] = analyze_audio_reaction( + request.audio_path, + start_seconds, + end_seconds, + key_seconds, + candidate_rows, + ) + + feedback = list_recent_feedback_context("variety_comedy", limit=20) + judge_payload, judge_warning = _global_judge(provider, expanded, preference, feedback) + scored = [ + score_comedy_candidate(candidate, judge_payload.get(candidate["source_id"]) or {}) + for candidate in expanded + ] + scored = dedupe_scored_candidates(scored) + candidate_pool_limit = max(1, min(12, int(request.candidate_pool_limit or 12))) + kept = [item for item in scored if item["quality_tier"] in {"A", "B"}] + kept = sorted(kept, key=lambda item: item["quality_score"], reverse=True)[:candidate_pool_limit] + + a_ranked = [item for item in kept if item["quality_tier"] == "A"] + selected_ids = { + item["source_id"] + for item in a_ranked[: max(1, min(12, int(request.final_clip_target or 5)))] + } + for item in kept: + item["selected_by_default"] = item["source_id"] in selected_ids + + kept = sorted(kept, key=lambda item: _time_to_seconds(item["start_time"])) + clips = [] + for index, item in enumerate(kept, start=1): + clips.append(_to_clip_payload(item, index)) + + selected_count = sum(1 for clip in clips if clip["selected_by_default"]) + summary = ( + f"综艺笑点优先 V2 已按 {len(windows)} 个重叠窗口召回," + f"扩展并全局复评 {len(expanded)} 条,保留 {len(clips)} 条候选," + f"其中 {selected_count} 条达到 A 级并默认启用。" + ) + warnings = [*recall_failures, *expansion_failures] + if judge_warning: + warnings.append(judge_warning) + if warnings: + summary += f" 有 {len(warnings)} 个局部步骤已降级或跳过。" + return AIClipAnalysisResult(task_id=request.task_id, analysis_summary=summary, clips=clips) + + +def build_comedy_windows( + rows: list[TranscriptRow], + *, + provider_name: str, +) -> list[ComedyTranscriptWindow]: + if provider_name == "local": + duration_limit = LOCAL_WINDOW_SECONDS + overlap_seconds = LOCAL_WINDOW_OVERLAP_SECONDS + char_budget = LOCAL_TRANSCRIPT_CHAR_BUDGET + else: + duration_limit = REMOTE_WINDOW_SECONDS + overlap_seconds = REMOTE_WINDOW_OVERLAP_SECONDS + char_budget = REMOTE_TRANSCRIPT_CHAR_BUDGET + + raw_windows: list[tuple[TranscriptRow, ...]] = [] + start_index = 0 + while start_index < len(rows): + start_seconds = rows[start_index].start_seconds + current: list[TranscriptRow] = [] + current_chars = 0 + end_index = start_index + while end_index < len(rows): + row = rows[end_index] + line = _format_row(row) + exceeds_time = bool(current) and row.end_seconds - start_seconds > duration_limit + exceeds_chars = bool(current) and current_chars + len(line) + 1 > char_budget + if exceeds_time or exceeds_chars: + break + current.append(row) + current_chars += len(line) + 1 + end_index += 1 + if not current: + current = [rows[start_index]] + end_index = start_index + 1 + raw_windows.append(tuple(current)) + if end_index >= len(rows): + break + next_time = max(current[0].start_seconds + 1, current[-1].end_seconds - overlap_seconds) + next_index = start_index + 1 + while next_index < end_index and rows[next_index].end_seconds < next_time: + next_index += 1 + start_index = max(start_index + 1, next_index) + + total = len(raw_windows) + return [ + ComedyTranscriptWindow( + index=index, + total=total, + start_seconds=window_rows[0].start_seconds, + end_seconds=window_rows[-1].end_seconds, + rows=window_rows, + text="\n".join(_format_row(row) for row in window_rows), + ) + for index, window_rows in enumerate(raw_windows, start=1) + ] + + +def dedupe_recall_moments(moments: list[dict]) -> list[dict]: + selected: list[dict] = [] + for moment in sorted(moments, key=lambda item: float(item.get("recall_score") or 0), reverse=True): + key_seconds = int(moment["key_seconds"]) + topic = _normalize_topic(moment.get("topic_key") or moment.get("title") or "") + duplicate = False + for existing in selected: + existing_topic = _normalize_topic(existing.get("topic_key") or existing.get("title") or "") + if abs(key_seconds - int(existing["key_seconds"])) <= 30: + duplicate = True + break + if topic and topic == existing_topic and abs(key_seconds - int(existing["key_seconds"])) <= 120: + duplicate = True + break + if not duplicate: + selected.append(moment) + return sorted(selected, key=lambda item: int(item["key_seconds"])) + + +def normalize_clip_bounds( + start_seconds: int, + end_seconds: int, + key_seconds: int, + context_rows: list[TranscriptRow], +) -> tuple[int, int] | None: + if not context_rows: + return None + lower = context_rows[0].start_seconds + upper = context_rows[-1].end_seconds + start_seconds = max(lower, min(start_seconds, key_seconds)) + end_seconds = min(upper, max(end_seconds, key_seconds + 1)) + + if end_seconds - start_seconds < PREFERRED_MIN_CLIP_SECONDS: + missing = PREFERRED_MIN_CLIP_SECONDS - (end_seconds - start_seconds) + start_seconds = max(lower, start_seconds - (missing // 2 + missing % 2)) + end_seconds = min(upper, end_seconds + missing // 2) + if end_seconds - start_seconds < PREFERRED_MIN_CLIP_SECONDS: + if start_seconds == lower: + end_seconds = min(upper, start_seconds + PREFERRED_MIN_CLIP_SECONDS) + else: + start_seconds = max(lower, end_seconds - PREFERRED_MIN_CLIP_SECONDS) + + if end_seconds - start_seconds > MAX_COMEDY_CLIP_SECONDS: + start_seconds = max(lower, key_seconds - 60) + end_seconds = min(upper, start_seconds + MAX_COMEDY_CLIP_SECONDS) + if end_seconds <= key_seconds: + end_seconds = min(upper, key_seconds + 90) + start_seconds = max(lower, end_seconds - MAX_COMEDY_CLIP_SECONDS) + + start_seconds = _nearest_boundary(start_seconds, context_rows, use_start=True) + end_seconds = _nearest_boundary(end_seconds, context_rows, use_start=False) + start_seconds, end_seconds = _expand_bounds_to_min_duration( + start_seconds, + end_seconds, + context_rows, + PREFERRED_MIN_CLIP_SECONDS, + ) + duration = end_seconds - start_seconds + if duration < MIN_ACCEPTED_CLIP_SECONDS or duration > MAX_COMEDY_CLIP_SECONDS: + return None + if not start_seconds <= key_seconds < end_seconds: + return None + return start_seconds, end_seconds + + +def dedupe_expanded_candidates(candidates: list[dict]) -> list[dict]: + ranked = sorted( + candidates, + key=lambda item: float(item.get("humor_score") or 0) + float(item.get("completeness_score") or 0), + reverse=True, + ) + selected: list[dict] = [] + for candidate in ranked: + if any(_is_duplicate_candidate(candidate, existing, overlap_threshold=0.4) for existing in selected): + continue + selected.append(candidate) + return sorted(selected, key=lambda item: _time_to_seconds(item["start_time"])) + + +def score_comedy_candidate(candidate: dict, judge: dict) -> dict: + humor = _score_value(judge.get("humor_score"), candidate.get("humor_score"), default=50) + interaction = _score_value( + judge.get("interaction_reaction_score"), + candidate.get("interaction_reaction_score"), + default=50, + ) + completeness = _score_value( + judge.get("completeness_score"), + candidate.get("completeness_score"), + default=50, + ) + hook = _score_value(judge.get("hook_score"), candidate.get("hook_score"), default=50) + novelty = _score_value(judge.get("novelty_score"), candidate.get("novelty_score"), default=50) + title = _score_value(judge.get("title_score"), candidate.get("title_score"), default=50) + text_score = round( + humor * 0.30 + + interaction * 0.20 + + completeness * 0.20 + + hook * 0.10 + + novelty * 0.10 + + title * 0.10, + 1, + ) + audio = candidate.get("audio_evidence") or {} + audio_available = bool(audio.get("available")) + audio_score = _score_value(audio.get("score"), default=0) + weighted_score = text_score * 0.75 + audio_score * 0.25 + # 音频是辅助加分项:反应信号弱或缺失时,不反向扣减已经成立的文字质量分。 + quality_score = round(max(text_score, weighted_score), 1) if audio_available else text_score + + hard_gate_passed = humor >= HUMOR_HARD_GATE and completeness >= COMPLETENESS_HARD_GATE + if quality_score >= QUALITY_A_THRESHOLD and hard_gate_passed: + tier = "A" + rejection_reason = "" + elif quality_score >= QUALITY_B_THRESHOLD: + tier = "B" + rejection_reason = str(judge.get("rejection_reason") or "未同时达到笑点闭环、完整度和 A 级总分门槛") + else: + tier = "C" + rejection_reason = str(judge.get("rejection_reason") or "综合质量分低于候选门槛") + + evidence = { + "why_selected": str(judge.get("why_selected") or candidate.get("highlight_reason") or ""), + "arc_structure": str(judge.get("arc_structure") or candidate.get("arc_structure") or ""), + "score_breakdown": { + "humor": humor, + "interaction_reaction": interaction, + "completeness": completeness, + "hook": hook, + "novelty": novelty, + "title": title, + "text_quality": text_score, + "audio_reaction": audio_score, + "final": quality_score, + }, + "audio": audio, + } + return { + **candidate, + "title": str(judge.get("title") or candidate.get("title") or "综艺笑点候选")[:160], + "topic_key": str(judge.get("topic_key") or candidate.get("topic_key") or "")[:120], + "humor_score": humor, + "interaction_reaction_score": interaction, + "completeness_score": completeness, + "hook_score": hook, + "novelty_score": novelty, + "title_score": title, + "text_quality_score": text_score, + "audio_reaction_score": audio_score, + "quality_score": quality_score, + "quality_tier": tier, + "quality_evidence": evidence, + "rejection_reason": rejection_reason, + "selected_by_default": False, + } + + +def dedupe_scored_candidates(candidates: list[dict]) -> list[dict]: + selected: list[dict] = [] + for candidate in sorted(candidates, key=lambda item: item["quality_score"], reverse=True): + if any(_is_duplicate_candidate(candidate, existing, overlap_threshold=0.3) for existing in selected): + continue + selected.append(candidate) + return selected + + +def _recall_moments( + provider: AIProvider, + windows: list[ComedyTranscriptWindow], + preference: str, +) -> tuple[list[dict], list[str]]: + moments: list[dict] = [] + failures = [] + for window in windows: + prompt = _recall_prompt(window, preference) + try: + payload = _generate_payload(provider, prompt, expected_key="moments") + raw_moments = payload.get("moments") or [] + if not isinstance(raw_moments, list): + raise AIAnalysisError("moments 不是数组") + for index, item in enumerate(raw_moments[:RECALL_LIMIT_PER_WINDOW], start=1): + if not isinstance(item, dict): + continue + key_text = _first_time(item, ("key_time", "key_moment_time", "moment_time", "start_time")) + if not key_text: + continue + key_seconds = _time_to_seconds(key_text) + if key_seconds < window.start_seconds or key_seconds > window.end_seconds: + continue + moments.append( + { + "source_id": f"w{window.index:03d}_m{index:02d}", + "key_time": _seconds_to_time(key_seconds), + "key_seconds": key_seconds, + "title": str(item.get("title") or item.get("hook") or "综艺笑点")[:160], + "topic_key": str(item.get("topic_key") or item.get("title") or "")[:120], + "humor_reason": str(item.get("humor_reason") or item.get("reason") or "")[:1000], + "recall_score": _score_value(item.get("recall_score"), default=60), + } + ) + except Exception as exc: + failures.append(f"召回窗口 {window.index}/{window.total} 跳过:{exc}") + return moments, failures + + +def _expand_moments( + provider: AIProvider, + rows: list[TranscriptRow], + moments: list[dict], + preference: str, + *, + provider_name: str, +) -> tuple[list[dict], list[str]]: + expanded = [] + failures = [] + batch_size = 1 if provider_name == "local" else EXPANSION_BATCH_SIZE_REMOTE + for offset in range(0, len(moments), batch_size): + batch = moments[offset : offset + batch_size] + contexts = [] + context_rows_by_id: dict[str, list[TranscriptRow]] = {} + for moment in batch: + context_rows = _rows_in_range( + rows, + max(rows[0].start_seconds, int(moment["key_seconds"]) - 120), + min(rows[-1].end_seconds, int(moment["key_seconds"]) + 150), + ) + context_rows_by_id[moment["source_id"]] = context_rows + contexts.append( + { + "source_id": moment["source_id"], + "key_time": moment["key_time"], + "title": moment["title"], + "reason": moment["humor_reason"], + "transcript": "\n".join(_format_row(row) for row in context_rows), + } + ) + try: + payload = _generate_payload(provider, _expansion_prompt(contexts, preference), expected_key="clips") + raw_clips = payload.get("clips") or [] + if not isinstance(raw_clips, list): + raise AIAnalysisError("clips 不是数组") + for item in raw_clips: + if not isinstance(item, dict): + continue + source_id = str(item.get("source_id") or "") + moment = next((value for value in batch if value["source_id"] == source_id), None) + context_rows = context_rows_by_id.get(source_id) or [] + if not moment or not context_rows: + continue + start_text = _first_time(item, ("start_time",)) + end_text = _first_time(item, ("end_time",)) + key_text = _first_time(item, ("key_moment_time", "key_time")) or moment["key_time"] + if not start_text or not end_text: + continue + bounds = normalize_clip_bounds( + _time_to_seconds(start_text), + _time_to_seconds(end_text), + _time_to_seconds(key_text), + context_rows, + ) + if not bounds: + continue + start_seconds, end_seconds = bounds + key_seconds = min(end_seconds - 1, max(start_seconds, _time_to_seconds(key_text))) + expanded.append( + { + "source_id": source_id, + "title": str(item.get("title") or moment["title"])[:160], + "start_time": _seconds_to_time(start_seconds), + "end_time": _seconds_to_time(end_seconds), + "duration_seconds": end_seconds - start_seconds, + "key_moment_time": _seconds_to_time(key_seconds), + "topic_key": str(item.get("topic_key") or moment["topic_key"])[:120], + "summary": str(item.get("summary") or moment["humor_reason"] or moment["title"])[:1000], + "highlight_reason": str(item.get("highlight_reason") or moment["humor_reason"] or "")[:1000], + "arc_structure": str(item.get("arc_structure") or "")[:1000], + "suggested_editing": str(item.get("suggested_editing") or "保留铺垫、笑点和笑点后的反应,压缩无关停顿。")[:1000], + "humor_score": _score_value(item.get("humor_score"), default=60), + "interaction_reaction_score": _score_value(item.get("interaction_reaction_score"), default=60), + "completeness_score": _score_value(item.get("completeness_score"), default=60), + "hook_score": _score_value(item.get("hook_score"), default=55), + "novelty_score": _score_value(item.get("novelty_score"), default=55), + "title_score": _score_value(item.get("title_score"), default=55), + } + ) + except Exception as exc: + failures.append(f"上下文扩展批次 {offset // batch_size + 1} 跳过:{exc}") + return expanded, failures + + +def _global_judge( + provider: AIProvider, + candidates: list[dict], + preference: str, + feedback: list[dict], +) -> tuple[dict[str, dict], str]: + prompt_candidates = [] + for item in candidates: + prompt_candidates.append( + { + "source_id": item["source_id"], + "title": item["title"], + "time_range": f"{item['start_time']}-{item['end_time']}", + "duration_seconds": item["duration_seconds"], + "topic_key": item["topic_key"], + "summary": item["summary"], + "highlight_reason": item["highlight_reason"], + "arc_structure": item["arc_structure"], + "audio_reaction": item.get("audio_evidence") or {}, + } + ) + try: + payload = _generate_payload( + provider, + _judge_prompt(prompt_candidates, preference, feedback), + expected_key="ranked_clips", + ) + raw_items = payload.get("ranked_clips") or payload.get("clips") or [] + if not isinstance(raw_items, list): + raise AIAnalysisError("ranked_clips 不是数组") + return { + str(item.get("source_id")): item + for item in raw_items + if isinstance(item, dict) and item.get("source_id") + }, "" + except Exception as exc: + return {}, f"全局评审调用失败,已使用扩展阶段评分降级:{exc}" + + +def _to_clip_payload(item: dict, index: int) -> dict: + start_seconds = _time_to_seconds(item["start_time"]) + key_seconds = _time_to_seconds(item["key_moment_time"]) + duration = int(item["duration_seconds"]) + cover_time = max(0.0, min(duration - 0.001, float(key_seconds - start_seconds))) + return { + "clip_id": f"clip_{index:03d}", + "title": item["title"], + "start_time": item["start_time"], + "end_time": item["end_time"], + "duration_seconds": duration, + "cover_time_seconds": round(cover_time, 3), + "summary": item["summary"], + "highlight_reason": item["highlight_reason"], + "spread_value": "高" if item["quality_tier"] == "A" else "中", + "suggested_editing": item["suggested_editing"], + "confidence_score": round(float(item["quality_score"]) / 100, 4), + "selected_by_default": bool(item["selected_by_default"]), + "quality_tier": item["quality_tier"], + "quality_score": item["quality_score"], + "text_quality_score": item["text_quality_score"], + "humor_score": item["humor_score"], + "completeness_score": item["completeness_score"], + "audio_reaction_score": item["audio_reaction_score"], + "topic_key": item["topic_key"], + "key_moment_time": item["key_moment_time"], + "quality_evidence": item["quality_evidence"], + "rejection_reason": item["rejection_reason"], + } + + +def _generate_payload(provider: AIProvider, prompt: str, *, expected_key: str) -> dict: + raw = provider.generate_json(prompt) + try: + payload = _loads_ai_json(raw) + except AIAnalysisError as first_error: + raw = provider.generate_json( + prompt, + retry_instruction=f"上一次输出无法解析。只返回严格 JSON,并确保包含 {expected_key} 数组。", + ) + try: + payload = _loads_ai_json(raw) + except AIAnalysisError as second_error: + raise AIAnalysisError(str(second_error)) from first_error + if not isinstance(payload, dict): + raise AIAnalysisError("AI 输出必须是 JSON 对象") + return payload + + +def _recall_prompt(window: ComedyTranscriptWindow, preference: str) -> str: + return f"""你是《康熙来了》笑点召回编辑。现在只做宽召回,不做凑数,不输出完整切片。 +从这一个约 5 分钟且与相邻窗口重叠的逐句转写中,找出 0-{RECALL_LIMIT_PER_WINDOW} 个真正可能成立的笑点时刻。 +必须有反转、尴尬、意外回答、主持人补刀或明显现场反应;纯八卦、纯身体话题、平铺直叙不算好笑。 +{preference} +只输出:{{"moments":[{{"key_time":"HH:MM:SS","title":"短标题","topic_key":"同一故事的稳定短标识","humor_reason":"为什么可能好笑","recall_score":0}}]}} +时间必须来自转写;没有合适内容就返回空数组。 + +窗口 {window.index}/{window.total},范围 {_seconds_to_time(window.start_seconds)}-{_seconds_to_time(window.end_seconds)}: +{window.text}""" + + +def _expansion_prompt(contexts: list[dict], preference: str) -> str: + return f"""你是综艺短视频剪辑导演。请围绕每个已召回笑点,从各自前后文中形成一条完整片段。 +默认 60-150 秒,必须包含必要铺垫、核心笑点/反转、笑点后的追问/补刀/解释/笑声和自然收尾。 +不要把同一笑点拆成多条,不要输出只有一句包袱或只有背景信息的片段。 +{preference} +只输出严格 JSON:{{"clips":[{{"source_id":"原值","title":"标题","start_time":"HH:MM:SS","end_time":"HH:MM:SS","key_moment_time":"HH:MM:SS","topic_key":"话题标识","summary":"情境与看点","highlight_reason":"具体笑点","arc_structure":"铺垫→笑点→反应→收尾","suggested_editing":"剪辑建议","humor_score":0,"interaction_reaction_score":0,"completeness_score":0,"hook_score":0,"novelty_score":0,"title_score":0}}]}} +所有分数为 0-100,不要虚高;时间必须来自对应转写。 + +待扩展内容: +{json.dumps(contexts, ensure_ascii=False)}""" + + +def _judge_prompt(candidates: list[dict], preference: str, feedback: list[dict]) -> str: + feedback_summary = [ + { + "decision": item.get("decision"), + "reason": item.get("reason_code"), + "title": item.get("title_snapshot"), + "note": item.get("note"), + } + for item in feedback + ] + return f"""你是《康熙来了》短视频总编。请把所有候选放在一起横向比较,重点淘汰“不够好笑但话题看似刺激”的内容。 +同一故事、相邻时间或同一笑点只能保留最完整的一条。音频信号只是辅助证据,不能弥补笑点闭环和完整度不足。 +{preference} +参考用户近期审片反馈:{json.dumps(feedback_summary, ensure_ascii=False)} + +只输出严格 JSON:{{"ranked_clips":[{{"source_id":"原值","title":"可优化标题","topic_key":"统一后的话题标识","humor_score":0,"interaction_reaction_score":0,"completeness_score":0,"hook_score":0,"novelty_score":0,"title_score":0,"arc_structure":"铺垫→笑点→反应→收尾","why_selected":"为什么值得发","rejection_reason":"若不足则说明原因"}}]}} +所有候选都要返回,所有分数为 0-100,不要虚高。 + +候选:{json.dumps(candidates, ensure_ascii=False)}""" + + +def _preference_summary(prompt_template: str, ai_preference: str) -> str: + prompt = (prompt_template or "").replace("{{AI_PREFERENCE}}", ai_preference or "") + for marker in ("# Output Format", "【输出格式】", "输出 JSON", "转写文本:", "# Transcript", "{{TRANSCRIPT_TEXT}}"): + if marker in prompt: + prompt = prompt.split(marker, 1)[0] + prompt = " ".join(prompt.split())[:2500] + extra = " ".join((ai_preference or "").split())[:500] + parts = [] + if prompt: + parts.append(f"本任务既有选片偏好:{prompt}") + if extra and extra not in prompt: + parts.append(f"用户补充偏好:{extra}") + return "\n".join(parts) + + +def _rows_in_range(rows: list[TranscriptRow], start_seconds: int, end_seconds: int) -> list[TranscriptRow]: + return [row for row in rows if row.end_seconds >= start_seconds and row.start_seconds <= end_seconds] + + +def _format_row(row: TranscriptRow) -> str: + return f"{row.start_time} - {row.end_time} {row.text}" + + +def _first_time(item: dict, keys: tuple[str, ...]) -> str: + for key in keys: + value = str(item.get(key) or "").strip() + if re.fullmatch(r"(?:\d{2}:)?\d{2}:\d{2}", value): + return value + return "" + + +def _nearest_boundary(seconds: int, rows: list[TranscriptRow], *, use_start: bool) -> int: + values = [row.start_seconds if use_start else row.end_seconds for row in rows] + return min(values, key=lambda value: abs(value - seconds)) + + +def _expand_bounds_to_min_duration( + start_seconds: int, + end_seconds: int, + rows: list[TranscriptRow], + minimum_seconds: int, +) -> tuple[int, int]: + if end_seconds - start_seconds >= minimum_seconds: + return start_seconds, end_seconds + + lower = rows[0].start_seconds + upper = rows[-1].end_seconds + missing = minimum_seconds - (end_seconds - start_seconds) + desired_start = max(lower, start_seconds - (missing // 2 + missing % 2)) + desired_end = min(upper, end_seconds + missing // 2) + start_boundaries = sorted({row.start_seconds for row in rows}) + end_boundaries = sorted({row.end_seconds for row in rows}) + start_seconds = max((value for value in start_boundaries if value <= desired_start), default=lower) + end_seconds = min((value for value in end_boundaries if value >= desired_end), default=upper) + + if end_seconds - start_seconds < minimum_seconds: + desired_end = min(upper, start_seconds + minimum_seconds) + end_seconds = min((value for value in end_boundaries if value >= desired_end), default=upper) + if end_seconds - start_seconds < minimum_seconds: + desired_start = max(lower, end_seconds - minimum_seconds) + start_seconds = max((value for value in start_boundaries if value <= desired_start), default=lower) + return start_seconds, end_seconds + + +def _score_value(*values: Any, default: float) -> float: + for value in values: + if value is None or value == "": + continue + try: + number = float(value) + except (TypeError, ValueError): + continue + if 0 <= number <= 1: + number *= 100 + return round(max(0.0, min(100.0, number)), 1) + return float(default) + + +def _normalize_topic(value: str) -> str: + return re.sub(r"[^\w\u4e00-\u9fff]+", "", str(value or "").lower())[:80] + + +def _is_duplicate_candidate(first: dict, second: dict, *, overlap_threshold: float) -> bool: + first_start = _time_to_seconds(first["start_time"]) + first_end = _time_to_seconds(first["end_time"]) + second_start = _time_to_seconds(second["start_time"]) + second_end = _time_to_seconds(second["end_time"]) + overlap = max(0, min(first_end, second_end) - max(first_start, second_start)) + shorter = max(1, min(first_end - first_start, second_end - second_start)) + if overlap / shorter >= overlap_threshold: + return True + first_topic = _normalize_topic(first.get("topic_key") or first.get("title") or "") + second_topic = _normalize_topic(second.get("topic_key") or second.get("title") or "") + gap = max(0, max(first_start, second_start) - min(first_end, second_end)) + return bool(first_topic and first_topic == second_topic and gap <= 90) + + +__all__ = [ + "ComedyAnalysisRequest", + "analyze_variety_comedy", + "build_comedy_windows", + "dedupe_expanded_candidates", + "dedupe_recall_moments", + "dedupe_scored_candidates", + "normalize_clip_bounds", + "score_comedy_candidate", +] diff --git a/app/services/ai_analysis_workflow_service.py b/app/services/ai_analysis_workflow_service.py index da3d7fc..63d3aa0 100644 --- a/app/services/ai_analysis_workflow_service.py +++ b/app/services/ai_analysis_workflow_service.py @@ -18,6 +18,7 @@ inspect_local_analysis_plan, result_to_jsonable, ) +from app.services.ai.variety_comedy_analyzer import ComedyAnalysisRequest, analyze_variety_comedy from app.services.ai.diagnostics import ensure_local_ai_ready from app.services.ai_prompt_preset_service import get_task_ai_prompt_preset from app.services.storage_service import get_artifact_paths @@ -179,10 +180,13 @@ def _insert_clip_candidates_with_connection(connection, task_id: str, clips: lis """ INSERT INTO clip_candidates ( id, task_id, clip_key, title, start_time, end_time, duration_seconds, - summary, reason, highlight_reason, spread_value, suggested_editing, - confidence_score, selected_by_default, enabled, reviewed, created_at, updated_at + cover_time_seconds, summary, reason, highlight_reason, spread_value, suggested_editing, + confidence_score, quality_tier, quality_score, text_quality_score, humor_score, + completeness_score, audio_reaction_score, topic_key, key_moment_time, + quality_evidence_json, rejection_reason, + selected_by_default, enabled, reviewed, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( database_id or f"{task_id}_clip_{index:03d}", @@ -192,14 +196,26 @@ def _insert_clip_candidates_with_connection(connection, task_id: str, clips: lis clip["start_time"], clip["end_time"], clip["duration_seconds"], + clip.get("cover_time_seconds"), clip["summary"], clip["highlight_reason"], clip["highlight_reason"], clip["spread_value"], clip["suggested_editing"], clip["confidence_score"], + clip.get("quality_tier") or "", + float(clip.get("quality_score") or 0), + float(clip.get("text_quality_score") or 0), + float(clip.get("humor_score") or 0), + float(clip.get("completeness_score") or 0), + float(clip.get("audio_reaction_score") or 0), + clip.get("topic_key") or "", + clip.get("key_moment_time") or "", + json.dumps(clip.get("quality_evidence") or {}, ensure_ascii=False), + clip.get("rejection_reason") or "", 1 if selected_by_default else 0, 1 if selected_by_default else 0, + 0, now, now, ), @@ -210,9 +226,6 @@ def _replace_clip_candidates(task_id: str, clips: list[dict]) -> None: """在同一个事务里替换候选片段,失败时保留原结果。""" from app.services.task_service import _now_iso - if not clips: - raise ValueError("AI 没有生成可保存的候选片段") - now = _now_iso() with get_connection() as connection: connection.execute("DELETE FROM clip_candidates WHERE task_id = ?", (task_id,)) @@ -229,6 +242,7 @@ def _summarize_analysis_clips(clips: list[dict]) -> list[dict]: "start_time": clip.get("start_time") or "", "end_time": clip.get("end_time") or "", "duration_seconds": int(clip.get("duration_seconds") or 0), + "cover_time_seconds": clip.get("cover_time_seconds"), } ) return summaries @@ -438,7 +452,7 @@ def _ensure_ai_analysis_history_from_current_file(task_id: str) -> None: model=meta.get("model") or _ai_model_name(provider), fallback_notice="", prompt_preset=prompt_preset, - requested_clip_count=len(clips) or int(task.get("candidate_clip_count") or 5), + requested_clip_count=len(clips) or int(task.get("candidate_clip_count") or 12), ) @@ -528,6 +542,33 @@ def _analyze_with_provider(task_id: str, task: dict, paths: dict[str, Path], pro if not prompt_template: raise AIAnalysisError(f"当前选择的 AI Prompt 方案\"{prompt_preset.get('name')}\"还没有填写 Prompt 内容") + append_task_log(task_id, f"AI Prompt 方案:{prompt_preset.get('slot')}号 - {prompt_preset.get('name')}") + if provider_name == "local": + ensure_local_ai_ready() + + if task.get("selection_profile") == "variety_comedy": + window_seconds = 180 if provider_name == "local" else 300 + overlap_seconds = 45 if provider_name == "local" else 60 + append_task_log( + task_id, + "综艺笑点优先 V2:" + f"{window_seconds // 60} 分钟重叠召回窗口,重叠 {overlap_seconds} 秒;" + f"候选池最多 {min(12, int(task['candidate_clip_count']))} 条," + f"最终最多启用 {int(task.get('final_clip_target') or 5)} 条 A 级片段", + ) + return analyze_variety_comedy( + ComedyAnalysisRequest( + task_id=task_id, + transcript_path=paths["transcript_path"], + audio_path=paths["audio_path"], + candidate_pool_limit=int(task["candidate_clip_count"]), + final_clip_target=int(task.get("final_clip_target") or 5), + ai_preference=task.get("ai_preference") or "", + prompt_template=prompt_template, + provider_name=provider_name, + ) + ) + request = AnalysisRequest( task_id=task_id, transcript_path=paths["transcript_path"], @@ -537,9 +578,6 @@ def _analyze_with_provider(task_id: str, task: dict, paths: dict[str, Path], pro prompt_template=prompt_template, provider_name=provider_name, ) - append_task_log(task_id, f"AI Prompt 方案:{prompt_preset.get('slot')}号 - {prompt_preset.get('name')}") - if provider_name == "local": - ensure_local_ai_ready() plan = inspect_local_analysis_plan(request) provider_label = _ai_provider_label(provider_name) append_task_log( @@ -618,6 +656,8 @@ def process_task_ai_analysis(task_id: str, provider: str | None = None) -> dict: "provider": used_provider, "provider_label": _ai_provider_label(used_provider), "model": _ai_model_name(used_provider), + "selection_profile": task.get("selection_profile") or "general", + "final_clip_target": int(task.get("final_clip_target") or 5), "generated_at": _now_iso(), } prompt_preset = get_task_ai_prompt_preset(task_id) diff --git a/app/services/audio_reaction_service.py b/app/services/audio_reaction_service.py new file mode 100644 index 0000000..71f5912 --- /dev/null +++ b/app/services/audio_reaction_service.py @@ -0,0 +1,228 @@ +"""轻量音频反应特征。 + +这里只计算音量、动态、停顿和短句密度等代理信号,不把它描述成精确的 +笑声分类或说话人识别。 +""" + +from __future__ import annotations + +from array import array +import math +from pathlib import Path +import re +import shutil +import statistics +import subprocess +import wave +from typing import Any + +from app.core.config import settings + + +REACTION_SAMPLE_RATE = 16_000 +REACTION_FRAME_SECONDS = 0.1 +_LAUGHTER_PATTERN = re.compile(r"(?:哈){2,}|哈哈|笑死|爆笑|大笑|笑声") + + +def analyze_audio_reaction( + audio_path: Path, + start_seconds: float, + end_seconds: float, + key_moment_seconds: float | None, + transcript_rows: list[Any], +) -> dict[str, Any]: + duration = max(0.0, float(end_seconds) - float(start_seconds)) + if duration <= 0 or not audio_path.exists(): + return _unavailable("未找到可分析的音频") + + try: + samples = _read_pcm_samples(audio_path, start_seconds, duration) + except Exception as exc: + return _unavailable(f"音频反应分析已降级:{exc}") + if not samples: + return _unavailable("音频片段为空") + + rms_frames = _rms_frames(samples, REACTION_SAMPLE_RATE) + if not rms_frames: + return _unavailable("没有计算到有效音量帧") + + texts = [str(getattr(row, "text", "") or "") for row in transcript_rows] + laughter_tokens = len(_LAUGHTER_PATTERN.findall(" ".join(texts))) + rapid_turns = sum( + 1 + for row in transcript_rows + if _row_duration(row) <= 3.0 and 0 < len(str(getattr(row, "text", "") or "").strip()) <= 20 + ) + turns_per_minute = rapid_turns / max(duration / 60, 0.25) + + median_rms = statistics.median(rms_frames) + p90_rms = _percentile(rms_frames, 0.9) + mean_rms = statistics.fmean(rms_frames) + dynamic_ratio = statistics.pstdev(rms_frames) / max(mean_rms, 1.0) + silence_threshold = max(180.0, median_rms * 0.35) + silence_ratio = sum(value <= silence_threshold for value in rms_frames) / len(rms_frames) + + reaction_ratio = _reaction_ratio( + rms_frames, + start_seconds=start_seconds, + key_moment_seconds=key_moment_seconds, + ) + laughter_component = min(35.0, laughter_tokens * 18.0) + burst_component = min(25.0, max(0.0, (reaction_ratio - 1.0) / 1.5 * 25.0)) + dynamic_component = min(15.0, dynamic_ratio / 0.9 * 15.0) + pause_component = ( + min(10.0, silence_ratio / 0.18 * 10.0) + if 0.02 <= silence_ratio <= 0.4 + else 0.0 + ) + turn_component = min(15.0, turns_per_minute / 12.0 * 15.0) + score = round( + laughter_component + + burst_component + + dynamic_component + + pause_component + + turn_component, + 1, + ) + + labels = [] + if laughter_tokens: + labels.append(f"转写命中 {laughter_tokens} 处笑声词") + if reaction_ratio >= 1.35: + labels.append("笑点后出现明显音量反应") + if dynamic_ratio >= 0.45: + labels.append("现场声音动态变化明显") + if pause_component >= 5: + labels.append("片段中存在短暂停顿与节奏变化") + if turns_per_minute >= 8: + labels.append("短句往返较密集") + if not labels: + labels.append("未检测到明显现场反应代理信号") + + return { + "available": True, + "score": score, + "laughter_token_count": laughter_tokens, + "reaction_loudness_ratio": round(reaction_ratio, 3), + "dynamic_ratio": round(dynamic_ratio, 3), + "silence_ratio": round(silence_ratio, 3), + "rapid_turns_per_minute": round(turns_per_minute, 2), + "median_rms": round(median_rms, 2), + "peak_rms": round(p90_rms, 2), + "component_scores": { + "laughter_words": round(laughter_component, 1), + "post_moment_burst": round(burst_component, 1), + "dynamics": round(dynamic_component, 1), + "pauses": round(pause_component, 1), + "rapid_turns": round(turn_component, 1), + }, + "labels": labels, + } + + +def _read_pcm_samples(audio_path: Path, start_seconds: float, duration_seconds: float) -> array: + ffmpeg_path = shutil.which("ffmpeg") + if ffmpeg_path: + command = [ + ffmpeg_path, + "-hide_banner", + "-loglevel", + "error", + "-ss", + f"{max(0.0, start_seconds):.3f}", + "-i", + str(audio_path), + "-t", + f"{duration_seconds:.3f}", + "-vn", + "-ac", + "1", + "-ar", + str(REACTION_SAMPLE_RATE), + "-f", + "s16le", + "pipe:1", + ] + result = subprocess.run( + command, + capture_output=True, + timeout=min(settings.ffmpeg_chunk_timeout, 180), + ) + if result.returncode == 0 and result.stdout: + samples = array("h") + samples.frombytes(result.stdout[: len(result.stdout) - (len(result.stdout) % 2)]) + return samples + detail = result.stderr.decode("utf-8", errors="replace").strip() + if audio_path.suffix.lower() != ".wav": + raise RuntimeError(detail or "FFmpeg 无法读取音频") + + if audio_path.suffix.lower() != ".wav": + raise RuntimeError("FFmpeg 不可用,且音频不是 WAV") + return _read_wave_samples(audio_path, start_seconds, duration_seconds) + + +def _read_wave_samples(audio_path: Path, start_seconds: float, duration_seconds: float) -> array: + with wave.open(str(audio_path), "rb") as handle: + if handle.getsampwidth() != 2: + raise RuntimeError("WAV 不是 16 位 PCM,无法使用轻量降级读取") + source_rate = handle.getframerate() + channels = handle.getnchannels() + handle.setpos(min(handle.getnframes(), int(max(0, start_seconds) * source_rate))) + raw = handle.readframes(int(duration_seconds * source_rate)) + source = array("h") + source.frombytes(raw[: len(raw) - (len(raw) % 2)]) + if channels > 1: + source = array("h", (source[index] for index in range(0, len(source), channels))) + if source_rate == REACTION_SAMPLE_RATE: + return source + step = max(1, round(source_rate / REACTION_SAMPLE_RATE)) + return array("h", source[::step]) + + +def _rms_frames(samples: array, sample_rate: int) -> list[float]: + frame_size = max(1, int(sample_rate * REACTION_FRAME_SECONDS)) + values = [] + for offset in range(0, len(samples), frame_size): + frame = samples[offset : offset + frame_size] + if not frame: + continue + mean_square = sum(float(value) * float(value) for value in frame) / len(frame) + values.append(math.sqrt(mean_square)) + return values + + +def _reaction_ratio( + rms_frames: list[float], + *, + start_seconds: float, + key_moment_seconds: float | None, +) -> float: + if key_moment_seconds is None: + return _percentile(rms_frames, 0.9) / max(statistics.median(rms_frames), 1.0) + key_index = int(max(0.0, key_moment_seconds - start_seconds) / REACTION_FRAME_SECONDS) + before = rms_frames[max(0, key_index - 20) : max(1, key_index)] + after = rms_frames[key_index : min(len(rms_frames), key_index + 80)] + if not before or not after: + return _percentile(rms_frames, 0.9) / max(statistics.median(rms_frames), 1.0) + return _percentile(after, 0.9) / max(statistics.median(before), 1.0) + + +def _row_duration(row: Any) -> float: + start = float(getattr(row, "start_seconds", 0) or 0) + end = float(getattr(row, "end_seconds", start) or start) + return max(0.0, end - start) + + +def _percentile(values: list[float], fraction: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + index = min(len(ordered) - 1, max(0, round((len(ordered) - 1) * fraction))) + return ordered[index] + + +def _unavailable(reason: str) -> dict[str, Any]: + return {"available": False, "score": 0.0, "reason": reason, "labels": [reason]} + + +__all__ = ["analyze_audio_reaction"] diff --git a/app/services/auto_publish_service.py b/app/services/auto_publish_service.py index 4085513..507399b 100644 --- a/app/services/auto_publish_service.py +++ b/app/services/auto_publish_service.py @@ -7,7 +7,8 @@ from app.core.config import settings from app.db.database import get_connection -from app.services.publish_service import DEFAULT_BILIBILI_TID, get_publish_job +from app.services.publish_service import DEFAULT_BILIBILI_TID, USER_REMOVED_ERROR_CODE, get_publish_job +from app.services.publish_domain import validate_publish_mode, validate_target_platform from app.services.task_service import _now_iso @@ -31,33 +32,74 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: for item in scheduled_items: output_clip = item["output_clip"] metadata = item["metadata"] - target_platform = metadata["platform"] - platform = settings.publish_scheduler_default_platform or target_platform + cover = item.get("cover") or {} + platform = validate_target_platform(metadata["platform"]) + publish_mode = validate_publish_mode(settings.publish_default_mode) + latest = connection.execute( + """ + SELECT id, status, error_code + FROM publish_jobs + WHERE output_clip_id = ? AND platform = ? + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC, created_at DESC, id DESC + LIMIT 1 + """, + (output_clip["id"], platform), + ).fetchone() + if ( + latest + and str(latest["status"] or "").upper() == "CANCELLED" + and str(latest["error_code"] or "") == USER_REMOVED_ERROR_CODE + ): + skipped_ids.append(latest["id"]) + continue existing = connection.execute( """ SELECT id FROM publish_jobs - WHERE output_clip_id = ? AND platform = ? AND publish_mode = 'manual_export' + WHERE output_clip_id = ? AND platform = ? AND publish_mode = ? + AND status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING', 'NEED_REVIEW') ORDER BY created_at DESC LIMIT 1 """, - (output_clip["id"], platform), + (output_clip["id"], platform, publish_mode), ).fetchone() if existing: skipped_ids.append(existing["id"]) continue + cover_file_path = str(cover.get("cover_file_path") or "").strip() + if not cover_file_path: + raise ValueError(f"{output_clip.get('id') or '未知切片'} 没有生成封面,已停止创建不完整的发布任务") + cover_time_seconds = float(cover.get("cover_time_seconds") or 0) + account = connection.execute( + """ + SELECT id FROM publish_accounts + WHERE platform = ? AND login_status = 'normal' + ORDER BY COALESCE(last_login_at, updated_at) DESC LIMIT 1 + """, + (platform,), + ).fetchone() + account_id = account["id"] if account else None scheduled_at = str(item.get("scheduled_at") or "").strip() - status = "NEED_REVIEW" if metadata.get("risk_flags") else ("SCHEDULED" if scheduled_at else "WAITING") + if scheduled_at: + from app.services.publish_time import to_utc_iso + + scheduled_at = to_utc_iso(scheduled_at, settings.app_timezone) + status = "NEED_REVIEW" if metadata.get("risk_flags") else ( + "SCHEDULED" if scheduled_at and (publish_mode != "local_browser" or account_id) else "WAITING" + ) job_id = uuid4().hex[:12] provider_response = { "source": "auto_pipeline", - "target_platform": target_platform, + "target_platform": platform, "metadata_source": metadata.get("source") or "", "metadata_error": metadata.get("error") or "", "cover_text": metadata.get("cover_text") or "", + "cover_source": cover.get("cover_source") or "midpoint_fallback", + "cover_time_seconds": cover_time_seconds, "risk_flags": metadata.get("risk_flags") or [], - "note": "全自动流水线只创建待发送任务,发布时间在发送中心统一设置。", + "publish_mode": publish_mode, + "note": "全自动流水线已直接创建最终发布任务,可在发送中心设置排期。", } connection.execute( """ @@ -67,18 +109,20 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: tags, hashtags, cover_text, risk_flags, visibility, cover_mode, cover_time_seconds, allow_download, bilibili_tid, bilibili_copyright, bilibili_source, cover_file_path, scheduled_at, - status, audit_status, error_message, last_error, provider_response, publish_result, - created_at, updated_at + schedule_timezone, timezone, status, audit_status, error_message, last_error, + provider_response, publish_result, max_attempts, created_at, updated_at ) - VALUES (?, ?, ?, ?, NULL, ?, 'manual_export', 'original', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', - 'auto', 0, 1, ?, 'original', '', '', ?, ?, 'not_submitted', '', '', ?, '', ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 'original', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', + 'time', ?, 1, ?, 'original', '', ?, ?, ?, ?, ?, 'not_submitted', '', '', ?, '', ?, ?, ?) """, ( job_id, task["id"], output_clip["id"], output_clip["id"], + account_id, platform, + publish_mode, output_clip.get("output_file_path") or "", output_clip.get("output_file_path") or "", metadata.get("title") or "精彩片段", @@ -88,10 +132,15 @@ def create_auto_publish_jobs(task: dict, scheduled_items: list[dict]) -> dict: ", ".join(metadata.get("hashtags") or []), metadata.get("cover_text") or "", json.dumps(metadata.get("risk_flags") or [], ensure_ascii=False), + cover_time_seconds, DEFAULT_BILIBILI_TID, + cover_file_path, scheduled_at, + settings.app_timezone, + settings.app_timezone, status, json.dumps(provider_response, ensure_ascii=False), + settings.publish_scheduler_max_retry_count, now, now, ), diff --git a/app/services/clip_feedback_service.py b/app/services/clip_feedback_service.py new file mode 100644 index 0000000..ef7ef63 --- /dev/null +++ b/app/services/clip_feedback_service.py @@ -0,0 +1,107 @@ +"""候选片段人工反馈。 + +反馈作为独立审片记录保存,不会删除候选片段或历史 AI 分析结果。 +""" + +from __future__ import annotations + +from uuid import uuid4 + +from app.db.database import get_connection +from app.models.task import ClipFeedbackCreate +from app.services.task_log_service import append_task_log + + +FEEDBACK_REASON_LABELS = { + "worth_publishing": "值得发", + "not_funny": "不好笑", + "fragmented": "内容太碎", + "missing_setup": "铺垫不足", + "duplicate": "内容重复", + "dragging": "节奏拖沓", + "other": "其他", +} + + +def save_clip_feedback(task_id: str, clip_id: str, payload: ClipFeedbackCreate) -> dict: + from app.services.task_service import _now_iso, get_clip_candidate, get_task # noqa: F811 + + task = get_task(task_id, include_video_probe=False) + if not task: + raise ValueError("任务不存在") + clip = get_clip_candidate(task_id, clip_id) + now = _now_iso() + + with get_connection() as connection: + active_run = connection.execute( + """ + SELECT id FROM ai_analysis_runs + WHERE task_id = ? AND is_active = 1 + ORDER BY run_number DESC LIMIT 1 + """, + (task_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO clip_feedback ( + id, task_id, clip_candidate_id, analysis_run_id, selection_profile, + decision, reason_code, note, title_snapshot, summary_snapshot, + start_time, end_time, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + uuid4().hex[:12], + task_id, + clip_id, + active_run["id"] if active_run else None, + task.get("selection_profile") or "general", + payload.decision, + payload.reason_code, + (payload.note or "").strip(), + clip.get("title") or "", + clip.get("summary") or "", + clip.get("start_time") or "", + clip.get("end_time") or "", + now, + ), + ) + connection.execute( + """ + UPDATE clip_candidates + SET enabled = ?, reviewed = 1, updated_at = ? + WHERE task_id = ? AND id = ? AND is_deleted = 0 + """, + (1 if payload.decision == "keep" else 0, now, task_id, clip_id), + ) + connection.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (now, task_id)) + connection.commit() + + label = FEEDBACK_REASON_LABELS.get(payload.reason_code, payload.reason_code) + append_task_log(task_id, f"已记录片段反馈:{clip.get('title') or clip_id} · {label}") + return { + "status": "ok", + "message": f"已记录反馈:{label}。", + "decision": payload.decision, + "reason_code": payload.reason_code, + "enabled": payload.decision == "keep", + } + + +def list_recent_feedback_context(selection_profile: str, limit: int = 20) -> list[dict]: + safe_limit = max(1, min(50, int(limit))) + with get_connection() as connection: + rows = connection.execute( + """ + SELECT decision, reason_code, note, title_snapshot, summary_snapshot, + start_time, end_time, created_at + FROM clip_feedback + WHERE selection_profile = ? + ORDER BY created_at DESC + LIMIT ? + """, + (selection_profile, safe_limit), + ).fetchall() + return [dict(row) for row in rows] + + +__all__ = ["FEEDBACK_REASON_LABELS", "list_recent_feedback_context", "save_clip_feedback"] diff --git a/app/services/database_backup_service.py b/app/services/database_backup_service.py new file mode 100644 index 0000000..f868c51 --- /dev/null +++ b/app/services/database_backup_service.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import os +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from uuid import uuid4 +from zoneinfo import ZoneInfo + + +PUBLISH_MIGRATION_BACKUP_PREFIX = "workflow-before-publish-migration-" +PUBLISH_MIGRATION_BACKUP_SUFFIX = ".sqlite3" +PUBLISH_MIGRATION_BACKUP_GLOB = ( + f"{PUBLISH_MIGRATION_BACKUP_PREFIX}*{PUBLISH_MIGRATION_BACKUP_SUFFIX}" +) +PUBLISH_MIGRATION_JOURNAL_GLOB = f"{PUBLISH_MIGRATION_BACKUP_GLOB}-journal" +PUBLISH_MIGRATION_BACKUP_COOLDOWN = timedelta(hours=24) +PUBLISH_MIGRATION_BACKUP_KEEP_DAYS = 14 +MEDIA_CLEANUP_BACKUP_PREFIX = "workflow-before-media-cleanup-" +BACKUP_TIMEZONE = ZoneInfo("Asia/Shanghai") + + +class BackupSafetyError(RuntimeError): + """Raised when a cleanup or migration backup cannot be completed safely.""" + + +@dataclass(frozen=True) +class BackupCleanupPlan: + database_path: Path + backup_dir: Path + keep_files: tuple[Path, ...] + delete_files: tuple[Path, ...] + invalid_files: tuple[Path, ...] + journal_files: tuple[Path, ...] + + @property + def release_bytes(self) -> int: + paths = (*self.delete_files, *self.journal_files) + return sum(path.stat().st_size for path in paths if path.exists()) + + +@dataclass(frozen=True) +class BackupCleanupResult: + deleted_files: int + released_bytes: int + + +def sqlite_quick_check(database_path: Path) -> str: + path = database_path.resolve() + if not path.is_file(): + return "missing" + connection = sqlite3.connect(f"{path.as_uri()}?mode=ro", uri=True, timeout=10) + try: + row = connection.execute("PRAGMA quick_check").fetchone() + return str(row[0]) if row else "no_result" + except sqlite3.Error as exc: + return f"error: {exc}" + finally: + connection.close() + + +def _ensure_safe_backup_path(path: Path, backup_dir: Path) -> None: + resolved_dir = backup_dir.resolve() + absolute_parent = Path(os.path.abspath(path.parent)) + if path.is_symlink() or absolute_parent != resolved_dir: + raise BackupSafetyError(f"备份路径越界或使用了符号链接:{path}") + + +def _completed_backup_files(backup_dir: Path) -> list[Path]: + if not backup_dir.exists(): + return [] + files = [ + path + for path in backup_dir.glob(PUBLISH_MIGRATION_BACKUP_GLOB) + if path.is_file() + ] + for path in files: + _ensure_safe_backup_path(path, backup_dir) + return sorted( + files, + key=lambda path: (path.stat().st_mtime_ns, path.name), + reverse=True, + ) + + +def _backup_day(path: Path) -> str: + timestamp = datetime.fromtimestamp(path.stat().st_mtime, tz=BACKUP_TIMEZONE) + return timestamp.date().isoformat() + + +def build_cleanup_plan( + database_path: Path, + backup_dir: Path, + *, + keep_days: int = PUBLISH_MIGRATION_BACKUP_KEEP_DAYS, +) -> BackupCleanupPlan: + if keep_days < 1: + raise ValueError("keep_days 必须大于或等于 1") + + database_path = database_path.resolve() + backup_dir = backup_dir.resolve() + integrity = sqlite_quick_check(database_path) + if integrity != "ok": + raise BackupSafetyError(f"主数据库完整性检查失败:{integrity}") + + files_by_day: dict[str, list[Path]] = {} + for path in _completed_backup_files(backup_dir): + files_by_day.setdefault(_backup_day(path), []).append(path) + + selected_by_day: dict[str, Path] = {} + invalid_files: list[Path] = [] + for day, files in files_by_day.items(): + for path in files: + if sqlite_quick_check(path) == "ok": + selected_by_day[day] = path + break + invalid_files.append(path) + if day not in selected_by_day: + raise BackupSafetyError(f"{day} 没有任何通过完整性检查的备份,已中止清理") + + retained_days = set(sorted(selected_by_day, reverse=True)[:keep_days]) + keep_files = { + path for day, path in selected_by_day.items() if day in retained_days + } + all_files = {path for files in files_by_day.values() for path in files} + delete_files = all_files - keep_files + + journal_files: list[Path] = [] + if backup_dir.exists(): + for path in backup_dir.glob(PUBLISH_MIGRATION_JOURNAL_GLOB): + if not path.is_file(): + continue + _ensure_safe_backup_path(path, backup_dir) + journal_files.append(path) + + def sort_key(path: Path) -> tuple[int, str]: + return path.stat().st_mtime_ns, path.name + + return BackupCleanupPlan( + database_path=database_path, + backup_dir=backup_dir, + keep_files=tuple(sorted(keep_files, key=sort_key)), + delete_files=tuple(sorted(delete_files, key=sort_key)), + invalid_files=tuple(sorted(set(invalid_files), key=sort_key)), + journal_files=tuple(sorted(journal_files, key=sort_key)), + ) + + +def apply_cleanup_plan( + plan: BackupCleanupPlan, + *, + progress_every: int = 10_000, +) -> BackupCleanupResult: + if sqlite_quick_check(plan.database_path) != "ok": + raise BackupSafetyError("删除前主数据库完整性检查失败,已中止清理") + + current_files = set(_completed_backup_files(plan.backup_dir)) + planned_files = {*plan.keep_files, *plan.delete_files} + if current_files != planned_files: + raise BackupSafetyError("备份目录在预演后发生变化,请重新生成清理计划") + + for path in plan.keep_files: + if sqlite_quick_check(path) != "ok": + raise BackupSafetyError(f"拟保留备份完整性检查失败:{path.name}") + + deleted_files = 0 + released_bytes = 0 + paths_to_delete = (*plan.delete_files, *plan.journal_files) + for path in paths_to_delete: + if not path.exists(): + continue + _ensure_safe_backup_path(path, plan.backup_dir) + size = path.stat().st_size + path.unlink() + deleted_files += 1 + released_bytes += size + if progress_every > 0 and deleted_files % progress_every == 0: + print(f"已安全删除 {deleted_files:,} 个文件……", flush=True) + + return BackupCleanupResult( + deleted_files=deleted_files, + released_bytes=released_bytes, + ) + + +def _recent_valid_backup( + backup_dir: Path, + *, + now: datetime, + cooldown: timedelta, +) -> Path | None: + cutoff = now.timestamp() - cooldown.total_seconds() + for path in _completed_backup_files(backup_dir): + if path.stat().st_mtime < cutoff: + break + if sqlite_quick_check(path) == "ok": + return path + return None + + +def create_publish_migration_backup( + database_path: Path, + backup_dir: Path, + *, + now: datetime | None = None, + cooldown: timedelta = PUBLISH_MIGRATION_BACKUP_COOLDOWN, + keep_days: int = PUBLISH_MIGRATION_BACKUP_KEEP_DAYS, +) -> Path | None: + database_path = database_path.resolve() + backup_dir = backup_dir.resolve() + now = now.astimezone(BACKUP_TIMEZONE) if now else datetime.now(BACKUP_TIMEZONE) + backup_dir.mkdir(parents=True, exist_ok=True) + + if _recent_valid_backup(backup_dir, now=now, cooldown=cooldown): + return None + + timestamp = now.strftime("%Y%m%d-%H%M%S-%f") + unique_suffix = f"{os.getpid()}-{uuid4().hex[:8]}" + final_path = backup_dir / ( + f"{PUBLISH_MIGRATION_BACKUP_PREFIX}{timestamp}-{unique_suffix}" + f"{PUBLISH_MIGRATION_BACKUP_SUFFIX}" + ) + temporary_path = final_path.with_name( + f"{final_path.name}.tmp-{uuid4().hex}" + ) + + source_connection: sqlite3.Connection | None = None + backup_connection: sqlite3.Connection | None = None + try: + source_connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=10, + ) + backup_connection = sqlite3.connect(str(temporary_path), timeout=10) + source_connection.backup(backup_connection) + backup_connection.close() + backup_connection = None + source_connection.close() + source_connection = None + + integrity = sqlite_quick_check(temporary_path) + if integrity != "ok": + raise BackupSafetyError(f"新备份完整性检查失败:{integrity}") + os.replace(temporary_path, final_path) + + cleanup_plan = build_cleanup_plan( + database_path, + backup_dir, + keep_days=keep_days, + ) + apply_cleanup_plan(cleanup_plan, progress_every=0) + return final_path + except Exception as exc: + if temporary_path.exists(): + temporary_path.unlink() + if isinstance(exc, BackupSafetyError): + raise + raise BackupSafetyError(f"创建迁移前备份失败:{exc}") from exc + finally: + if backup_connection is not None: + backup_connection.close() + if source_connection is not None: + source_connection.close() + + +def create_media_cleanup_backup( + database_path: Path, + backup_dir: Path, + *, + now: datetime | None = None, +) -> Path: + """永久删除任务媒体前,原子创建一份仅包含 SQLite 元数据的备份。""" + database_path = database_path.resolve() + backup_dir = backup_dir.resolve() + now = now.astimezone(BACKUP_TIMEZONE) if now else datetime.now(BACKUP_TIMEZONE) + backup_dir.mkdir(parents=True, exist_ok=True) + + timestamp = now.strftime("%Y%m%d-%H%M%S-%f") + final_path = backup_dir / ( + f"{MEDIA_CLEANUP_BACKUP_PREFIX}{timestamp}-{os.getpid()}-{uuid4().hex[:8]}.sqlite3" + ) + temporary_path = final_path.with_name(f"{final_path.name}.tmp-{uuid4().hex}") + source_connection: sqlite3.Connection | None = None + backup_connection: sqlite3.Connection | None = None + try: + source_connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=10, + ) + backup_connection = sqlite3.connect(str(temporary_path), timeout=10) + source_connection.backup(backup_connection) + backup_connection.close() + backup_connection = None + source_connection.close() + source_connection = None + + integrity = sqlite_quick_check(temporary_path) + if integrity != "ok": + raise BackupSafetyError(f"媒体清理前备份完整性检查失败:{integrity}") + os.replace(temporary_path, final_path) + return final_path + except Exception as exc: + if temporary_path.exists(): + temporary_path.unlink() + if isinstance(exc, BackupSafetyError): + raise + raise BackupSafetyError(f"创建媒体清理前备份失败:{exc}") from exc + finally: + if backup_connection is not None: + backup_connection.close() + if source_connection is not None: + source_connection.close() diff --git a/app/services/job_service.py b/app/services/job_service.py index 859d8d9..b5455ef 100644 --- a/app/services/job_service.py +++ b/app/services/job_service.py @@ -23,12 +23,14 @@ JOB_STATUS_RUNNING = "running" JOB_STATUS_COMPLETED = "completed" JOB_STATUS_FAILED = "failed" +JOB_STATUS_CANCELLED = "cancelled" JOB_STATUS_LABELS = { JOB_STATUS_QUEUED: "排队中", JOB_STATUS_RUNNING: "运行中", JOB_STATUS_COMPLETED: "已完成", JOB_STATUS_FAILED: "失败", + JOB_STATUS_CANCELLED: "已取消", } JOB_TYPE_LABELS = { diff --git a/app/services/job_worker.py b/app/services/job_worker.py index 2909cde..074ff5c 100644 --- a/app/services/job_worker.py +++ b/app/services/job_worker.py @@ -17,6 +17,8 @@ def execute_job(job_id: str) -> dict: job = job_service.get_job(job_id) if not job: raise ValueError(f"job 不存在:{job_id}") + if job.get("status") == job_service.JOB_STATUS_CANCELLED: + return job job_type = job.get("job_type") task_id = job.get("task_id") diff --git a/app/services/pipeline_engine.py b/app/services/pipeline_engine.py index c23487a..0d21825 100644 --- a/app/services/pipeline_engine.py +++ b/app/services/pipeline_engine.py @@ -12,6 +12,8 @@ from app.services import task_service from app.services.auto_publish_service import create_auto_publish_jobs, platforms_for_task from app.services.metadata_generator import MetadataGenerator +from app.services.publish_service import generate_publish_cover_for_item +from app.services.publish_time import next_allowed_schedule_time from app.services.storage_service import ( create_task_directory, get_artifact_paths, @@ -62,8 +64,8 @@ "auto_schedule_mode": "default", "auto_schedule_start_at": "", "auto_schedule_interval_hours": 3, - "auto_schedule_daily_start_time": "09:00", - "auto_schedule_daily_end_time": "21:00", + "auto_schedule_daily_start_time": "07:00", + "auto_schedule_daily_end_time": "00:00", "auto_metadata_use_ai": False, } @@ -142,6 +144,8 @@ def _get_task(self, task_id: str) -> dict: task = task_service.get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") + if task.get("is_deleted"): + raise ValueError("任务已永久删除,已停止后续自动处理") return task def _load_auto_config(self, task: dict) -> dict: @@ -223,7 +227,7 @@ def _select_clips(self, task_id: str, context: dict) -> dict: raise ValueError("没有可用于自动切片的候选片段,请先重新运行 AI 分析") target_count = self._resolve_target_count(task, config) - max_duration_seconds = max(1, int(task.get("max_clip_duration") or 5)) * 60 + max_duration_seconds = max(1, int(task.get("max_clip_duration") or 10)) * 60 valid_candidates = [] skipped = [] for clip in candidates: @@ -240,10 +244,19 @@ def _select_clips(self, task_id: str, context: dict) -> dict: continue valid_candidates.append({**clip, "start_seconds": start, "end_seconds": end, "duration": duration}) - selected = sorted(valid_candidates, key=lambda item: float(item.get("confidence_score") or 0), reverse=True) + eligible = [item for item in valid_candidates if bool(item.get("selected_by_default"))] + if task.get("selection_profile") == "variety_comedy": + eligible = [item for item in eligible if item.get("quality_tier") == "A"] + selected = sorted( + eligible, + key=lambda item: float(item.get("quality_score") or item.get("confidence_score") or 0), + reverse=True, + ) selected = sorted(selected[:target_count], key=lambda item: float(item["start_seconds"])) if not selected: - raise ValueError("候选片段的时间戳均无效或超过单条切片最长时长") + if task.get("selection_profile") == "variety_comedy": + raise ValueError("本集没有达到 A 级质量门槛的综艺片段,已停止自动切片,避免为了数量强行输出") + raise ValueError("没有同时满足默认入选和时间戳要求的候选片段") selected_ids = {clip["id"] for clip in selected} self._update_selected_clips(task_id, selected_ids) payload = { @@ -272,7 +285,7 @@ def _select_clips(self, task_id: str, context: dict) -> dict: def _cut_video(self, task_id: str, context: dict) -> dict: append_task_log(task_id, "全自动模式:开始原视频裁切,本轮明确跳过字幕烧录") - result = task_service.process_task_video_cuts(task_id) + result = task_service.process_task_video_cuts(task_id, sync_publish_jobs=False) output_clips = task_service.list_output_clips(task_id) success = [clip for clip in output_clips if clip.get("status") == "completed" and clip.get("file_exists")] failed = [item for item in result.get("results") or [] if item.get("status") == "failed"] @@ -294,11 +307,16 @@ def _generate_metadata(self, task_id: str, context: dict) -> dict: generator = MetadataGenerator(use_ai=config["auto_metadata_use_ai"]) metadata_items = [] for output_clip in output_clips: + cover = generate_publish_cover_for_item( + output_clip, + preferred_time_seconds=output_clip.get("cover_time_seconds"), + ) for platform in platforms_for_task(task): metadata_items.append( { "output_clip": output_clip, "metadata": generator.generate(output_clip, platform), + "cover": cover, } ) paths = get_artifact_paths(task_id) @@ -358,7 +376,9 @@ def _list_raw_candidates(self, task_id: str) -> list[dict]: """ SELECT id, task_id, clip_key, title, start_time, end_time, duration_seconds, summary, reason, highlight_reason, spread_value, suggested_editing, - confidence_score, selected_by_default, enabled, reviewed, is_deleted + confidence_score, quality_tier, quality_score, humor_score, + completeness_score, audio_reaction_score, + selected_by_default, enabled, reviewed, is_deleted FROM clip_candidates WHERE task_id = ? AND is_deleted = 0 ORDER BY start_time ASC @@ -368,7 +388,9 @@ def _list_raw_candidates(self, task_id: str) -> list[dict]: return [dict(row) for row in rows] def _resolve_target_count(self, task: dict, config: dict) -> int: - return max(1, min(50, int(task.get("candidate_clip_count") or 5))) + if task.get("selection_profile") == "variety_comedy": + return max(1, min(12, int(task.get("final_clip_target") or 5))) + return max(1, min(50, int(task.get("candidate_clip_count") or 12))) def _update_selected_clips(self, task_id: str, selected_ids: set[str]) -> None: now = task_service._now_iso() @@ -395,9 +417,11 @@ def _update_selected_clips(self, task_id: str, selected_ids: set[str]) -> None: def _write_clip_metadata(self, task_id: str, output_clips: list[dict], metadata_items: list[dict]) -> None: paths = get_artifact_paths(task_id) metadata_by_clip: dict[str, list[dict]] = {} + cover_by_clip: dict[str, dict] = {} for item in metadata_items: clip_id = item["output_clip"]["id"] metadata_by_clip.setdefault(clip_id, []).append(item["metadata"]) + cover_by_clip[clip_id] = item.get("cover") or {} payload = [] for clip in output_clips: payload.append( @@ -408,6 +432,7 @@ def _write_clip_metadata(self, task_id: str, output_clips: list[dict], metadata_ "status": clip.get("status") or "", "error_message": clip.get("error_message") or "", "recommend_reason": clip.get("highlight_reason") or clip.get("clip_summary") or "", + "cover": cover_by_clip.get(clip.get("id") or "", {}), "metadata": metadata_by_clip.get(clip.get("id") or "", []), } ) @@ -531,20 +556,27 @@ def build_schedule_times(count: int, config: dict, now: datetime | None = None) return [(start + index * interval).isoformat(timespec="seconds") for index in range(count)] if mode == "daily_window": - window_start = _parse_clock(str(config.get("auto_schedule_daily_start_time") or "09:00"), time(9, 0)) - window_end = _parse_clock(str(config.get("auto_schedule_daily_end_time") or "21:00"), time(21, 0)) + window_start = _parse_clock( + str(config.get("auto_schedule_daily_start_time") or "07:00"), + time(7, 0), + ).isoformat(timespec="minutes") + window_end = _parse_clock( + str(config.get("auto_schedule_daily_end_time") or "00:00"), + time(0, 0), + ).isoformat(timespec="minutes") scheduled = [] - cursor = start + cursor = next_allowed_schedule_time( + start, + daily_start_time=window_start, + daily_end_time=window_end, + ) while len(scheduled) < count: - day_start = datetime.combine(cursor.date(), window_start).replace(tzinfo=cursor.tzinfo) - day_end = datetime.combine(cursor.date(), window_end).replace(tzinfo=cursor.tzinfo) - if cursor < day_start: - cursor = day_start - if cursor > day_end: - cursor = datetime.combine(cursor.date() + timedelta(days=1), window_start).replace(tzinfo=cursor.tzinfo) - continue scheduled.append(cursor.isoformat(timespec="seconds")) - cursor = cursor + timedelta(hours=interval_hours) + cursor = next_allowed_schedule_time( + cursor + timedelta(hours=interval_hours), + daily_start_time=window_start, + daily_end_time=window_end, + ) return scheduled return [(start + index * timedelta(hours=3)).isoformat(timespec="seconds") for index in range(count)] diff --git a/app/services/publish_adapters.py b/app/services/publish_adapters.py index 6bd49c8..d673b8f 100644 --- a/app/services/publish_adapters.py +++ b/app/services/publish_adapters.py @@ -1,184 +1,40 @@ -from __future__ import annotations - -import json -import shutil -from abc import ABC, abstractmethod -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any - -from app.core.config import settings -from app.services.storage_service import resolve_video_file_path - - -class PublishValidationError(ValueError): - def __init__(self, message: str, error_code: str = "validation_failed") -> None: - super().__init__(message) - self.message = message - self.error_code = error_code - - -@dataclass(frozen=True) -class PublishResult: - ok: bool - payload: dict[str, Any] - remote_video_id: str = "" - - -def _now_iso() -> str: - return datetime.now().astimezone().isoformat(timespec="seconds") - - -def _parse_json_dict(value: str | None) -> dict[str, Any]: - if not value: - return {} - try: - parsed = json.loads(value) - except json.JSONDecodeError: - return {"raw": value} - return parsed if isinstance(parsed, dict) else {"data": parsed} - - -def _json_dump(path: Path, payload: dict[str, Any]) -> None: - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def _text_dump(path: Path, value: str) -> None: - path.write_text((value or "").strip() + "\n", encoding="utf-8") - - -def _job_clip_id(job: dict[str, Any]) -> str: - return str(job.get("clip_id") or job.get("output_clip_id") or "unknown_clip").strip() - - -def _job_caption(job: dict[str, Any]) -> str: - return str(job.get("caption") or job.get("description") or "").strip() - +"""v1.4 兼容导入层。 -def _job_hashtags(job: dict[str, Any]) -> str: - return str(job.get("hashtags") or job.get("tags") or "").strip() - - -def _job_cover_text(job: dict[str, Any]) -> str: - provider_payload = _parse_json_dict(job.get("provider_response")) - result_payload = _parse_json_dict(job.get("publish_result")) - return str( - job.get("cover_text") - or provider_payload.get("cover_text") - or result_payload.get("cover_text") - or job.get("title") - or "" - ).strip() - - -def _job_video_path(job: dict[str, Any]) -> Path: - raw_path = str(job.get("video_path") or job.get("video_file_path") or "").strip() - if not raw_path: - raise PublishValidationError("video_path is empty", "missing_video_path") - resolved = resolve_video_file_path(raw_path) or Path(raw_path).expanduser() - if not resolved.exists() or not resolved.is_file(): - raise PublishValidationError(f"video file does not exist: {raw_path}", "video_not_found") - return resolved - - -class BasePublisher(ABC): - name = "base" - - def validate(self, job: dict[str, Any]) -> None: - _job_video_path(job) - if not str(job.get("title") or "").strip(): - raise PublishValidationError("title is empty", "missing_title") - if not _job_caption(job): - raise PublishValidationError("caption is empty", "missing_caption") - - def build_payload(self, job: dict[str, Any]) -> dict[str, Any]: - return { - "job_id": job.get("id") or "", - "task_id": job.get("task_id") or "", - "clip_id": _job_clip_id(job), - "platform": job.get("platform") or "", - "account_id": job.get("account_id") or "", - "scheduled_at": job.get("scheduled_at") or "", - "title": str(job.get("title") or "").strip(), - "caption": _job_caption(job), - "hashtags": _job_hashtags(job), - "cover_text": _job_cover_text(job), - "video_path": str(_job_video_path(job)), - "publisher": self.name, - } - - @abstractmethod - def publish(self, job: dict[str, Any]) -> PublishResult: - raise NotImplementedError - - -class ManualExportPublisher(BasePublisher): - name = "manual_export" - - def __init__(self, export_dir: Path | None = None) -> None: - self.export_dir = Path(export_dir or settings.publish_scheduler_export_dir) - - def build_package_dir(self, job: dict[str, Any]) -> Path: - return self.export_dir / str(job.get("task_id") or "unknown_task") / _job_clip_id(job) - - def publish(self, job: dict[str, Any]) -> PublishResult: - self.validate(job) - video_path = _job_video_path(job) - package_dir = self.build_package_dir(job) - package_dir.mkdir(parents=True, exist_ok=True) - - clip_path = package_dir / "clip.mp4" - shutil.copy2(video_path, clip_path) - - payload = self.build_payload(job) - payload.update( - { - "package_dir": str(package_dir), - "clip_file": str(clip_path), - "exported_at": _now_iso(), - } - ) - - _text_dump(package_dir / "title.txt", payload["title"]) - _text_dump(package_dir / "caption.txt", payload["caption"]) - _text_dump(package_dir / "hashtags.txt", payload["hashtags"]) - _text_dump(package_dir / "cover_text.txt", payload["cover_text"]) - _json_dump(package_dir / "publish_plan.json", payload) - _json_dump( - package_dir / "metadata.json", - { - **payload, - "source_video_name": video_path.name, - "source_video_size_bytes": video_path.stat().st_size, - }, - ) - - return PublishResult( - ok=True, - payload=payload, - remote_video_id=f"manual_export:{job.get('id') or package_dir.name}", - ) +新代码应从 ``app.services.publishers`` 导入;保留本文件避免旧脚本和测试立即失效。 +""" +from __future__ import annotations -class LocalBrowserPublisher(BasePublisher): - name = "local_browser" +from typing import Any - def publish(self, job: dict[str, Any]) -> PublishResult: - self.validate(job) - raise PublishValidationError( - "local browser publisher is reserved but not implemented in v1.4.0", - "local_browser_not_implemented", - ) +from app.services.publishers.base import ( + BasePublisher, + PublishError, + PublishOutcome, + PublishResult, + PublishValidationError, + PublishWorkerUnavailable, +) +from app.services.publishers.local_browser import LocalBrowserPublisher +from app.services.publishers.manual_export import ManualExportPublisher +from app.services.publishers.registry import get_publisher def publisher_for_job(job: dict[str, Any]) -> BasePublisher: - platform = str(job.get("platform") or "").strip().lower() - publish_mode = str(job.get("publish_mode") or "").strip().lower() - if platform == "local_browser" or publish_mode == "local_browser": - return LocalBrowserPublisher() - if platform == "manual_export" or publish_mode == "manual_export": - return ManualExportPublisher() - if settings.publish_scheduler_default_platform == "manual_export": - return ManualExportPublisher() - return ManualExportPublisher() + return get_publisher( + str(job.get("platform") or ""), + str(job.get("publish_mode") or ""), + ) + + +__all__ = [ + "BasePublisher", + "LocalBrowserPublisher", + "ManualExportPublisher", + "PublishError", + "PublishOutcome", + "PublishResult", + "PublishValidationError", + "PublishWorkerUnavailable", + "publisher_for_job", +] diff --git a/app/services/publish_domain.py b/app/services/publish_domain.py new file mode 100644 index 0000000..c845f23 --- /dev/null +++ b/app/services/publish_domain.py @@ -0,0 +1,42 @@ +"""发布领域常量:目标平台、执行方式与状态。""" + +TARGET_PLATFORMS = { + "douyin": "抖音", + "bilibili": "B站", +} + +PUBLISH_MODES = { + "opencli_publish": "opencli 兼容发送(需显式开启)", + "manual_export": "本地发布包导出", + "api_publish": "平台 API 发布", + "local_browser": "Windows Chrome 真实发布", +} + +PUBLISH_STATUSES = { + "DRAFT", + "WAITING", + "SCHEDULED", + "PUBLISHING", + "PUBLISHED", + "EXPORTED", + "FAILED", + "CANCELLED", + "NEED_REVIEW", +} + +TERMINAL_PUBLISH_STATUSES = {"PUBLISHED", "EXPORTED", "CANCELLED"} +ACTIVE_PUBLISH_STATUSES = PUBLISH_STATUSES - TERMINAL_PUBLISH_STATUSES + + +def validate_target_platform(platform: str) -> str: + value = (platform or "").strip().lower() + if value not in TARGET_PLATFORMS: + raise ValueError("目标平台只能是 douyin 或 bilibili") + return value + + +def validate_publish_mode(publish_mode: str) -> str: + value = (publish_mode or "").strip().lower() + if value not in PUBLISH_MODES: + raise ValueError("不支持的发布执行方式") + return value diff --git a/app/services/publish_executor.py b/app/services/publish_executor.py new file mode 100644 index 0000000..df139e3 --- /dev/null +++ b/app/services/publish_executor.py @@ -0,0 +1,38 @@ +"""统一发布执行入口;任务领取和最终状态由 PublishScheduler 负责。""" + +from __future__ import annotations + +from typing import Any, Callable + +from app.services.publish_repository import PublishRepository +from app.services.publishers.registry import get_publisher + + +def execute_publish_job( + job_id: str, + force: bool = False, + *, + runner: Callable[[list[str]], Any] | None = None, + repository: PublishRepository | None = None, + worker_client=None, +) -> dict[str, Any]: + del force # 是否允许领取由 Scheduler 处理,Publisher 不绕过状态机。 + repo = repository or PublishRepository() + job = repo.get_job(job_id) + if not job: + from app.services.publishers.base import PublishValidationError + + raise PublishValidationError("发布任务不存在", "publish_job_not_found") + dependencies: dict[str, Any] = {"repository": repo, "runner": runner} + if worker_client is not None: + dependencies["worker_client"] = worker_client + publisher = get_publisher( + str(job.get("platform") or ""), + str(job.get("publish_mode") or ""), + **dependencies, + ) + result = publisher.publish(job) + # LocalBrowserPublisher 已即时记录 Worker 原始结果;其他模式在这里统一补写。 + if str(job.get("publish_mode") or "") != "local_browser": + repo.record_provider_result(job_id, result) + return result.as_dict() diff --git a/app/services/publish_readiness.py b/app/services/publish_readiness.py new file mode 100644 index 0000000..109d2e4 --- /dev/null +++ b/app/services/publish_readiness.py @@ -0,0 +1,265 @@ +"""发布前置条件计算;页面、排期和立即发送共用同一结果。""" + +from __future__ import annotations + +from typing import Any + +from app.db.database import get_connection +from app.services.publishers.base import PublishError, PublishValidationError + + +SAFE_PREFLIGHT_REPAIR_CODES = { + "legacy_schedule_requires_confirmation", + "opencli_fallback_disabled", +} + + +class SendReadinessBlocked(ValueError): + """发送条件不足,并携带可供页面直接展示的结构化原因。""" + + def __init__(self, readiness: dict[str, Any]) -> None: + super().__init__(str(readiness.get("message") or "发布条件尚未满足")) + self.readiness = readiness + + +class PublishPlatformIsolationBlocked(ValueError): + """所选任务跨越平台或试图修改任务所属平台。""" + + +def list_account_snapshots() -> list[dict[str, Any]]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, platform, account_name, auth_type, login_status, + login_message, login_checked_at, last_login_at + FROM publish_accounts + ORDER BY created_at, id + """ + ).fetchall() + return [dict(row) for row in rows] + + +def _issue(code: str, message: str, action: str, **details: Any) -> dict[str, Any]: + return {"code": code, "message": message, "action": action, **details} + + +def _content_issues(job: dict[str, Any], platform: str, publish_mode: str) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + caption = str(job.get("caption") or job.get("description") or "").strip() + hashtags = str(job.get("hashtags") or job.get("tags") or "").strip() + checks = [ + ("title", str(job.get("title") or "").strip(), "标题"), + ("caption", caption, "正文/简介"), + ("video", str(job.get("video_path") or job.get("video_file_path") or "").strip(), "视频文件"), + ] + if publish_mode == "local_browser": + checks.extend( + [ + ("hashtags", hashtags, "话题/标签"), + ("cover", str(job.get("cover_file_path") or "").strip(), "封面"), + ] + ) + missing = [label for _, value, label in checks if not value] + if platform == "bilibili" and publish_mode == "local_browser": + if not str(job.get("bilibili_tid") or "").strip(): + missing.append("B站分区") + if str(job.get("bilibili_copyright") or "original") == "repost" and not str( + job.get("bilibili_source") or "" + ).strip(): + missing.append("转载来源") + if missing: + issues.append( + _issue( + "content_incomplete", + f"请先补充:{'、'.join(missing)}", + "complete_content", + missing_fields=missing, + ) + ) + return issues + + +def build_send_readiness( + job: dict[str, Any], + *, + accounts: list[dict[str, Any]] | None = None, + resolve_legacy: bool = False, + validate_files: bool = False, + worker_available: bool | None = None, + worker_message: str = "", +) -> dict[str, Any]: + """返回发送就绪状态;本函数不修改任务和账号。""" + + account_rows = accounts if accounts is not None else list_account_snapshots() + status = str(job.get("status") or "").upper() + platform = str(job.get("platform") or "").strip().lower() + original_mode = str(job.get("publish_mode") or "").strip().lower() + resolved_mode = "local_browser" if original_mode == "opencli_publish" else original_mode + needs_legacy_conversion = original_mode == "opencli_publish" + issues: list[dict[str, Any]] = [] + + if platform not in {"douyin", "bilibili"}: + issues.append(_issue("unsupported_platform", "当前平台不支持真实投稿", "complete_content")) + + if resolved_mode not in {"local_browser", "manual_export"}: + issues.append(_issue("unsupported_publish_mode", "当前发布方式不受支持", "complete_content")) + + if needs_legacy_conversion and not resolve_legacy: + issues.append( + _issue( + "legacy_publish_mode", + "旧版发送方式将在发送前转换为 Windows Chrome", + "convert_and_send", + ) + ) + + resolved_account: dict[str, Any] | None = None + auto_selected = False + if resolved_mode == "local_browser" and platform in {"douyin", "bilibili"}: + account_id = str(job.get("account_id") or "").strip() + if account_id: + resolved_account = next((item for item in account_rows if str(item.get("id")) == account_id), None) + if not resolved_account: + issues.append(_issue("account_not_found", "原发布账号已不存在,请重新选择", "select_account")) + else: + matching = [item for item in account_rows if str(item.get("platform") or "") == platform] + if not matching: + issues.append( + _issue( + "account_missing", + f"还没有可用的{'抖音' if platform == 'douyin' else 'B站'}账号", + "create_account", + platform=platform, + ) + ) + elif len(matching) > 1: + issues.append( + _issue( + "account_selection_required", + "检测到多个同平台账号,请先选择本次使用的账号", + "select_account", + platform=platform, + ) + ) + else: + resolved_account = matching[0] + auto_selected = True + + if resolved_account: + if str(resolved_account.get("platform") or "") != platform: + issues.append(_issue("account_platform_mismatch", "账号与目标平台不一致", "select_account")) + elif str(resolved_account.get("login_status") or "login_required") != "normal": + issues.append( + _issue( + "account_login_required", + f"账号“{resolved_account.get('account_name') or '未命名账号'}”尚未登录", + "login_account", + account_id=str(resolved_account.get("id") or ""), + account_name=str(resolved_account.get("account_name") or ""), + login_message=str(resolved_account.get("login_message") or ""), + ) + ) + + issues.extend(_content_issues(job, platform, resolved_mode)) + + if resolved_mode == "local_browser" and worker_available is False: + issues.append( + _issue( + "publish_worker_unavailable", + worker_message or "Windows 发布 Worker 未连接", + "start_worker", + ) + ) + + resolved_account_id = str(resolved_account.get("id") or "") if resolved_account else "" + dispatch_issues = [item for item in issues if item["code"] != "legacy_publish_mode"] + can_auto_resolve = needs_legacy_conversion and not dispatch_issues + + if validate_files and not dispatch_issues: + candidate = { + **job, + "publish_mode": resolved_mode, + "account_id": resolved_account_id or job.get("account_id") or "", + } + try: + if resolved_mode == "local_browser": + from app.services.publishers.local_browser import LocalBrowserPublisher + + LocalBrowserPublisher(platform=platform).validate(candidate) + elif resolved_mode == "manual_export": + from app.services.publishers.manual_export import ManualExportPublisher + + ManualExportPublisher().validate(candidate) + except PublishError as exc: + issue = _issue(exc.error_code or "content_invalid", exc.message, "complete_content") + issues.append(issue) + dispatch_issues.append(issue) + can_auto_resolve = False + + repairable = ( + status == "NEED_REVIEW" + and str(job.get("error_code") or "") in SAFE_PREFLIGHT_REPAIR_CODES + and not str(job.get("platform_url") or "").strip() + and not str(job.get("remote_video_id") or "").strip() + ) + + action_priority = { + "start_worker": 1, + "create_account": 2, + "select_account": 3, + "login_account": 4, + "complete_content": 5, + "convert_and_send": 6, + } + primary = min(issues, key=lambda item: action_priority.get(str(item.get("action")), 99)) if issues else None + ready = not issues + dispatch_ready = not dispatch_issues + message = "发送条件已满足" + if primary: + message = str(primary.get("message") or "发布条件尚未满足") + + return { + "ready": ready, + "dispatch_ready": dispatch_ready, + "message": message, + "action": str(primary.get("action") if primary else ("export" if resolved_mode == "manual_export" else "send")), + "issues": issues, + "requires_worker": resolved_mode == "local_browser", + "original_publish_mode": original_mode, + "resolved_publish_mode": resolved_mode, + "resolved_account_id": resolved_account_id, + "resolved_account_name": str(resolved_account.get("account_name") or "") if resolved_account else "", + "auto_selected_account": auto_selected, + "needs_legacy_conversion": needs_legacy_conversion, + "can_auto_resolve": can_auto_resolve, + "repairable": repairable, + } + + +def worker_blocked_readiness(message: str) -> dict[str, Any]: + issue = _issue("publish_worker_unavailable", message, "start_worker") + return { + "ready": False, + "dispatch_ready": False, + "message": message, + "action": "start_worker", + "issues": [issue], + "requires_worker": True, + "original_publish_mode": "local_browser", + "resolved_publish_mode": "local_browser", + "resolved_account_id": "", + "resolved_account_name": "", + "auto_selected_account": False, + "needs_legacy_conversion": False, + "can_auto_resolve": False, + "repairable": False, + } + + +def require_worker_available(worker_client: Any) -> None: + try: + health = worker_client.health() + if str(health.get("status") or "") != "ok": + raise PublishValidationError("Windows 发布 Worker 健康检查异常", "publish_worker_unavailable") + except PublishError as exc: + raise SendReadinessBlocked(worker_blocked_readiness(exc.message)) from exc diff --git a/app/services/publish_repository.py b/app/services/publish_repository.py new file mode 100644 index 0000000..51eeed2 --- /dev/null +++ b/app/services/publish_repository.py @@ -0,0 +1,154 @@ +"""发布任务持久化边界,集中处理脱敏结果和状态事件。""" + +from __future__ import annotations + +import json +from typing import Any + +from app.db.database import get_connection +from app.services.publish_time import utc_now_iso +from app.services.publishers.base import PublishResult, sanitize_provider_response + + +class PublishRepository: + def get_job(self, job_id: str) -> dict[str, Any] | None: + with get_connection() as connection: + row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + return dict(row) if row else None + + def record_provider_result( + self, + job_id: str, + result: PublishResult, + *, + connection=None, + updated_at: str | None = None, + ) -> None: + """保存脱敏平台结果;复用连接时由调用方统一提交事务。""" + + now = updated_at or utc_now_iso() + provider_json = json.dumps( + sanitize_provider_response(result.provider_response), ensure_ascii=False + ) + result_json = json.dumps(result.as_dict(), ensure_ascii=False) + values = ( + result.remote_video_id, + result.remote_video_id, + result.platform_url, + provider_json, + result_json, + result.published_at or None, + result.error_code, + result.message if result.error_code else "", + result.message if result.error_code else "", + int(result.needs_manual_review), + now, + job_id, + ) + sql = """ + UPDATE publish_jobs + SET remote_video_id = ?, platform_item_id = ?, platform_url = ?, + provider_response = ?, publish_result = ?, published_at = ?, + error_code = ?, last_error = ?, error_message = ?, + needs_manual_review = ?, updated_at = ? + WHERE id = ? + """ + if connection is not None: + connection.execute(sql, values) + return + with get_connection() as owned: + owned.execute(sql, values) + owned.commit() + + def update_execution_phase( + self, + job_id: str, + phase: str, + details: dict[str, Any] | None = None, + ) -> None: + """同步 Worker 的实时阶段;不会在这里改变任务最终状态。""" + + values = sanitize_provider_response(details or {}) + message = str(values.get("message") or "") if isinstance(values, dict) else "" + now = utc_now_iso() + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs + SET execution_phase = ?, + last_error = CASE WHEN ? <> '' THEN ? ELSE last_error END, + error_message = CASE WHEN ? <> '' THEN ? ELSE error_message END, + updated_at = ? + WHERE id = ? AND status = 'PUBLISHING' + """, + (phase, message, message, message, message, now, job_id), + ) + connection.commit() + + def add_event( + self, + job_id: str, + event_type: str, + *, + from_status: str = "", + to_status: str = "", + worker_id: str = "", + error_code: str = "", + message: str = "", + payload: dict[str, Any] | None = None, + connection=None, + ) -> None: + values = ( + job_id, + event_type, + from_status, + to_status, + worker_id, + error_code, + message, + json.dumps(sanitize_provider_response(payload or {}), ensure_ascii=False), + utc_now_iso(), + ) + sql = """ + INSERT INTO publish_job_events ( + job_id, event_type, from_status, to_status, worker_id, + error_code, message, payload, occurred_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + if connection is not None: + connection.execute(sql, values) + return + with get_connection() as owned: + owned.execute(sql, values) + owned.commit() + + def list_events(self, job_id: str) -> list[dict[str, Any]]: + with get_connection() as connection: + rows = connection.execute( + "SELECT * FROM publish_job_events WHERE job_id = ? ORDER BY occurred_at, id", + (job_id,), + ).fetchall() + return [dict(row) for row in rows] + + def update_account_status( + self, + account_id: str, + login_status: str, + message: str = "", + *, + logged_in: bool = False, + ) -> None: + now = utc_now_iso() + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_accounts + SET login_status = ?, login_message = ?, login_checked_at = ?, + last_login_at = CASE WHEN ? THEN ? ELSE last_login_at END, + authorization_status = CASE WHEN ? THEN 'authorized' ELSE authorization_status END, + updated_at = ? + WHERE id = ? + """, + (login_status, message, now, int(logged_in), now, int(logged_in), now, account_id), + ) + connection.commit() diff --git a/app/services/publish_scheduler.py b/app/services/publish_scheduler.py index c9b8589..6e02529 100644 --- a/app/services/publish_scheduler.py +++ b/app/services/publish_scheduler.py @@ -1,114 +1,132 @@ +"""SQLite 发布调度器:立即发送和定时发送共用此状态机。""" + from __future__ import annotations import argparse import asyncio import json -from datetime import datetime, time, timedelta +import logging +import os +import socket +import sqlite3 +from datetime import datetime, timedelta, timezone from typing import Any +from uuid import uuid4 from app.core.config import settings from app.db.database import get_connection, init_db -from app.services.publish_adapters import PublishValidationError, publisher_for_job +from app.services.publish_executor import execute_publish_job +from app.services.publish_readiness import ( + PublishPlatformIsolationBlocked, + SendReadinessBlocked, + build_send_readiness, + list_account_snapshots, + require_worker_available, +) +from app.services.publish_repository import PublishRepository +from app.services.publish_time import ( + app_zone, + build_schedule_times, + ensure_future, + local_display, + next_allowed_schedule_time, + parse_datetime, + to_utc_iso, + utc_now, + utc_now_iso, +) +from app.services.publishers.base import ( + PublishError, + PublishOutcome, + PublishResult, + PublishWorkerUnavailable, +) +from app.services.publishers.worker_client import PublishWorkerClient from app.services.task_log_service import append_task_log -PUBLISH_STATUSES = { - "DRAFT", - "SCHEDULED", - "WAITING", - "PUBLISHING", - "PUBLISHED", - "FAILED", - "CANCELLED", - "NEED_REVIEW", -} +logger = logging.getLogger(__name__) def now_iso() -> str: - return datetime.now().astimezone().isoformat(timespec="seconds") - - -def parse_datetime(value: str | None) -> datetime: - text = (value or "").strip() - if not text: - raise ValueError("scheduled_at is empty") - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - if parsed.tzinfo is None: - return parsed.astimezone() - return parsed - - -def parse_clock(value: str, field_label: str) -> time: - try: - parsed = time.fromisoformat((value or "").strip()) - except ValueError as exc: - raise ValueError(f"{field_label}格式无效,请使用 HH:MM") from exc - return parsed.replace(second=0, microsecond=0) + return utc_now_iso() def build_batch_schedule_times( count: int, *, - start_at: str, - interval_hours: int, + start_at_local: str, + timezone_name: str, + interval_minutes: int, daily_start_time: str, daily_end_time: str, + reject_past: bool = True, ) -> list[str]: - if count <= 0: - return [] - - cursor = parse_datetime(start_at) - interval = timedelta(hours=max(1, int(interval_hours))) - window_start = parse_clock(daily_start_time, "每日开始时间") - window_end = parse_clock(daily_end_time, "每日结束时间") - if window_end <= window_start: - raise ValueError("每日结束时间必须晚于每日开始时间") - - scheduled: list[str] = [] - while len(scheduled) < count: - day_start = datetime.combine(cursor.date(), window_start).replace(tzinfo=cursor.tzinfo) - day_end = datetime.combine(cursor.date(), window_end).replace(tzinfo=cursor.tzinfo) - if cursor < day_start: - cursor = day_start - if cursor > day_end: - cursor = datetime.combine(cursor.date() + timedelta(days=1), window_start).replace( - tzinfo=cursor.tzinfo - ) - continue - scheduled.append(cursor.isoformat(timespec="seconds")) - cursor += interval - return scheduled - - -def _row_to_dict(row) -> dict[str, Any] | None: - return dict(row) if row else None - - -def _parse_json(value: str | None) -> Any: - if not value: - return None - try: - return json.loads(value) - except json.JSONDecodeError: - return value + return build_schedule_times( + count, + start_at_local=start_at_local, + timezone_name=timezone_name, + interval_minutes=interval_minutes, + daily_start_time=daily_start_time, + daily_end_time=daily_end_time, + reject_past=reject_past, + ) + + +def build_batch_schedule_preview( + job_ids: list[str], + *, + start_at_local: str, + timezone_name: str, + interval_minutes: int, + daily_start_time: str, + daily_end_time: str, + reject_past: bool = True, +) -> list[dict[str, str]]: + utc_times = build_batch_schedule_times( + len(job_ids), + start_at_local=start_at_local, + timezone_name=timezone_name, + interval_minutes=interval_minutes, + daily_start_time=daily_start_time, + daily_end_time=daily_end_time, + reject_past=reject_past, + ) + return [ + { + "job_id": job_id, + "scheduled_at_utc": scheduled, + "scheduled_at_local": parse_datetime(scheduled).astimezone(app_zone(timezone_name)).isoformat(timespec="seconds"), + "scheduled_at_local_display": local_display(scheduled, timezone_name), + "timezone": timezone_name, + } + for job_id, scheduled in zip(job_ids, utc_times, strict=True) + ] -def _risk_flags(job: dict[str, Any]) -> list[Any]: - direct = _parse_json(job.get("risk_flags")) - if isinstance(direct, list): - return [item for item in direct if item] - if isinstance(direct, str) and direct.strip(): - return [direct.strip()] - provider = _parse_json(job.get("provider_response")) - if isinstance(provider, dict) and isinstance(provider.get("risk_flags"), list): - return [item for item in provider["risk_flags"] if item] - return [] +def get_publish_job_raw(job_id: str) -> dict[str, Any] | None: + return PublishRepository().get_job(job_id) + + +_SCHEDULER_HEALTH: dict[str, Any] = { + "running": False, + "scanning": False, + "last_scan_at": "", + "next_scan_at": "", + "last_error_code": "", + "last_error_message": "", + "last_error_at": "", + "consecutive_failures": 0, +} +_ACTIVE_SCHEDULER: "PublishScheduler | None" = None -def get_publish_job_raw(job_id: str) -> dict[str, Any] | None: - with get_connection() as connection: - row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() - return _row_to_dict(row) +def wake_scheduler() -> bool: + scheduler = _ACTIVE_SCHEDULER + if not scheduler: + return False + scheduler.wake() + return True class PublishScheduler: @@ -116,404 +134,1193 @@ def __init__( self, interval_seconds: int | None = None, max_retry_count: int | None = None, + *, + executor=execute_publish_job, + repository: PublishRepository | None = None, + worker_client: PublishWorkerClient | None = None, + worker_id: str | None = None, ) -> None: self.interval_seconds = max(1, int(interval_seconds or settings.publish_scheduler_interval_seconds)) - self.max_retry_count = max(0, int(max_retry_count if max_retry_count is not None else settings.publish_scheduler_max_retry_count)) + self.max_retry_count = max( + 1, + int(max_retry_count if max_retry_count is not None else settings.publish_scheduler_max_retry_count), + ) + self.executor = executor + self.repository = repository or PublishRepository() + self.worker_client = worker_client or PublishWorkerClient() + self.worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}:{uuid4().hex[:8]}" self._stop_event: asyncio.Event | None = None + self._wake_event: asyncio.Event | None = None + self._loop: asyncio.AbstractEventLoop | None = None def run_once(self) -> dict[str, Any]: init_db() - self.recover_interrupted_jobs() - jobs = self.list_due_jobs() - results = [self.execute_job(job["id"]) for job in jobs] - return { - "status": "ok", - "checked_at": now_iso(), - "matched_count": len(jobs), - "published_count": sum(1 for item in results if item.get("status") == "published"), - "failed_count": sum(1 for item in results if item.get("status") == "failed"), - "skipped_count": sum(1 for item in results if item.get("status") == "skipped"), - "results": results, - } + _SCHEDULER_HEALTH["scanning"] = True + try: + recovered = self.recover_interrupted_jobs() + jobs = self.list_due_jobs() + results: list[dict[str, Any]] = [] + for job in jobs: + try: + results.append(self.execute_job(job["id"])) + except sqlite3.Error: + # 数据库属于全局基础设施故障;停止本轮,交给常驻循环稍后重试。 + raise + except Exception: + logger.exception("发布任务执行出现未预期异常:%s", job.get("id")) + results.append( + self._mark_need_review( + str(job["id"]), + "unexpected_scheduler_error", + "调度任务出现未预期异常,为避免重复投稿已转入人工复核", + ) + ) + checked_at = utc_now() + self._record_scan_success(checked_at) + return { + "status": "ok", + "checked_at": _SCHEDULER_HEALTH["last_scan_at"], + "recovered_count": recovered, + "matched_count": len(jobs), + "published_count": sum(item.get("status") == "published" for item in results), + "exported_count": sum(item.get("status") == "exported" for item in results), + "failed_count": sum(item.get("status") == "failed" for item in results), + "need_review_count": sum(item.get("status") == "need_review" for item in results), + "rescheduled_count": sum(item.get("status") == "rescheduled" for item in results), + "skipped_count": sum(item.get("status") == "skipped" for item in results), + "results": results, + } + finally: + _SCHEDULER_HEALTH["scanning"] = False async def run_forever(self) -> None: - init_db() + global _ACTIVE_SCHEDULER + self._loop = asyncio.get_running_loop() self._stop_event = asyncio.Event() - while not self._stop_event.is_set(): - self.run_once() - try: - await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds) - except TimeoutError: - continue + self._wake_event = asyncio.Event() + _ACTIVE_SCHEDULER = self + _SCHEDULER_HEALTH["running"] = True + try: + while not self._stop_event.is_set(): + try: + await asyncio.to_thread(self.run_once) + except Exception as exc: + self._record_scan_error(exc) + logger.exception("发布调度扫描失败,将在下一轮自动重试") + if self._stop_event.is_set(): + break + self._wake_event.clear() + try: + await asyncio.wait_for(self._wake_event.wait(), timeout=self.interval_seconds) + except TimeoutError: + pass + finally: + if _ACTIVE_SCHEDULER is self: + _ACTIVE_SCHEDULER = None + _SCHEDULER_HEALTH["running"] = False + + def _record_scan_success(self, checked_at: datetime) -> None: + _SCHEDULER_HEALTH["last_scan_at"] = checked_at.isoformat(timespec="seconds") + _SCHEDULER_HEALTH["next_scan_at"] = ( + checked_at + timedelta(seconds=self.interval_seconds) + ).isoformat(timespec="seconds") + _SCHEDULER_HEALTH["last_error_code"] = "" + _SCHEDULER_HEALTH["last_error_message"] = "" + _SCHEDULER_HEALTH["last_error_at"] = "" + _SCHEDULER_HEALTH["consecutive_failures"] = 0 + + def _record_scan_error(self, exc: Exception) -> None: + failed_at = utc_now() + if isinstance(exc, sqlite3.Error): + error_code = "database_unavailable" + error_message = "数据库暂时不可用,调度器将在下一轮自动重试" + else: + error_code = "scheduler_scan_failed" + error_message = "调度扫描出现异常,调度器将在下一轮自动重试" + _SCHEDULER_HEALTH["last_error_code"] = error_code + _SCHEDULER_HEALTH["last_error_message"] = error_message + _SCHEDULER_HEALTH["last_error_at"] = failed_at.isoformat(timespec="seconds") + _SCHEDULER_HEALTH["next_scan_at"] = ( + failed_at + timedelta(seconds=self.interval_seconds) + ).isoformat(timespec="seconds") + _SCHEDULER_HEALTH["consecutive_failures"] = ( + int(_SCHEDULER_HEALTH.get("consecutive_failures") or 0) + 1 + ) - def stop(self) -> None: - if self._stop_event: - self._stop_event.set() + def wake(self) -> None: + if self._loop and self._wake_event: + self._loop.call_soon_threadsafe(self._wake_event.set) - def recover_interrupted_jobs(self) -> int: - now = now_iso() - with get_connection() as connection: - cursor = connection.execute( - """ - UPDATE publish_jobs - SET status = 'SCHEDULED', - last_error = COALESCE(NULLIF(last_error, ''), 'Recovered from interrupted PUBLISHING state'), - error_message = COALESCE(NULLIF(error_message, ''), 'Recovered from interrupted PUBLISHING state'), - updated_at = ? - WHERE status = 'PUBLISHING' AND published_at IS NULL - """, - (now,), - ) - connection.commit() - return int(cursor.rowcount or 0) + def stop(self) -> None: + if self._loop and self._stop_event: + self._loop.call_soon_threadsafe(self._stop_event.set) + if self._wake_event: + self._loop.call_soon_threadsafe(self._wake_event.set) def list_due_jobs(self) -> list[dict[str, Any]]: - current = datetime.now().astimezone() + now = utc_now() due: list[dict[str, Any]] = [] with get_connection() as connection: rows = connection.execute( """ - SELECT * - FROM publish_jobs + SELECT * FROM publish_jobs WHERE status = 'SCHEDULED' - ORDER BY scheduled_at ASC, created_at ASC + ORDER BY COALESCE(next_attempt_at, scheduled_at), created_at """ ).fetchall() for row in rows: job = dict(row) + due_value = job.get("next_attempt_at") or job.get("scheduled_at") try: - scheduled_at = parse_datetime(job.get("scheduled_at")) - except ValueError as exc: - due.append({**job, "_invalid_schedule_error": str(exc)}) + due_at = parse_datetime(due_value).astimezone(timezone.utc) + except ValueError: + due.append(job) continue - if scheduled_at <= current: + if due_at <= now: due.append(job) return due - def execute_job(self, job_id: str, *, force: bool = False, allow_republish: bool = False) -> dict[str, Any]: - job = get_publish_job_raw(job_id) + def execute_job(self, job_id: str, *, force: bool = False, runner=None) -> dict[str, Any]: + job = self.repository.get_job(job_id) if not job: - return {"status": "failed", "job_id": job_id, "message": "publish job not found"} - + return {"status": "failed", "job_id": job_id, "message": "发布任务不存在"} status = str(job.get("status") or "").upper() - if status == "PUBLISHED" and not allow_republish: - return {"status": "skipped", "job_id": job_id, "message": "already published"} - if status in {"CANCELLED", "NEED_REVIEW"}: - return {"status": "skipped", "job_id": job_id, "message": f"status is {status}"} - if status not in {"SCHEDULED", "FAILED", "PUBLISHING"} and not force: - return {"status": "skipped", "job_id": job_id, "message": f"status is {status}"} - - if _risk_flags(job) and not settings.publish_scheduler_allow_publish_without_review: - self._mark_need_review(job_id, _risk_flags(job)) - return {"status": "skipped", "job_id": job_id, "message": "risk flags require review"} + if status in {"PUBLISHED", "EXPORTED", "CANCELLED", "PUBLISHING", "NEED_REVIEW"}: + return {"status": "skipped", "job_id": job_id, "message": f"任务状态为 {status},不能领取"} + if status != "SCHEDULED": + return {"status": "skipped", "job_id": job_id, "message": f"任务状态为 {status}"} + + # 旧版 OpenCLI 排期不得由新调度器静默补发。先转入人工复核, + # 只有用户在对应平台逐条确认后,才会创建新的 Windows Chrome 任务。 + if str(job.get("publish_mode") or "") == "opencli_publish": + return self._mark_need_review( + job_id, + "legacy_schedule_requires_confirmation", + "旧版排期已暂停,未执行上传;请选择对应平台账号后逐条转换并发送", + ) + readiness = build_send_readiness( + job, + accounts=list_account_snapshots(), + resolve_legacy=False, + validate_files=True, + ) + if not readiness["ready"]: + return { + "status": "skipped", + "job_id": job_id, + "error_code": "send_setup_required", + "message": readiness["message"], + "send_readiness": readiness, + } + if readiness["requires_worker"]: + try: + require_worker_available(self.worker_client) + except SendReadinessBlocked as exc: + return { + "status": "skipped", + "job_id": job_id, + "error_code": "publish_worker_unavailable", + "message": str(exc), + "send_readiness": exc.readiness, + } + + risk_flags = self._risk_flags(job) + if risk_flags and not settings.publish_scheduler_allow_publish_without_review: + return self._mark_need_review(job_id, "risk_flags_require_review", f"内容风险标记需要人工复核:{risk_flags}") try: - scheduled_at = parse_datetime(job.get("scheduled_at")) + due_at = parse_datetime(job.get("next_attempt_at") or job.get("scheduled_at")) except ValueError as exc: return self._mark_failed(job_id, "invalid_scheduled_at", str(exc)) - if not force and scheduled_at > datetime.now().astimezone(): - return {"status": "skipped", "job_id": job_id, "message": "scheduled_at is in the future"} - - attempts = int(job.get("attempt_count") or job.get("retry_count") or 0) - if not force and attempts >= self.max_retry_count: - return self._mark_failed(job_id, "max_retry_exceeded", "max retry count exceeded") - - self._mark_publishing(job_id) - job = get_publish_job_raw(job_id) or job + if not force and due_at > utc_now(): + return {"status": "skipped", "job_id": job_id, "message": "尚未到计划发布时间"} + + max_attempts = max(1, int(job.get("max_attempts") or self.max_retry_count)) + if not force and int(job.get("attempt_count") or 0) >= max_attempts: + return self._mark_failed(job_id, "max_retry_exceeded", "上传前安全重试次数已用完") + if not self._claim_scheduled_job(job_id): + return {"status": "skipped", "job_id": job_id, "message": "任务已被另一个调度器领取"} + claimed = self.repository.get_job(job_id) or job try: - result = publisher_for_job(job).publish(job) - except PublishValidationError as exc: + raw_result = self.executor( + job_id, + force=force, + runner=runner, + repository=self.repository, + worker_client=self.worker_client, + ) + result = PublishResult.from_dict(raw_result) + except PublishWorkerUnavailable as exc: + return self._handle_worker_unavailable(claimed, exc) + except PublishError as exc: + if exc.needs_manual_review: + return self._mark_need_review(job_id, exc.error_code, exc.message) return self._mark_failed(job_id, exc.error_code, exc.message) except Exception as exc: - return self._mark_failed(job_id, "publish_failed", str(exc) or exc.__class__.__name__) - - return self._mark_published(job_id, result.payload, result.remote_video_id) + return self._mark_need_review( + job_id, + "publish_result_uncertain", + f"执行器异常且无法确定是否已上传,请人工核对:{exc}", + ) - def publish_now(self, job_id: str, *, allow_republish: bool = False) -> dict[str, Any]: - self._set_schedule_to_now(job_id) - return self.execute_job(job_id, force=True, allow_republish=allow_republish) + if result.outcome == PublishOutcome.PUBLISHED: + return self._mark_published(job_id, result) + if result.outcome == PublishOutcome.EXPORTED: + return self._mark_exported(job_id, result) + if result.outcome == PublishOutcome.NEED_REVIEW or result.needs_manual_review: + return self._mark_need_review(job_id, result.error_code or "manual_review_required", result.message, result) + return self._mark_failed(job_id, result.error_code or "publish_failed", result.message, result) - def retry_failed(self, job_id: str) -> dict[str, Any]: - job = get_publish_job_raw(job_id) + def publish_now(self, job_id: str) -> dict[str, Any]: + job = self.repository.get_job(job_id) if not job: - raise ValueError("publish job not found") - if str(job.get("status") or "").upper() != "FAILED": - raise ValueError("only FAILED publish jobs can be retried") - self._set_schedule_to_now(job_id) - return self.execute_job(job_id, force=True) - - def cancel_job(self, job_id: str) -> dict[str, Any]: - return self._update_status(job_id, "CANCELLED", "cancelled manually") + raise ValueError("发布任务不存在") + if str(job.get("status") or "").upper() not in {"DRAFT", "WAITING", "SCHEDULED"}: + raise ValueError("只有草稿、等待或已排期任务可以立即发送") + readiness = self._require_ready_jobs([job_id], resolve_legacy=True, check_worker=True)[job_id] + if str(job.get("publish_mode") or "") == "opencli_publish": + self._mark_need_review( + job_id, + "legacy_schedule_requires_confirmation", + "旧版任务已暂停;正在保留原记录并创建新的 Windows Chrome 投稿任务", + ) + return self.repair_and_publish( + job_id, + account_id=str(readiness.get("resolved_account_id") or ""), + ) + now = utc_now_iso() + with get_connection() as connection: + cursor = connection.execute( + """ + UPDATE publish_jobs + SET account_id = ?, publish_mode = ?, scheduled_at = ?, next_attempt_at = NULL, + timezone = ?, schedule_timezone = ?, + status = 'SCHEDULED', error_code = '', error_message = '', last_error = '', + needs_manual_review = 0, updated_at = ? + WHERE id = ? AND status IN ('DRAFT', 'WAITING', 'SCHEDULED') + """, + ( + readiness["resolved_account_id"] or job.get("account_id") or None, + readiness["resolved_publish_mode"] or job.get("publish_mode"), + now, + settings.app_timezone, + settings.app_timezone, + now, + job_id, + ), + ) + if cursor.rowcount: + self._record_auto_target_resolution(job, readiness, connection=connection) + self.repository.add_event( + job_id, "publish_now", from_status=str(job.get("status") or ""), + to_status="SCHEDULED", message="立即发送已进入统一调度队列", connection=connection, + ) + connection.commit() + if not cursor.rowcount: + raise ValueError("任务状态已变化,请刷新页面后重试") + wake_scheduler() + return {"status": "scheduled", "job_id": job_id, "scheduled_at": now, "job": self._public_job(job_id)} - def skip_job(self, job_id: str) -> dict[str, Any]: - return self._update_status(job_id, "CANCELLED", "skipped manually", {"action": "skip"}) + def retry_failed( + self, + job_id: str, + scheduled_at: str | None = None, + *, + visibility: str | None = None, + ) -> dict[str, Any]: + source = self.repository.get_job(job_id) + if not source: + raise ValueError("发布任务不存在") + if str(source.get("status") or "").upper() != "FAILED": + raise ValueError("只有明确失败的任务可以重试;需复核任务请先确认平台未发布并标记失败") + schedule = to_utc_iso(scheduled_at, settings.app_timezone) if scheduled_at else utc_now_iso() + if scheduled_at: + ensure_future(scheduled_at, settings.app_timezone) + resolved_visibility = str(visibility or source.get("visibility") or "public") + if resolved_visibility not in {"public", "friends", "private"}: + raise ValueError("可见范围只支持公开、好友可见或仅自己可见") + readiness = self._require_ready_jobs( + [job_id], + resolve_legacy=True, + check_worker=True, + )[job_id] + resolved_mode = str(readiness.get("resolved_publish_mode") or source.get("publish_mode") or "") + with get_connection() as connection: + active = connection.execute( + """ + SELECT id, status FROM publish_jobs + WHERE id <> ? AND output_clip_id = ? AND platform = ? AND publish_mode = ? + AND status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING', 'NEED_REVIEW') + ORDER BY created_at DESC LIMIT 1 + """, + ( + job_id, + source.get("output_clip_id"), + source.get("platform"), + resolved_mode, + ), + ).fetchone() + if active: + raise ValueError( + f"同一视频已有任务 {active['id']} 处于 {active['status']};" + "如为人工复核,请先确认平台未发布并标记失败" + ) + return self._clone_job_for_retry( + source, + scheduled_at=schedule, + event_type="manual_retry_created", + event_message=f"由失败任务 {job_id} 创建", + event_from_status="FAILED", + overrides={ + "visibility": resolved_visibility, + "publish_mode": resolved_mode, + "account_id": readiness.get("resolved_account_id") or source.get("account_id") or None, + }, + ) - def approve_review(self, job_id: str) -> dict[str, Any]: - job = get_publish_job_raw(job_id) - if not job: - raise ValueError("publish job not found") - if str(job.get("status") or "").upper() != "NEED_REVIEW": - raise ValueError("only NEED_REVIEW jobs can be approved") - now = now_iso() - next_status = "SCHEDULED" if (job.get("scheduled_at") or "").strip() else "WAITING" + def repair_and_publish( + self, + job_id: str, + account_id: str = "", + visibility: str = "", + ) -> dict[str, Any]: + source = self.repository.get_job(job_id) + if not source: + raise ValueError("发布任务不存在") + source_readiness = build_send_readiness( + source, + accounts=list_account_snapshots(), + resolve_legacy=False, + ) + if str(source.get("status") or "").upper() != "NEED_REVIEW" or not source_readiness["repairable"]: + raise ValueError("该任务不是明确发生在上传前的旧任务,不能自动修复;请先人工核对平台结果") with get_connection() as connection: - connection.execute( + existing = connection.execute( """ - UPDATE publish_jobs - SET status = ?, risk_flags = '', last_error = '', - error_message = '', updated_at = ? - WHERE id = ? + SELECT id FROM publish_jobs + WHERE retry_of_job_id = ? + ORDER BY created_at DESC LIMIT 1 """, - (next_status, now, job_id), + (job_id,), + ).fetchone() + if existing: + existing_id = str(existing["id"]) + return { + "status": "already_created", + "job_id": existing_id, + "source_job_id": job_id, + "job": self._public_job(existing_id), + "message": "该旧任务已经创建过替代任务,本次没有重复创建", + } + resolved_visibility = visibility.strip() or str(source.get("visibility") or "public") + if resolved_visibility not in {"public", "friends", "private"}: + raise ValueError("可见范围只支持公开、好友可见或仅自己可见") + candidate = { + **source, + "account_id": account_id.strip() or source.get("account_id") or "", + "visibility": resolved_visibility, + } + readiness = build_send_readiness( + candidate, + accounts=list_account_snapshots(), + resolve_legacy=True, + validate_files=True, + ) + if not readiness["dispatch_ready"]: + raise SendReadinessBlocked(readiness) + if readiness["requires_worker"]: + require_worker_available(self.worker_client) + result = self._clone_job_for_retry( + source, + scheduled_at=utc_now_iso(), + event_type="safe_repair_created", + event_message=f"由上传前失败任务 {job_id} 安全修复", + event_from_status="NEED_REVIEW", + overrides={ + "publish_mode": readiness["resolved_publish_mode"], + "account_id": readiness["resolved_account_id"] or None, + "visibility": resolved_visibility, + }, + ) + with get_connection() as connection: + self.repository.add_event( + job_id, + "safe_repair_replacement_created", + from_status="NEED_REVIEW", + to_status="NEED_REVIEW", + message=f"已保留原记录并创建替代任务 {result['job_id']}", + payload={"replacement_job_id": result["job_id"]}, + connection=connection, + ) + connection.commit() + result["message"] = "旧任务已保留,新的 Windows Chrome 投稿任务已进入调度器" + result["source_job_id"] = job_id + return result + + def _clone_job_for_retry( + self, + source: dict[str, Any], + *, + scheduled_at: str, + event_type: str, + event_message: str, + event_from_status: str, + overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + source_id = str(source.get("id") or "") + new_id = f"pub_{uuid4().hex}" + columns_to_clear = { + "id", "status", "scheduled_at", "next_attempt_at", "attempt_count", "retry_count", + "claimed_at", "started_at", "finished_at", "worker_id", "execution_id", "execution_phase", + "platform_item_id", "platform_upload_id", "remote_video_id", "platform_url", "error_code", + "error_message", "last_error", "provider_response", "publish_result", "published_at", + "needs_manual_review", "created_at", "updated_at", "retry_of_job_id", + } + with get_connection() as connection: + available = {row["name"] for row in connection.execute("PRAGMA table_info(publish_jobs)").fetchall()} + values = {key: value for key, value in source.items() if key in available and key not in columns_to_clear} + values.update({ + "id": new_id, + "status": "SCHEDULED", + "scheduled_at": scheduled_at, + "timezone": source.get("timezone") or settings.app_timezone, + "schedule_timezone": source.get("schedule_timezone") or settings.app_timezone, + "attempt_count": 0, + "retry_count": 0, + "max_attempts": source.get("max_attempts") or self.max_retry_count, + "needs_manual_review": 0, + "retry_of_job_id": source_id, + "created_at": utc_now_iso(), + "updated_at": utc_now_iso(), + }) + values.update(overrides or {}) + columns = list(values) + try: + connection.execute( + f"INSERT INTO publish_jobs ({', '.join(columns)}) VALUES ({', '.join('?' for _ in columns)})", + [values[column] for column in columns], + ) + except sqlite3.IntegrityError as exc: + raise ValueError( + "同一视频已经存在等待、排期、执行中或人工复核任务;请刷新发送中心后核对" + ) from exc + self.repository.add_event( + new_id, event_type, from_status=event_from_status, to_status="SCHEDULED", + message=event_message, payload={"retry_of_job_id": source_id}, connection=connection, ) connection.commit() - return {"status": "ok", "job": get_publish_job_raw(job_id)} + wake_scheduler() + return {"status": "scheduled", "job_id": new_id, "retry_of_job_id": source_id, "job": self._public_job(new_id)} + + def recover_interrupted_jobs(self) -> int: + stale_before = utc_now() - timedelta(minutes=max(1, int(settings.publish_job_stale_minutes))) + with get_connection() as connection: + rows = connection.execute("SELECT * FROM publish_jobs WHERE status = 'PUBLISHING'").fetchall() + recovered = 0 + for raw in rows: + job = dict(raw) + try: + updated_at = parse_datetime(job.get("updated_at")).astimezone(timezone.utc) + except ValueError: + updated_at = datetime.min.replace(tzinfo=timezone.utc) + execution_id = str(job.get("execution_id") or "") + phase = str(job.get("execution_phase") or "unknown") + details: dict[str, Any] = {} + # Worker 执行日志是跨进程恢复的唯一依据。只要有 execution_id 就主动查询, + # 不依赖宿主 Worker 回写 SQLite,也不会因此重复调用投稿接口。 + if execution_id: + try: + execution = self.worker_client.execution(execution_id) + phase = str(execution.get("phase") or phase) + details = execution.get("details") if isinstance(execution.get("details"), dict) else {} + except PublishError: + pass + if phase == "confirmed_success" and details: + try: + self._mark_published(job["id"], PublishResult.from_dict(details)) + except Exception: + self._mark_need_review(job["id"], "recovery_result_uncertain", "Worker 记录成功但结果数据不完整,请人工确认") + recovered += 1 + elif phase == "manual_review" and details: + result = PublishResult.from_dict(details) + self._mark_need_review( + job["id"], + result.error_code or "manual_review_required", + result.message or "Worker 已停止自动发送,请人工确认平台结果", + result, + ) + recovered += 1 + elif phase == "failed" and details: + result = PublishResult.from_dict(details) + self._mark_failed( + job["id"], + result.error_code or "publish_failed", + result.message or "Worker 已确认发送失败", + result, + ) + recovered += 1 + elif updated_at > stale_before: + continue + elif phase in {"received", "browser_opening", "browser_opened", "rejected"} and execution_id: + self._reschedule_before_upload(job["id"], "应用重启后确认尚未开始上传,已安全重新排队") + recovered += 1 + else: + self._mark_need_review( + job["id"], + "interrupted_publish_uncertain", + "应用重启前的发布结果不确定,为避免重复投稿已停止自动重试", + ) + recovered += 1 + return recovered def update_schedule(self, job_id: str, scheduled_at: str) -> dict[str, Any]: - parsed = parse_datetime(scheduled_at) - job = get_publish_job_raw(job_id) + parsed = ensure_future(scheduled_at, settings.app_timezone) + job = self.repository.get_job(job_id) if not job: - raise ValueError("publish job not found") - if str(job.get("status") or "").upper() == "PUBLISHED": - raise ValueError("published jobs cannot be rescheduled") - now = now_iso() + raise ValueError("发布任务不存在") + if str(job.get("status") or "").upper() not in {"DRAFT", "WAITING", "SCHEDULED"}: + raise ValueError("当前状态不能修改排期;失败任务请使用重试,需复核任务请先人工确认") + if str(job.get("publish_mode") or "") == "opencli_publish": + raise ValueError("旧版任务不能直接改排期;请逐条使用“转换并发送”创建新的 Windows Chrome 任务") + readiness = self._require_ready_jobs([job_id], resolve_legacy=True, check_worker=True)[job_id] + stored = to_utc_iso(parsed) + now = utc_now_iso() with get_connection() as connection: connection.execute( """ - UPDATE publish_jobs - SET scheduled_at = ?, status = 'SCHEDULED', updated_at = ? + UPDATE publish_jobs SET account_id = ?, publish_mode = ?, + scheduled_at = ?, next_attempt_at = NULL, + timezone = ?, schedule_timezone = ?, status = 'SCHEDULED', updated_at = ? WHERE id = ? """, - (parsed.isoformat(timespec="seconds"), now, job_id), + ( + readiness["resolved_account_id"] or job.get("account_id") or None, + readiness["resolved_publish_mode"] or job.get("publish_mode"), + stored, + settings.app_timezone, + settings.app_timezone, + now, + job_id, + ), + ) + self._record_auto_target_resolution(job, readiness, connection=connection) + self.repository.add_event( + job_id, "schedule_updated", from_status=str(job.get("status") or ""), + to_status="SCHEDULED", payload={"scheduled_at": stored}, connection=connection, ) connection.commit() - return {"status": "ok", "job": get_publish_job_raw(job_id)} + wake_scheduler() + return {"status": "ok", "job": self._public_job(job_id)} - def update_batch_schedule( + def preview_batch_schedule( self, job_ids: list[str], *, - action: str, - start_at: str = "", - interval_hours: int = 3, - daily_start_time: str = "09:00", - daily_end_time: str = "21:00", + platform: str | None = None, + start_at_local: str, + timezone_name: str, + interval_minutes: int, + daily_start_time: str, + daily_end_time: str, ) -> dict[str, Any]: - normalized_ids = list(dict.fromkeys(str(job_id).strip() for job_id in job_ids if str(job_id).strip())) - if not normalized_ids: - raise ValueError("至少选择一条发布任务") - if action not in {"apply", "clear"}: - raise ValueError("不支持的排期操作") + ids = self._validate_batch_jobs(job_ids, platform) + self._reject_legacy_schedule_apply(ids) + self._require_ready_jobs(ids, resolve_legacy=True, check_worker=True) + schedule = build_batch_schedule_preview( + ids, + start_at_local=start_at_local, + timezone_name=timezone_name, + interval_minutes=interval_minutes, + daily_start_time=daily_start_time, + daily_end_time=daily_end_time, + ) + return {"status": "ok", "timezone": timezone_name, "schedule": schedule} - placeholders = ", ".join("?" for _ in normalized_ids) + def next_batch_schedule_start( + self, + job_ids: list[str], + *, + platform: str, + timezone_name: str = "Asia/Shanghai", + interval_minutes: int = 180, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", + ) -> dict[str, Any]: + ids = self._validate_batch_jobs(job_ids, platform) + placeholders = ",".join("?" for _ in ids) with get_connection() as connection: rows = connection.execute( - f"SELECT * FROM publish_jobs WHERE id IN ({placeholders})", - normalized_ids, + f""" + SELECT id, status, scheduled_at + FROM publish_jobs + WHERE platform = ? + AND status IN ('WAITING', 'SCHEDULED', 'PUBLISHING') + AND TRIM(COALESCE(scheduled_at, '')) <> '' + AND id NOT IN ({placeholders}) + """, + [platform, *ids], ).fetchall() - jobs_by_id = {row["id"]: dict(row) for row in rows} - missing_ids = [job_id for job_id in normalized_ids if job_id not in jobs_by_id] - if missing_ids: - raise ValueError(f"有 {len(missing_ids)} 条发布任务不存在") - blocked = [ - job_id - for job_id in normalized_ids - if str(jobs_by_id[job_id].get("status") or "").upper() in {"PUBLISHED", "CANCELLED"} - ] - if blocked: - raise ValueError("已发布或已取消的任务不能修改排期") - - schedule_times = ( - build_batch_schedule_times( - len(normalized_ids), - start_at=start_at, - interval_hours=interval_hours, + + now = utc_now() + candidates: list[tuple[datetime, str]] = [] + for row in rows: + try: + scheduled = parse_datetime(row["scheduled_at"]).astimezone(timezone.utc) + except ValueError: + continue + if scheduled > now: + candidates.append((scheduled, str(row["id"]))) + + if not candidates: + return { + "status": "empty", + "timezone": timezone_name, + "message": "当前平台暂无其他未来排期,请手动选择第 1 条发布时间。", + "latest_job_id": "", + "latest_scheduled_at_utc": "", + "latest_scheduled_at_local_display": "", + "next_start_at_utc": "", + "next_start_at_local": "", + "next_start_at_local_display": "", + } + + latest, latest_job_id = max(candidates, key=lambda item: item[0]) + zone = app_zone(timezone_name) + candidate = latest.astimezone(zone) + timedelta(minutes=interval_minutes) + next_start = next_allowed_schedule_time( + candidate, + daily_start_time=daily_start_time, + daily_end_time=daily_end_time, + ) + return { + "status": "ok", + "timezone": timezone_name, + "message": "已接在当前平台最晚排期后。", + "latest_job_id": latest_job_id, + "latest_scheduled_at_utc": to_utc_iso(latest), + "latest_scheduled_at_local_display": local_display(latest, timezone_name), + "next_start_at_utc": to_utc_iso(next_start), + "next_start_at_local": next_start.strftime("%Y-%m-%dT%H:%M"), + "next_start_at_local_display": local_display(next_start, timezone_name), + } + + def update_batch_schedule( + self, + job_ids: list[str], + *, + platform: str | None = None, + action: str, + start_at_local: str = "", + timezone_name: str = "Asia/Shanghai", + interval_minutes: int = 180, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", + confirmed_schedule: list[dict[str, str]] | None = None, + ) -> dict[str, Any]: + ids = self._validate_batch_jobs(job_ids, platform) + if action not in {"apply", "clear"}: + raise ValueError("不支持的排期操作") + if action == "apply": + self._reject_legacy_schedule_apply(ids) + readiness_map = ( + self._require_ready_jobs(ids, resolve_legacy=True, check_worker=True) + if action == "apply" + else {} + ) + if action == "apply" and confirmed_schedule: + schedule_map = {str(item.get("job_id") or ""): str(item.get("scheduled_at_utc") or "") for item in confirmed_schedule} + if set(schedule_map) != set(ids): + raise ValueError("确认排期与所选任务不一致,请重新预览") + schedule = [] + for job_id in ids: + parsed = ensure_future(schedule_map[job_id], timezone_name) + stored = to_utc_iso(parsed) + schedule.append({ + "job_id": job_id, + "scheduled_at_utc": stored, + "scheduled_at_local": parsed.astimezone(app_zone(timezone_name)).isoformat(timespec="seconds"), + "scheduled_at_local_display": local_display(parsed, timezone_name), + "timezone": timezone_name, + }) + elif action == "apply": + schedule = build_batch_schedule_preview( + ids, + start_at_local=start_at_local, + timezone_name=timezone_name, + interval_minutes=interval_minutes, daily_start_time=daily_start_time, daily_end_time=daily_end_time, ) - if action == "apply" - else [""] * len(normalized_ids) - ) - - now = now_iso() + else: + schedule = [{ + "job_id": job_id, "scheduled_at_utc": "", "scheduled_at_local": "", + "scheduled_at_local_display": "未排期", "timezone": timezone_name, + } for job_id in ids] + now = utc_now_iso() with get_connection() as connection: - for job_id, scheduled_at in zip(normalized_ids, schedule_times, strict=True): - current_status = str(jobs_by_id[job_id].get("status") or "").upper() - if current_status == "NEED_REVIEW": - next_status = "NEED_REVIEW" - elif action == "apply": - next_status = "SCHEDULED" - elif current_status == "FAILED": - next_status = "FAILED" - else: - next_status = "WAITING" + rows = connection.execute( + f"SELECT id, status, account_id, publish_mode FROM publish_jobs WHERE id IN ({','.join('?' for _ in ids)})", ids + ).fetchall() + row_map = {row["id"]: dict(row) for row in rows} + for item in schedule: + job_id = item["job_id"] + current = row_map[job_id] + status = str(current["status"] or "").upper() + if status not in {"DRAFT", "WAITING", "SCHEDULED"}: + raise ValueError(f"任务 {job_id} 当前状态不能修改排期") + next_status = "SCHEDULED" if action == "apply" else "WAITING" + readiness = readiness_map.get(job_id) or {} connection.execute( """ - UPDATE publish_jobs - SET scheduled_at = ?, status = ?, updated_at = ?, - error_code = CASE WHEN ? = 'apply' THEN '' ELSE error_code END, - error_message = CASE WHEN ? = 'apply' THEN '' ELSE error_message END, - last_error = CASE WHEN ? = 'apply' THEN '' ELSE last_error END + UPDATE publish_jobs SET account_id = ?, publish_mode = ?, + scheduled_at = ?, next_attempt_at = NULL, + timezone = ?, schedule_timezone = ?, status = ?, updated_at = ? WHERE id = ? """, - (scheduled_at, next_status, now, action, action, action, job_id), + ( + readiness.get("resolved_account_id") or current.get("account_id") or None, + readiness.get("resolved_publish_mode") or current.get("publish_mode"), + item["scheduled_at_utc"], timezone_name, timezone_name, next_status, now, job_id, + ), + ) + if action == "apply": + self._record_auto_target_resolution(current, readiness, connection=connection) + self.repository.add_event( + job_id, "batch_schedule_applied" if action == "apply" else "schedule_cleared", + from_status=status, to_status=next_status, payload=item, connection=connection, ) connection.commit() - + if action == "apply": + wake_scheduler() return { - "status": "ok", - "action": action, - "updated_count": len(normalized_ids), - "message": ( - f"已为 {len(normalized_ids)} 条任务设置发布时间。" - if action == "apply" - else f"已清除 {len(normalized_ids)} 条任务的发布时间。" - ), - "jobs": [get_publish_job_raw(job_id) for job_id in normalized_ids], + "status": "ok", "action": action, "updated_count": len(ids), + "message": f"已保存 {len(ids)} 条任务的具体排期" if action == "apply" else f"已清除 {len(ids)} 条任务的排期", + "schedule": schedule, + "jobs": [self._public_job(job_id) for job_id in ids], } - def _set_schedule_to_now(self, job_id: str) -> None: - now = now_iso() + def cancel_job(self, job_id: str) -> dict[str, Any]: + job = self.repository.get_job(job_id) + if not job: + raise ValueError("发布任务不存在") + source = str(job.get("status") or "").upper() + if source not in {"DRAFT", "WAITING", "SCHEDULED"}: + raise ValueError("只有草稿、等待或已排期任务可以取消发送并返回内容准备") + now = utc_now_iso() with get_connection() as connection: - connection.execute( + cursor = connection.execute( """ UPDATE publish_jobs - SET scheduled_at = ?, status = CASE - WHEN status = 'PUBLISHED' THEN status - WHEN status = 'CANCELLED' THEN status - WHEN status = 'NEED_REVIEW' THEN status - ELSE 'SCHEDULED' - END, - updated_at = ? - WHERE id = ? + SET status = 'WAITING', scheduled_at = '', next_attempt_at = NULL, + claimed_at = NULL, started_at = NULL, finished_at = NULL, + worker_id = NULL, execution_id = NULL, execution_phase = '', + error_code = '', error_message = '', last_error = '', + needs_manual_review = 0, updated_at = ? + WHERE id = ? AND status = ? """, - (now, now, job_id), + (now, job_id, source), ) + if cursor.rowcount: + self.repository.add_event( + job_id, + "returned_to_preparation", + from_status=source, + to_status="WAITING", + message="用户取消发送并返回内容准备", + payload={"scheduled_at_cleared": True, "files_deleted": False}, + connection=connection, + ) connection.commit() + if not cursor.rowcount: + raise ValueError("任务状态已变化,请刷新后重试") + return { + "status": "ok", + "message": "已取消发送并返回内容准备;视频、文案和封面均已保留。", + "job": self._public_job(job_id), + } + + def skip_job(self, job_id: str) -> dict[str, Any]: + return self._transition_user_status(job_id, "CANCELLED", "用户跳过任务") + + def mark_failed_manually(self, job_id: str, message: str = "人工确认平台未发布") -> dict[str, Any]: + job = self.repository.get_job(job_id) + if not job: + raise ValueError("发布任务不存在") + if str(job.get("status") or "").upper() not in {"NEED_REVIEW", "SCHEDULED", "WAITING"}: + raise ValueError("当前任务不能人工标记失败") + return self._mark_failed(job_id, "manually_marked_failed", message) + + def mark_published_manually(self, job_id: str, platform_url: str) -> dict[str, Any]: + job = self.repository.get_job(job_id) + if not job: + raise ValueError("发布任务不存在") + if str(job.get("status") or "").upper() != "NEED_REVIEW": + raise ValueError("只有需复核任务可以人工标记已发布") + url = str(platform_url or "").strip() + expected_domain = "douyin.com" if job.get("platform") == "douyin" else "bilibili.com" + if not url.startswith(("http://", "https://")) or expected_domain not in url.lower(): + raise ValueError(f"请填写有效的 {expected_domain} 作品链接") + result = PublishResult( + outcome=PublishOutcome.PUBLISHED, + message="人工核对平台后标记为已发布", + remote_video_id="", + platform_url=url, + published_at=utc_now_iso(), + provider_response={"manual_confirmation": True, "platform_url": url}, + ) + return self._mark_published(job_id, result, require_publishing=False) - def _mark_publishing(self, job_id: str) -> None: - now = now_iso() - payload = json.dumps({"publisher": "started", "started_at": now}, ensure_ascii=False) + def approve_review(self, job_id: str, platform_url: str = "") -> dict[str, Any]: + return self.mark_published_manually(job_id, platform_url) + + def _validate_batch_jobs(self, job_ids: list[str], platform: str | None = None) -> list[str]: + ids = list(dict.fromkeys(str(job_id).strip() for job_id in job_ids if str(job_id).strip())) + if not ids: + raise ValueError("至少选择一条发布任务") with get_connection() as connection: - connection.execute( + rows = connection.execute( + f"SELECT id, platform FROM publish_jobs WHERE id IN ({','.join('?' for _ in ids)})", ids + ).fetchall() + if len(rows) != len(ids): + raise ValueError("部分发布任务不存在") + platforms = {str(row["platform"] or "") for row in rows} + if len(platforms) != 1: + raise PublishPlatformIsolationBlocked("抖音和 B站任务不能混合排期或批量操作") + if platform and platform not in platforms: + raise PublishPlatformIsolationBlocked("当前平台与所选任务不一致,请切换到对应平台后重试") + return ids + + def _reject_legacy_schedule_apply(self, job_ids: list[str]) -> None: + with get_connection() as connection: + count = connection.execute( + f"SELECT COUNT(*) FROM publish_jobs WHERE id IN ({','.join('?' for _ in job_ids)}) AND publish_mode = 'opencli_publish'", + job_ids, + ).fetchone()[0] + if int(count): + raise ValueError("旧版任务不能批量覆盖转换;请逐条使用“转换并发送”保留原记录") + + def _require_ready_jobs( + self, + job_ids: list[str], + *, + resolve_legacy: bool, + check_worker: bool, + ) -> dict[str, dict[str, Any]]: + accounts = list_account_snapshots() + readiness_map: dict[str, dict[str, Any]] = {} + worker_required = False + for job_id in job_ids: + job = self.repository.get_job(job_id) + if not job: + raise ValueError("发布任务不存在") + readiness = build_send_readiness( + job, + accounts=accounts, + resolve_legacy=resolve_legacy, + validate_files=True, + ) + readiness_map[job_id] = readiness + if not readiness["dispatch_ready"]: + raise SendReadinessBlocked(readiness) + worker_required = worker_required or bool(readiness["requires_worker"]) + if check_worker and worker_required: + require_worker_available(self.worker_client) + return readiness_map + + def _record_auto_target_resolution( + self, + job: dict[str, Any], + readiness: dict[str, Any], + *, + connection, + ) -> None: + original_mode = str(job.get("publish_mode") or "") + original_account = str(job.get("account_id") or "") + resolved_mode = str(readiness.get("resolved_publish_mode") or original_mode) + resolved_account = str(readiness.get("resolved_account_id") or original_account) + if original_mode == resolved_mode and original_account == resolved_account: + return + self.repository.add_event( + str(job.get("id") or ""), + "send_target_auto_resolved", + from_status=str(job.get("status") or ""), + to_status=str(job.get("status") or ""), + message="已自动选择唯一同平台账号并改用 Windows Chrome", + payload={ + "from_publish_mode": original_mode, + "to_publish_mode": resolved_mode, + "auto_selected_account": bool(readiness.get("auto_selected_account")), + }, + connection=connection, + ) + + def _public_job(self, job_id: str) -> dict[str, Any] | None: + from app.services import publish_service + + return publish_service.get_publish_job(job_id) or self.repository.get_job(job_id) + + def _claim_scheduled_job(self, job_id: str) -> bool: + now = utc_now_iso() + execution_id = uuid4().hex + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( """ UPDATE publish_jobs - SET status = 'PUBLISHING', + SET status = 'PUBLISHING', claimed_at = ?, started_at = ?, finished_at = NULL, + worker_id = ?, execution_id = ?, execution_phase = 'claimed', attempt_count = COALESCE(attempt_count, 0) + 1, retry_count = COALESCE(retry_count, 0) + 1, - last_error = '', - error_message = '', - publish_result = ?, - provider_response = ?, - updated_at = ? - WHERE id = ? + next_attempt_at = NULL, last_error = '', error_message = '', + needs_manual_review = 0, updated_at = ? + WHERE id = ? AND status = 'SCHEDULED' """, - (payload, payload, now, job_id), + (now, now, self.worker_id, execution_id, now, job_id), ) + if cursor.rowcount: + self.repository.add_event( + job_id, "claimed", from_status="SCHEDULED", to_status="PUBLISHING", + worker_id=self.worker_id, payload={"execution_id": execution_id}, connection=connection, + ) connection.commit() + return int(cursor.rowcount or 0) == 1 - def _mark_published(self, job_id: str, payload: dict[str, Any], remote_video_id: str) -> dict[str, Any]: - now = now_iso() - publish_result = json.dumps(payload, ensure_ascii=False) + def _handle_worker_unavailable(self, job: dict[str, Any], exc: PublishWorkerUnavailable) -> dict[str, Any]: + execution_id = str(job.get("execution_id") or "") + if exc.request_may_have_been_received and execution_id: + try: + execution = self.worker_client.execution(execution_id) + phase = str(execution.get("phase") or "unknown") + details = execution.get("details") if isinstance(execution.get("details"), dict) else {} + if phase == "confirmed_success" and details: + return self._mark_published(str(job["id"]), PublishResult.from_dict(details)) + if phase not in {"received", "browser_opening", "browser_opened", "rejected"}: + return self._mark_need_review( + str(job["id"]), "publish_worker_result_uncertain", + "Worker 连接中断且任务可能已经上传,请人工确认平台结果", + ) + except PublishError: + return self._mark_need_review( + str(job["id"]), "publish_worker_result_uncertain", + "Worker 超时后无法读取执行阶段,为避免重复投稿已停止自动重试", + ) + attempts = int((self.repository.get_job(str(job["id"])) or job).get("attempt_count") or 0) + max_attempts = max(1, int(job.get("max_attempts") or self.max_retry_count)) + if attempts >= max_attempts: + return self._mark_failed(str(job["id"]), exc.error_code, f"{exc.message};3 次上传前安全重试已用完") + delays = (30, 120, 300) + delay = delays[min(max(0, attempts - 1), len(delays) - 1)] + next_attempt = (utc_now() + timedelta(seconds=delay)).isoformat(timespec="seconds") + now = utc_now_iso() with get_connection() as connection: connection.execute( """ - UPDATE publish_jobs - SET status = 'PUBLISHED', - publish_result = ?, - provider_response = ?, - remote_video_id = ?, - platform_item_id = ?, - published_at = ?, - last_error = '', - error_message = '', - error_code = '', - audit_status = 'submitted', - updated_at = ? - WHERE id = ? + UPDATE publish_jobs SET status = 'SCHEDULED', next_attempt_at = ?, + error_code = ?, error_message = ?, last_error = ?, + execution_phase = 'worker_unavailable_before_upload', updated_at = ? + WHERE id = ? AND status = 'PUBLISHING' """, - (publish_result, publish_result, remote_video_id, remote_video_id, now, now, job_id), + (next_attempt, exc.error_code, exc.message, exc.message, now, job["id"]), + ) + self.repository.add_event( + str(job["id"]), "safe_retry_scheduled", from_status="PUBLISHING", to_status="SCHEDULED", + worker_id=self.worker_id, error_code=exc.error_code, message=exc.message, + payload={"next_attempt_at": next_attempt, "attempt_count": attempts}, connection=connection, ) connection.commit() - job = get_publish_job_raw(job_id) or {"task_id": ""} - self._append_log(job.get("task_id") or "", f"Publish job {job_id} completed by manual_export") - return {"status": "published", "job_id": job_id, "publish_result": payload} - - def _mark_failed(self, job_id: str, error_code: str, message: str) -> dict[str, Any]: - now = now_iso() - payload = json.dumps( - {"error_code": error_code, "message": message, "failed_at": now}, - ensure_ascii=False, - ) + return {"status": "rescheduled", "job_id": job["id"], "next_attempt_at": next_attempt, "message": exc.message} + + def _reschedule_before_upload(self, job_id: str, message: str) -> None: + now = utc_now_iso() with get_connection() as connection: connection.execute( """ - UPDATE publish_jobs - SET status = 'FAILED', - error_code = ?, - error_message = ?, - last_error = ?, - publish_result = ?, - provider_response = ?, - updated_at = ? - WHERE id = ? + UPDATE publish_jobs SET status = 'SCHEDULED', next_attempt_at = ?, + execution_phase = 'recovered_before_upload', last_error = ?, error_message = ?, updated_at = ? + WHERE id = ? AND status = 'PUBLISHING' """, - (error_code, message, message, payload, payload, now, job_id), + (now, message, message, now, job_id), + ) + self.repository.add_event( + job_id, "recovered_before_upload", from_status="PUBLISHING", to_status="SCHEDULED", + message=message, connection=connection, ) connection.commit() - job = get_publish_job_raw(job_id) or {"task_id": ""} - self._append_log(job.get("task_id") or "", f"Publish job {job_id} failed: {message}") - return {"status": "failed", "job_id": job_id, "error_code": error_code, "message": message} - def _mark_need_review(self, job_id: str, risk_flags: list[Any]) -> None: - now = now_iso() - message = f"risk flags require manual review: {risk_flags}" + def _mark_published(self, job_id: str, result: PublishResult, *, require_publishing: bool = True) -> dict[str, Any]: + now = utc_now_iso() + condition = "AND status = 'PUBLISHING'" if require_publishing else "AND status = 'NEED_REVIEW'" with get_connection() as connection: - connection.execute( + self.repository.record_provider_result( + job_id, result, connection=connection, updated_at=now + ) + cursor = connection.execute( + f""" + UPDATE publish_jobs SET status = 'PUBLISHED', published_at = ?, finished_at = ?, + platform_url = ?, remote_video_id = ?, platform_item_id = ?, + audit_status = 'submitted', needs_manual_review = 0, + error_code = '', error_message = '', last_error = '', + execution_phase = 'confirmed_success', updated_at = ? + WHERE id = ? {condition} + """, + (result.published_at or now, now, result.platform_url, result.remote_video_id, + result.remote_video_id, now, job_id), + ) + if cursor.rowcount: + self.repository.add_event( + job_id, "published", from_status="PUBLISHING" if require_publishing else "NEED_REVIEW", + to_status="PUBLISHED", worker_id=self.worker_id, payload=result.as_dict(), connection=connection, + ) + connection.commit() + else: + connection.rollback() + if not cursor.rowcount: + return {"status": "skipped", "job_id": job_id, "message": "任务状态已变化,未覆盖最新状态"} + self._append_log(job_id, "平台已确认投稿成功") + return {"status": "published", "job_id": job_id, "publish_result": result.as_dict()} + + def _mark_exported(self, job_id: str, result: PublishResult) -> dict[str, Any]: + now = utc_now_iso() + with get_connection() as connection: + self.repository.record_provider_result( + job_id, result, connection=connection, updated_at=now + ) + cursor = connection.execute( """ - UPDATE publish_jobs - SET status = 'NEED_REVIEW', last_error = ?, error_message = ?, - risk_flags = ?, updated_at = ? - WHERE id = ? + UPDATE publish_jobs SET status = 'EXPORTED', finished_at = ?, published_at = NULL, + audit_status = 'not_submitted', execution_phase = 'exported', updated_at = ? + WHERE id = ? AND status = 'PUBLISHING' """, - (message, message, json.dumps(risk_flags, ensure_ascii=False), now, job_id), + (now, now, job_id), ) - connection.commit() + if cursor.rowcount: + self.repository.add_event( + job_id, "exported", from_status="PUBLISHING", to_status="EXPORTED", + payload=result.as_dict(), connection=connection, + ) + connection.commit() + else: + connection.rollback() + if not cursor.rowcount: + return {"status": "skipped", "job_id": job_id, "message": "任务状态已变化,未覆盖最新状态"} + return {"status": "exported", "job_id": job_id, "publish_result": result.as_dict()} + + def _mark_failed( + self, job_id: str, error_code: str, message: str, result: PublishResult | None = None + ) -> dict[str, Any]: + now = utc_now_iso() + with get_connection() as connection: + row = connection.execute("SELECT status FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + from_status = str(row["status"] if row else "") + if result: + self.repository.record_provider_result( + job_id, result, connection=connection, updated_at=now + ) + cursor = connection.execute( + """ + UPDATE publish_jobs SET status = 'FAILED', finished_at = ?, error_code = ?, + error_message = ?, last_error = ?, needs_manual_review = 0, + execution_phase = 'failed', updated_at = ? + WHERE id = ? AND status NOT IN ('PUBLISHED', 'EXPORTED', 'CANCELLED') + """, + (now, error_code, message, message, now, job_id), + ) + if cursor.rowcount: + self.repository.add_event( + job_id, "failed", from_status=from_status, to_status="FAILED", + worker_id=self.worker_id, error_code=error_code, message=message, connection=connection, + ) + connection.commit() + else: + connection.rollback() + if not cursor.rowcount: + return {"status": "skipped", "job_id": job_id, "message": "任务状态已变化,未覆盖最新状态"} + self._append_log(job_id, f"发布失败:{message}") + return {"status": "failed", "job_id": job_id, "error_code": error_code, "message": message} - def _update_status( - self, - job_id: str, - status: str, - message: str = "", - result_payload: dict[str, Any] | None = None, + def _mark_need_review( + self, job_id: str, error_code: str, message: str, result: PublishResult | None = None ) -> dict[str, Any]: - job = get_publish_job_raw(job_id) + now = utc_now_iso() + with get_connection() as connection: + row = connection.execute("SELECT status FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + from_status = str(row["status"] if row else "") + if result: + self.repository.record_provider_result( + job_id, result, connection=connection, updated_at=now + ) + cursor = connection.execute( + """ + UPDATE publish_jobs SET status = 'NEED_REVIEW', finished_at = ?, error_code = ?, + error_message = ?, last_error = ?, needs_manual_review = 1, + execution_phase = 'manual_review', updated_at = ? + WHERE id = ? AND status NOT IN ('PUBLISHED', 'EXPORTED', 'CANCELLED') + """, + (now, error_code, message, message, now, job_id), + ) + if cursor.rowcount: + self.repository.add_event( + job_id, "needs_review", from_status=from_status, to_status="NEED_REVIEW", + worker_id=self.worker_id, error_code=error_code, message=message, connection=connection, + ) + connection.commit() + else: + connection.rollback() + if not cursor.rowcount: + return {"status": "skipped", "job_id": job_id, "message": "任务状态已变化,未覆盖最新状态"} + self._append_log(job_id, f"发布需要人工复核:{message}") + return {"status": "need_review", "job_id": job_id, "error_code": error_code, "message": message} + + def _transition_user_status(self, job_id: str, target: str, message: str) -> dict[str, Any]: + job = self.repository.get_job(job_id) if not job: - raise ValueError("publish job not found") - if str(job.get("status") or "").upper() == "PUBLISHED" and status != "PUBLISHED": - raise ValueError("published jobs cannot be changed by this operation") - now = now_iso() - payload = json.dumps(result_payload or {"message": message, "updated_at": now}, ensure_ascii=False) + raise ValueError("发布任务不存在") + source = str(job.get("status") or "").upper() + if source not in {"DRAFT", "WAITING", "SCHEDULED", "FAILED", "NEED_REVIEW"}: + raise ValueError("当前状态不能取消") + now = utc_now_iso() with get_connection() as connection: - connection.execute( + cursor = connection.execute( """ - UPDATE publish_jobs - SET status = ?, last_error = ?, error_message = ?, - publish_result = ?, provider_response = ?, updated_at = ? - WHERE id = ? + UPDATE publish_jobs SET status = ?, finished_at = ?, last_error = ?, + error_message = ?, updated_at = ? WHERE id = ? AND status = ? """, - (status, message, message, payload, payload, now, job_id), + (target, now, message, message, now, job_id, source), ) + if cursor.rowcount: + self.repository.add_event( + job_id, "cancelled", from_status=source, to_status=target, + message=message, connection=connection, + ) connection.commit() - return {"status": "ok", "job": get_publish_job_raw(job_id)} + if not cursor.rowcount: + raise ValueError("任务状态已变化,请刷新后重试") + return {"status": "ok", "job": self.repository.get_job(job_id)} + + @staticmethod + def _risk_flags(job: dict[str, Any]) -> list[Any]: + for value in (job.get("risk_flags"), job.get("provider_response")): + if not value: + continue + try: + parsed = json.loads(value) if isinstance(value, str) else value + except json.JSONDecodeError: + continue + if isinstance(parsed, list): + return [item for item in parsed if item] + if isinstance(parsed, dict) and isinstance(parsed.get("risk_flags"), list): + return [item for item in parsed["risk_flags"] if item] + return [] - def _append_log(self, task_id: str, message: str) -> None: + def _append_log(self, job_id: str, message: str) -> None: + job = self.repository.get_job(job_id) or {} + task_id = str(job.get("task_id") or "") if not task_id: return try: - append_task_log(task_id, message) + append_task_log(task_id, f"Publish job {job_id}: {message}") except Exception: - return + pass def queue_snapshot(task_id: str | None = None) -> dict[str, Any]: @@ -524,34 +1331,68 @@ def queue_snapshot(task_id: str | None = None) -> dict[str, Any]: params.append(task_id) with get_connection() as connection: rows = connection.execute( - f""" - SELECT * - FROM publish_jobs - {where} - ORDER BY scheduled_at ASC, created_at DESC - """, + f"SELECT * FROM publish_jobs {where} ORDER BY COALESCE(scheduled_at, created_at), created_at DESC", params, ).fetchall() jobs = [dict(row) for row in rows] - by_status = {status: [job for job in jobs if str(job.get("status") or "").upper() == status] for status in PUBLISH_STATUSES} - today = datetime.now().astimezone().date() + statuses = ("DRAFT", "WAITING", "SCHEDULED", "PUBLISHING", "PUBLISHED", "EXPORTED", "FAILED", "NEED_REVIEW", "CANCELLED") + by_status = {status: [job for job in jobs if str(job.get("status") or "").upper() == status] for status in statuses} + today = utc_now().astimezone(app_zone()).date() today_jobs = [] for job in jobs: try: - if parse_datetime(job.get("scheduled_at")).date() == today: + if parse_datetime(job.get("scheduled_at")).astimezone(app_zone()).date() == today: today_jobs.append(job) except ValueError: - continue + pass return { "all": jobs, - "pending": by_status["SCHEDULED"] + by_status["WAITING"], + "pending": by_status["DRAFT"] + by_status["WAITING"], + "scheduled": by_status["SCHEDULED"], "publishing": by_status["PUBLISHING"], "published": by_status["PUBLISHED"], + "exported": by_status["EXPORTED"], "failed": by_status["FAILED"], "need_review": by_status["NEED_REVIEW"], "cancelled": by_status["CANCELLED"], "today": today_jobs, "counts": {status: len(items) for status, items in by_status.items()}, + "timezone": settings.app_timezone, + } + + +def scheduler_health() -> dict[str, Any]: + with get_connection() as connection: + counts = connection.execute( + """ + SELECT SUM(CASE WHEN status = 'SCHEDULED' THEN 1 ELSE 0 END) scheduled_count, + SUM(CASE WHEN status = 'PUBLISHING' THEN 1 ELSE 0 END) publishing_count + FROM publish_jobs + """ + ).fetchone() + try: + worker = PublishWorkerClient(timeout=2).health() + worker_available = worker.get("status") == "ok" + worker_message = "Windows 发布 Worker 正常" + except PublishError as exc: + worker_available = False + worker_message = exc.message + return { + "enabled": bool(settings.publish_scheduler_enabled), + "running": bool(_SCHEDULER_HEALTH["running"]), + "scanning": bool(_SCHEDULER_HEALTH["scanning"]), + "last_scan_at": _SCHEDULER_HEALTH["last_scan_at"], + "next_scan_at": _SCHEDULER_HEALTH["next_scan_at"], + "last_error_code": _SCHEDULER_HEALTH["last_error_code"], + "last_error_message": _SCHEDULER_HEALTH["last_error_message"], + "last_error_at": _SCHEDULER_HEALTH["last_error_at"], + "consecutive_failures": int(_SCHEDULER_HEALTH["consecutive_failures"]), + "interval_seconds": int(settings.publish_scheduler_interval_seconds), + "scheduled_count": int(counts["scheduled_count"] or 0), + "publishing_count": int(counts["publishing_count"] or 0), + "worker_available": worker_available, + "worker_message": worker_message, + "timezone": settings.app_timezone, } @@ -567,7 +1408,6 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="NiuMa Studio publish scheduler") parser.add_argument("command", choices=["run", "run-once", "snapshot"]) args = parser.parse_args(argv) - scheduler = PublishScheduler() if args.command == "run-once": print(json.dumps(scheduler.run_once(), ensure_ascii=False, indent=2)) @@ -576,11 +1416,7 @@ def main(argv: list[str] | None = None) -> int: init_db() print(json.dumps(queue_snapshot(), ensure_ascii=False, indent=2)) return 0 - - async def _runner() -> None: - await scheduler.run_forever() - - asyncio.run(_runner()) + asyncio.run(scheduler.run_forever()) return 0 diff --git a/app/services/publish_service.py b/app/services/publish_service.py index e921901..07af119 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -1,30 +1,33 @@ import json +import math import os import re import shutil import subprocess import urllib.error import urllib.request -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from threading import Lock from typing import Any, Callable from urllib.parse import urlsplit from uuid import uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from app.core.config import settings from app.db.database import get_connection from app.models.task import ( PublishAccountCreate, PublishBatchJobCreate, + PublishBatchTargetUpdate, PublishCoverCreate, PublishCoverFrameBatchCreate, PublishJobContentUpdate, PublishJobCreate, PublishJobScheduleUpdate, + PublishJobTargetUpdate, PublishPlatformConfigUpdate, PublishSendJobUpdate, - PublishSendStart, ) from app.services.ai.ai_clip_analyzer import build_remote_provider from app.services.ai.base import AIProviderError @@ -33,14 +36,14 @@ DouyinPublishProvider, PublishProviderError, ) +from app.services.publish_domain import PUBLISH_MODES, TARGET_PLATFORMS +from app.services.publish_readiness import PublishPlatformIsolationBlocked +from app.services.publish_time import app_zone, local_display, parse_datetime from app.services.storage_service import get_artifact_paths, resolve_video_file_path from app.services.video_cut_service import ensure_ffmpeg_available, sanitize_filename_part, summarize_stderr -PLATFORM_LABELS = { - "douyin": "抖音", - "bilibili": "B站", -} +PLATFORM_LABELS = TARGET_PLATFORMS STATUS_LABELS = { "draft": "草稿", @@ -62,6 +65,30 @@ "cancelled": "amber", } +EXECUTION_PHASE_LABELS = { + "claimed": "正在领取任务", + "received": "Worker 已接收", + "browser_opening": "正在打开抖音", + "browser_opened": "抖音页面已打开", + "upload_started": "已选择视频", + "upload_waiting": "正在上传并解析视频", + "upload_completed": "视频上传完成", + "title_filled": "标题已填写", + "description_filled": "正文和话题已填写", + "form_verified_before_cover": "内容校验通过", + "recommended_cover_ready": "推荐封面已生成", + "recommended_cover_clicked": "正在设置推荐封面", + "recommended_cover_confirmed": "推荐封面已确认", + "recommended_cover_verified": "推荐封面已生效", + "form_verified_before_submit": "发布内容最终校验通过", + "visibility_verified": "可见范围已验证", + "precise_publish_clicked": "正在点击发布", + "submit_clicked": "已提交,等待平台结果", + "publish_result_checked": "正在确认发布结果", + "manual_review_waiting": "已暂停,等待人工处理", + "confirmed_success": "平台已确认发布成功", +} + VIDEO_SOURCE_LABELS = { "original": "原始切片", "subtitled": "带字幕成片", @@ -79,9 +106,32 @@ PUBLISH_STATUS_WAITING = "WAITING" PUBLISH_STATUS_PUBLISHING = "PUBLISHING" PUBLISH_STATUS_PUBLISHED = "PUBLISHED" +PUBLISH_STATUS_EXPORTED = "EXPORTED" PUBLISH_STATUS_FAILED = "FAILED" PUBLISH_STATUS_CANCELLED = "CANCELLED" PUBLISH_STATUS_NEED_REVIEW = "NEED_REVIEW" +USER_REMOVED_ERROR_CODE = "user_removed_from_preparation" +SUPERSEDED_BY_RECUT_ERROR_CODE = "superseded_by_recut" +ACTIVE_PREPARATION_STATUSES = { + PUBLISH_STATUS_DRAFT, + PUBLISH_STATUS_WAITING, + PUBLISH_STATUS_SCHEDULED, +} +PUBLISH_HISTORY_STATUSES = { + PUBLISH_STATUS_SCHEDULED, + PUBLISH_STATUS_PUBLISHING, + PUBLISH_STATUS_PUBLISHED, + PUBLISH_STATUS_EXPORTED, + PUBLISH_STATUS_FAILED, + PUBLISH_STATUS_NEED_REVIEW, + PUBLISH_STATUS_CANCELLED, +} +PUBLISH_HISTORY_HIDEABLE_STATUSES = { + PUBLISH_STATUS_PUBLISHED, + PUBLISH_STATUS_EXPORTED, + PUBLISH_STATUS_FAILED, + PUBLISH_STATUS_CANCELLED, +} LEGACY_STATUS_MAP = { "draft": PUBLISH_STATUS_DRAFT, @@ -94,19 +144,7 @@ "need_review": PUBLISH_STATUS_NEED_REVIEW, } -PLATFORM_LABELS.update( - { - "manual_export": "发布包导出", - "local_browser": "本地浏览器", - } -) - -PUBLISH_MODE_LABELS.update( - { - "manual_export": "手动发布包导出", - "local_browser": "本地浏览器发布", - } -) +PUBLISH_MODE_LABELS.update(PUBLISH_MODES) STATUS_LABELS = { PUBLISH_STATUS_DRAFT: "草稿", @@ -114,6 +152,7 @@ PUBLISH_STATUS_WAITING: "等待处理", PUBLISH_STATUS_PUBLISHING: "发布中", PUBLISH_STATUS_PUBLISHED: "已发布", + PUBLISH_STATUS_EXPORTED: "已导出发布包", PUBLISH_STATUS_FAILED: "发送失败", PUBLISH_STATUS_CANCELLED: "已取消", PUBLISH_STATUS_NEED_REVIEW: "需人工复核", @@ -131,6 +170,7 @@ PUBLISH_STATUS_WAITING: "amber", PUBLISH_STATUS_PUBLISHING: "purple", PUBLISH_STATUS_PUBLISHED: "green", + PUBLISH_STATUS_EXPORTED: "blue", PUBLISH_STATUS_FAILED: "red", PUBLISH_STATUS_CANCELLED: "amber", PUBLISH_STATUS_NEED_REVIEW: "amber", @@ -335,7 +375,10 @@ def _opencli_bridge_command_runner(command: list[str]) -> subprocess.CompletedPr {"command": command, "timeout": OPENCLI_TIMEOUT_SECONDS}, ensure_ascii=False, ).encode("utf-8"), - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {settings.publish_worker_token}", + }, method="POST", ) try: @@ -478,12 +521,22 @@ def _normalize_config(row) -> dict: def _normalize_account(row) -> dict: account = dict(row) + login_status = account.get("login_status") or "login_required" account.update( { "platform_label": PLATFORM_LABELS.get(account.get("platform"), account.get("platform")), "access_token_masked": _mask_secret(account.get("access_token")), "refresh_token_masked": _mask_secret(account.get("refresh_token")), "is_authorized": account.get("authorization_status") == "authorized", + "auth_type": account.get("auth_type") or "browser_profile", + "login_status": login_status, + "login_status_label": { + "normal": "正常", + "invalid": "登录失效", + "login_pending": "等待登录完成", + "busy": "账号操作中", + }.get(login_status, "需要重新登录"), + "login_message": account.get("login_message") or "", } ) return account @@ -498,17 +551,54 @@ def _normalize_publish_status(status: str | None) -> str: return LEGACY_STATUS_MAP.get(raw.lower(), raw) -def _format_publish_schedule(value: str | None) -> str: +def _format_publish_schedule(value: str | None, timezone_name: str = "Asia/Shanghai") -> str: text = (value or "").strip() if not text: return "未排期" try: - return datetime.fromisoformat(text.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M") + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + try: + display_zone = ZoneInfo(timezone_name or "Asia/Shanghai") + except ZoneInfoNotFoundError: + display_zone = ZoneInfo("Asia/Shanghai") + return parsed.astimezone(display_zone).strftime("%Y-%m-%d %H:%M") except ValueError: return text -def _normalize_job(row) -> dict: +def _format_task_created_at(value: str | None) -> str: + text = (value or "").strip() + if not text: + return "未知时间" + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.astimezone(ZoneInfo(settings.app_timezone)) + return parsed.strftime("%Y-%m-%d %H:%M") + except (ValueError, ZoneInfoNotFoundError): + return text + + +def _task_source_file_name(job: dict) -> str: + source_path = ( + job.get("task_nas_file_path") + if job.get("task_source_type") == "nas" + else job.get("task_original_video_path") + ) + source_text = str(source_path or "").strip() + if not source_text: + return "未记录原视频文件名" + return Path(source_text).name or "未记录原视频文件名" + + +def _normalize_job( + row, + *, + accounts: list[dict] | None = None, + worker_state: dict | None = None, +) -> dict: job = dict(row) status = _normalize_publish_status(job.get("status")) provider_payload = _parse_json_text(job.get("provider_response")) @@ -518,6 +608,35 @@ def _normalize_job(row) -> dict: clip_id = job.get("clip_id") or job.get("output_clip_id") or "" video_path = job.get("video_path") or job.get("video_file_path") or "" error_message = job.get("error_message") or job.get("last_error") or "" + schedule_timezone = job.get("schedule_timezone") or "Asia/Shanghai" + scheduled_at_utc = job.get("scheduled_at") or "" + execution_phase = str(job.get("execution_phase") or "") + execution_phase_label = EXECUTION_PHASE_LABELS.get(execution_phase, execution_phase) + scheduled_at_local = "" + if scheduled_at_utc: + try: + parse_datetime_value = datetime.fromisoformat(scheduled_at_utc.replace("Z", "+00:00")) + if parse_datetime_value.tzinfo is None: + parse_datetime_value = parse_datetime_value.replace(tzinfo=timezone.utc) + scheduled_at_local = parse_datetime_value.astimezone(ZoneInfo(schedule_timezone)).isoformat(timespec="seconds") + except (ValueError, ZoneInfoNotFoundError): + scheduled_at_local = scheduled_at_utc + missing_fields: list[str] = [] + if not str(job.get("title") or "").strip(): + missing_fields.append("标题") + if not str(caption).strip(): + missing_fields.append("正文/简介") + if not str(hashtags).strip(): + missing_fields.append("话题/标签") + if not str(job.get("cover_file_path") or "").strip(): + missing_fields.append("封面") + if str(job.get("publish_mode") or "") == "local_browser" and not str(job.get("account_id") or "").strip(): + missing_fields.append("发布账号") + if str(job.get("platform") or "") == "bilibili": + if not str(job.get("bilibili_tid") or "").strip(): + missing_fields.append("B站分区") + if job.get("bilibili_copyright") == "repost" and not str(job.get("bilibili_source") or "").strip(): + missing_fields.append("转载来源") job.update( { "status": status, @@ -533,11 +652,19 @@ def _normalize_job(row) -> dict: "last_error": job.get("last_error") or error_message, "attempt_count": int(job.get("attempt_count") or job.get("retry_count") or 0), "platform_label": PLATFORM_LABELS.get(job.get("platform"), job.get("platform")), - "status_label": STATUS_LABELS.get(status, status), + "status_label": ( + execution_phase_label + if status == PUBLISH_STATUS_PUBLISHING and execution_phase_label + else STATUS_LABELS.get(status, status) + ), + "execution_phase_label": execution_phase_label, "status_tone": STATUS_TONES.get(status, "blue"), "video_source_label": VIDEO_SOURCE_LABELS.get(job.get("video_source"), job.get("video_source")), "publish_mode_label": PUBLISH_MODE_LABELS.get(job.get("publish_mode"), job.get("publish_mode")), - "scheduled_at_display": _format_publish_schedule(job.get("scheduled_at")), + "schedule_timezone": schedule_timezone, + "scheduled_at_utc": scheduled_at_utc, + "scheduled_at_local": scheduled_at_local, + "scheduled_at_display": _format_publish_schedule(scheduled_at_utc, schedule_timezone), "account_name": job.get("account_name") or "未选择账号", "cover_media_url": _cover_media_url(job.get("task_id") or "", job.get("cover_file_path")), "video_media_url": _video_media_url( @@ -547,13 +674,83 @@ def _normalize_job(row) -> dict: ), "provider_payload": provider_payload, "publish_result_payload": publish_result_payload, - "platform_url": provider_payload.get("url") or provider_payload.get("platform_url") or "", + "platform_url": job.get("platform_url") or provider_payload.get("url") or provider_payload.get("platform_url") or "", "trace_path": provider_payload.get("trace_path") or "", + "content_complete": not missing_fields, + "missing_fields": missing_fields, + "account_login_status": job.get("account_login_status") or "login_required", + "account_login_message": job.get("account_login_message") or "", + "task_source_file_name": _task_source_file_name(job), + "task_created_at_display": _format_task_created_at(job.get("task_created_at")), + "is_user_removed": ( + status == PUBLISH_STATUS_CANCELLED + and str(job.get("error_code") or "") == USER_REMOVED_ERROR_CODE + ), + "output_is_active": bool(job.get("output_is_active", 1)), + "is_superseded_by_recut": ( + status == PUBLISH_STATUS_CANCELLED + and str(job.get("error_code") or "") == SUPERSEDED_BY_RECUT_ERROR_CODE + ), + "history_hidden": bool(job.get("history_hidden")), + "history_hidden_at": str(job.get("history_hidden_at") or ""), + "started_at_display": local_display(job.get("started_at"), settings.app_timezone) if job.get("started_at") else "—", + "finished_at_display": local_display(job.get("finished_at"), settings.app_timezone) if job.get("finished_at") else "—", + "history_hidden_at_display": ( + local_display(job.get("history_hidden_at"), settings.app_timezone) + if job.get("history_hidden_at") + else "" + ), } ) + from app.services.publish_readiness import build_send_readiness + + job["send_readiness"] = build_send_readiness( + job, + accounts=accounts, + worker_available=(worker_state or {}).get("worker_available"), + worker_message=str((worker_state or {}).get("worker_message") or ""), + ) return job +def _build_publish_task_groups(jobs: list[dict]) -> list[dict]: + groups: dict[str, dict] = {} + for job in jobs: + if not job.get("output_is_active", True): + continue + task_id = str(job.get("task_id") or "unknown-task") + group = groups.setdefault( + task_id, + { + "task_id": task_id, + "task_name": job.get("task_name") or "未命名任务", + "task_source_file_name": job.get("task_source_file_name") or "未记录原视频文件名", + "task_created_at": job.get("task_created_at") or job.get("created_at") or "", + "task_created_at_display": job.get("task_created_at_display") or "未知时间", + "jobs": [], + }, + ) + group["jobs"].append(job) + + def job_sort_key(job: dict) -> tuple[str, str, int, str, str]: + return ( + str(job.get("output_clip_created_at") or job.get("created_at") or ""), + str(job.get("output_file_name") or ""), + 0 if job.get("platform") == "douyin" else 1, + str(job.get("created_at") or ""), + str(job.get("id") or ""), + ) + + for group in groups.values(): + group["jobs"].sort(key=job_sort_key) + + return sorted( + groups.values(), + key=lambda group: (str(group.get("task_created_at") or ""), str(group.get("task_id") or "")), + reverse=True, + ) + + def list_platform_configs() -> list[dict]: with get_connection() as connection: rows = connection.execute( @@ -667,9 +864,9 @@ def create_account(payload: PublishAccountCreate) -> dict: INSERT INTO publish_accounts ( id, platform, account_name, account_uid, open_id, access_token, refresh_token, token_expires_at, refresh_expires_at, authorization_status, - scopes, remark, created_at, updated_at + auth_type, login_status, login_message, scopes, remark, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( account_id, @@ -682,6 +879,9 @@ def create_account(payload: PublishAccountCreate) -> dict: (payload.token_expires_at or "").strip(), (payload.refresh_expires_at or "").strip(), auth_status, + "oauth" if auth_status == "authorized" else "browser_profile", + "normal" if auth_status == "authorized" else "login_required", + "已保存平台 OAuth 授权" if auth_status == "authorized" else "请打开独立 Chrome 完成登录", (payload.scopes or "").strip(), (payload.remark or "").strip(), now, @@ -692,6 +892,55 @@ def create_account(payload: PublishAccountCreate) -> dict: return {"status": "ok", "message": "发布账号已保存。", "account": get_account(account_id)} +def check_browser_account(account_id: str) -> dict: + account = get_account(account_id) + if not account: + raise ValueError("发布账号不存在") + from app.services.publish_repository import PublishRepository + from app.services.publishers.worker_client import PublishWorkerClient + + result = PublishWorkerClient().check_account(account["platform"], account_id) + worker_status = str(result.get("login_status") or "").lower() + normal = worker_status == "normal" + previous_login = str(account.get("login_status") or "") == "normal" or bool(account.get("last_login_at")) + if normal: + stored_status = "normal" + elif worker_status in {"busy", "login_pending"}: + stored_status = worker_status + else: + stored_status = "invalid" if previous_login else "login_required" + PublishRepository().update_account_status( + account_id, + stored_status, + str(result.get("message") or ""), + logged_in=normal, + ) + return {"status": "ok", "account": get_account(account_id), "worker_result": result} + + +def start_browser_account_login(account_id: str) -> dict: + account = get_account(account_id) + if not account: + raise ValueError("发布账号不存在") + from app.services.publish_repository import PublishRepository + from app.services.publishers.worker_client import PublishWorkerClient + + result = PublishWorkerClient().start_login(account["platform"], account_id) + message = str(result.get("message") or "已打开登录窗口") + PublishRepository().update_account_status(account_id, "login_pending", message) + return {"status": "started", "message": message, "account": get_account(account_id)} + + +def open_browser_creator_center(account_id: str) -> dict: + account = get_account(account_id) + if not account: + raise ValueError("发布账号不存在") + from app.services.publishers.worker_client import PublishWorkerClient + + result = PublishWorkerClient().open_creator_center(account["platform"], account_id) + return {"status": "started", "message": result.get("message") or "已打开创作者中心", "account": account} + + def build_douyin_oauth_url() -> dict: config = get_platform_config("douyin") if not config: @@ -777,6 +1026,7 @@ def _get_output_clip_for_publish(task_id: str, output_clip_id: str) -> dict | No output_clip.output_file_name, output_clip.status AS output_status, clip_candidates.title AS clip_title, + clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, subtitle_jobs.output_file_path AS subtitled_output_file_path FROM output_clip @@ -802,6 +1052,7 @@ def _get_output_clip_by_id(output_clip_id: str) -> dict | None: output_clip.output_file_name, output_clip.status AS output_status, clip_candidates.title AS clip_title, + clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, subtitle_jobs.output_file_path AS subtitled_output_file_path FROM output_clip @@ -815,12 +1066,14 @@ def _get_output_clip_by_id(output_clip_id: str) -> dict | None: return _row_to_dict(row) -def _list_completed_publish_clips() -> list[dict]: +def _list_completed_publish_clips(task_id: str | None = None) -> list[dict]: + where_task = " AND tasks.id = ?" if task_id else "" with get_connection() as connection: rows = connection.execute( - """ + f""" SELECT tasks.id AS task_id, + tasks.platform AS task_platform, tasks.task_name, tasks.task_dir_name, output_clip.id AS output_clip_id, @@ -837,6 +1090,7 @@ def _list_completed_publish_clips() -> list[dict]: clip_candidates.start_time, clip_candidates.end_time, clip_candidates.duration_seconds, + clip_candidates.cover_time_seconds AS ai_cover_time_seconds, subtitle_jobs.status AS subtitle_status, subtitle_jobs.output_file_path AS subtitled_output_file_path FROM output_clip @@ -844,8 +1098,10 @@ def _list_completed_publish_clips() -> list[dict]: LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 WHERE tasks.is_deleted = 0 AND output_clip.status = 'completed' AND output_clip.is_active = 1 + {where_task} ORDER BY output_clip.created_at DESC - """ + """, + (task_id,) if task_id else (), ).fetchall() return [dict(row) for row in rows] @@ -998,6 +1254,7 @@ def _find_opencli_job(output_clip_id: str, platform: str) -> dict | None: """ SELECT * FROM publish_jobs WHERE output_clip_id = ? AND platform = ? AND publish_mode = 'opencli_publish' + AND status NOT IN ('PUBLISHED', 'EXPORTED', 'CANCELLED') ORDER BY created_at DESC LIMIT 1 """, @@ -1006,6 +1263,102 @@ def _find_opencli_job(output_clip_id: str, platform: str) -> dict | None: return _normalize_job(row) if row else None +def _find_active_publish_job(output_clip_id: str, platform: str) -> dict | None: + """查找任意执行方式的有效任务,避免刷新队列改变用户已选择的执行方式。""" + with get_connection() as connection: + row = connection.execute( + """ + SELECT * FROM publish_jobs + WHERE output_clip_id = ? AND platform = ? + AND status IN ('DRAFT', 'WAITING', 'SCHEDULED', 'PUBLISHING', 'NEED_REVIEW') + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC + LIMIT 1 + """, + (output_clip_id, platform), + ).fetchone() + return _normalize_job(row) if row else None + + +def _find_latest_publish_job(output_clip_id: str, platform: str) -> dict | None: + with get_connection() as connection: + row = connection.execute( + """ + SELECT * FROM publish_jobs + WHERE output_clip_id = ? AND platform = ? + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC, created_at DESC, id DESC + LIMIT 1 + """, + (output_clip_id, platform), + ).fetchone() + return _normalize_job(row) if row else None + + +def _is_user_removed_job(job: dict | None) -> bool: + return bool( + job + and str(job.get("status") or "").upper() == PUBLISH_STATUS_CANCELLED + and str(job.get("error_code") or "") == USER_REMOVED_ERROR_CODE + ) + + +def _restore_removed_publish_job_for_sync(job: dict) -> dict: + from app.services.publish_repository import PublishRepository + + active_statuses = ( + PUBLISH_STATUS_DRAFT, + PUBLISH_STATUS_WAITING, + PUBLISH_STATUS_SCHEDULED, + PUBLISH_STATUS_PUBLISHING, + PUBLISH_STATUS_NEED_REVIEW, + ) + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + duplicate = connection.execute( + f""" + SELECT id FROM publish_jobs + WHERE id <> ? AND output_clip_id = ? AND platform = ? + AND status IN ({','.join('?' for _ in active_statuses)}) + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC + LIMIT 1 + """, + ( + job["id"], + job.get("output_clip_id"), + job.get("platform"), + *active_statuses, + ), + ).fetchone() + if duplicate: + raise ValueError("同一裁剪片段在当前平台已有有效发布内容,不能重复恢复") + cursor = connection.execute( + """ + UPDATE publish_jobs + SET status = 'WAITING', scheduled_at = '', next_attempt_at = NULL, + finished_at = NULL, error_code = '', error_message = '', last_error = '', + needs_manual_review = 0, execution_phase = '', updated_at = ? + WHERE id = ? AND status = 'CANCELLED' AND error_code = ? + """, + (now, job["id"], USER_REMOVED_ERROR_CODE), + ) + if not cursor.rowcount: + raise ValueError("发布内容状态已经变化,请刷新后重试") + PublishRepository().add_event( + job["id"], + "restored_to_preparation", + from_status=PUBLISH_STATUS_CANCELLED, + to_status=PUBLISH_STATUS_WAITING, + message="任务级同步将当前平台内容重新加入内容准备", + payload={ + "platform": job.get("platform") or "", + "output_clip_id": job.get("output_clip_id") or "", + }, + connection=connection, + ) + connection.commit() + return get_publish_job(job["id"]) + + def _batch_find_opencli_jobs(output_clip_ids: list[str]) -> dict[str, dict[str, dict]]: """一次查询获得所有 output_clip 在各平台的 opencli 发布任务。 @@ -1038,6 +1391,30 @@ def _batch_find_opencli_jobs(output_clip_ids: list[str]) -> dict[str, dict[str, return result +def _batch_find_publish_jobs(output_clip_ids: list[str]) -> dict[str, dict[str, dict]]: + """返回每个切片、每个平台最新的一条发布任务,不限定执行方式。""" + if not output_clip_ids: + return {} + placeholders = ",".join("?" for _ in output_clip_ids) + with get_connection() as connection: + rows = connection.execute( + f""" + SELECT * FROM publish_jobs + WHERE output_clip_id IN ({placeholders}) + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC, created_at DESC + """, + output_clip_ids, + ).fetchall() + result: dict[str, dict[str, dict]] = {} + for row in rows: + job = _normalize_job(row) + output_id = str(job.get("output_clip_id") or "") + platform = str(job.get("platform") or "") + if output_id and platform: + result.setdefault(output_id, {}).setdefault(platform, job) + return result + + def _publish_provider_payload(metadata: dict, cover: dict | None = None) -> str: cover = cover or {} return json.dumps( @@ -1051,7 +1428,16 @@ def _publish_provider_payload(metadata: dict, cover: dict | None = None) -> str: ) -def _insert_opencli_job(item: dict, platform: str, metadata: dict, cover: dict | None = None) -> dict: +def _insert_opencli_job( + item: dict, + platform: str, + metadata: dict, + cover: dict | None = None, + *, + video_source: str = "original", + inherited: dict | None = None, +) -> dict: + inherited = inherited or {} raw_video_path, _ = _resolve_publish_video_path( { **item, @@ -1059,7 +1445,7 @@ def _insert_opencli_job(item: dict, platform: str, metadata: dict, cover: dict | "subtitle_status": item.get("subtitle_status"), "subtitled_output_file_path": item.get("subtitled_output_file_path"), }, - "original", + video_source, ) job_id = uuid4().hex[:12] now = _now_iso() @@ -1067,33 +1453,79 @@ def _insert_opencli_job(item: dict, platform: str, metadata: dict, cover: dict | cover_file_path = str(cover.get("cover_file_path") or "") cover_mode = "time" if cover_file_path else "auto" cover_time_seconds = float(cover.get("cover_time_seconds") or 0) + publish_mode = str(inherited.get("publish_mode") or settings.publish_default_mode) + if publish_mode not in PUBLISH_MODES: + publish_mode = settings.publish_default_mode with get_connection() as connection: + inherited_account_id = str(inherited.get("account_id") or "") + account = connection.execute( + """ + SELECT id FROM publish_accounts + WHERE platform = ? AND login_status = 'normal' + AND (? = '' OR id = ?) + ORDER BY COALESCE(last_login_at, updated_at) DESC LIMIT 1 + """, + (platform, inherited_account_id, inherited_account_id), + ).fetchone() + if not account and inherited_account_id: + account = connection.execute( + "SELECT id FROM publish_accounts WHERE id = ? AND platform = ?", + (inherited_account_id, platform), + ).fetchone() + if not account: + account = connection.execute( + """ + SELECT id FROM publish_accounts + WHERE platform = ? AND login_status = 'normal' + ORDER BY COALESCE(last_login_at, updated_at) DESC LIMIT 1 + """, + (platform,), + ).fetchone() + account_id = account["id"] if account else None + title = str(inherited.get("title") or metadata["title"]) + description = str(inherited.get("description") or inherited.get("caption") or metadata["description"]) + tags = str(inherited.get("tags") or inherited.get("hashtags") or metadata["tags"]) connection.execute( """ INSERT INTO publish_jobs ( - id, task_id, output_clip_id, account_id, platform, publish_mode, - video_source, video_file_path, title, description, tags, visibility, + id, task_id, output_clip_id, clip_id, account_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, tags, hashtags, visibility, cover_mode, cover_time_seconds, allow_download, bilibili_tid, bilibili_copyright, bilibili_source, cover_file_path, scheduled_at, - status, audit_status, provider_response, created_at, updated_at + schedule_timezone, timezone, status, audit_status, provider_response, + max_attempts, created_at, updated_at ) - VALUES (?, ?, ?, '', ?, 'opencli_publish', 'original', ?, ?, ?, ?, 'public', - ?, ?, 1, ?, 'original', '', ?, '', 'WAITING', 'not_submitted', ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, '', ?, ?, 'WAITING', 'not_submitted', ?, ?, ?, ?) """, ( job_id, item["task_id"], item["output_clip_id"], + item["output_clip_id"], + account_id, platform, + publish_mode, + video_source, raw_video_path, - metadata["title"], - metadata["description"], - metadata["tags"], + raw_video_path, + title, + description, + description, + tags, + tags, + str(inherited.get("visibility") or "public"), cover_mode, cover_time_seconds, - DEFAULT_BILIBILI_TID, + 1 if inherited.get("allow_download", True) else 0, + str(inherited.get("bilibili_tid") or DEFAULT_BILIBILI_TID), + str(inherited.get("bilibili_copyright") or "original"), + str(inherited.get("bilibili_source") or ""), cover_file_path, + settings.app_timezone, + settings.app_timezone, _publish_provider_payload(metadata, cover), + settings.publish_scheduler_max_retry_count, now, now, ), @@ -1102,10 +1534,14 @@ def _insert_opencli_job(item: dict, platform: str, metadata: dict, cover: dict | return get_publish_job(job_id) -def refresh_send_queue(use_ai: bool = False) -> dict: +def refresh_send_queue(use_ai: bool = False, platform: str | None = None) -> dict: + if platform and platform not in PLATFORM_LABELS: + raise ValueError("只支持补充抖音或 B站发送任务") + target_platforms = [platform] if platform else list(PLATFORM_LABELS) created: list[dict] = [] updated_covers = 0 skipped = 0 + skipped_removed = 0 errors: list[str] = [] for item in _list_completed_publish_clips(): item_metadata: dict | None = None @@ -1121,33 +1557,415 @@ def ensure_cover_for_item() -> dict: errors.append(f"{item.get('output_file_name') or item.get('output_clip_id')} / 自动封面:{exc}") return cover_state["cover"] or {} - for platform in PLATFORM_LABELS: - existing_job = _find_opencli_job(item["output_clip_id"], platform) + for target_platform in target_platforms: + existing_job = _find_active_publish_job(item["output_clip_id"], target_platform) if existing_job: skipped += 1 - if not existing_job.get("cover_file_path"): + if existing_job.get("publish_mode") == "opencli_publish" and not existing_job.get("cover_file_path"): cover = ensure_cover_for_item() if cover.get("cover_file_path"): _update_job_cover(existing_job["id"], cover) updated_covers += 1 continue + if _is_user_removed_job(_find_latest_publish_job(item["output_clip_id"], target_platform)): + skipped_removed += 1 + continue try: if item_metadata is None: item_metadata = generate_publish_metadata(item, use_ai=use_ai) - created.append(_insert_opencli_job(item, platform, item_metadata, ensure_cover_for_item())) + created.append(_insert_opencli_job(item, target_platform, item_metadata, ensure_cover_for_item())) except Exception as exc: - errors.append(f"{item.get('output_file_name') or item.get('output_clip_id')} / {PLATFORM_LABELS[platform]}:{exc}") + errors.append( + f"{item.get('output_file_name') or item.get('output_clip_id')} / " + f"{PLATFORM_LABELS[target_platform]}:{exc}" + ) if item_metadata is None: item_metadata = {} return { "status": "ok" if not errors else "partial", - "message": f"已新增 {len(created)} 条发送任务,自动选择 {len(created) + updated_covers} 张封面帧,跳过 {skipped} 条已存在任务,{len(errors)} 条需要处理。", + "message": ( + f"已新增 {len(created)} 条发送任务,自动选择 {len(created) + updated_covers} 张封面帧," + f"跳过 {skipped} 条已存在任务、{skipped_removed} 条手动移除内容,{len(errors)} 条需要处理。" + ), "created": created, + "skipped_removed": skipped_removed, "errors": errors, **get_publish_center_context(), } +def _task_target_platforms(task_platform: str | None) -> list[str]: + normalized = str(task_platform or "general").strip().lower() + if normalized in PLATFORM_LABELS: + return [normalized] + return list(PLATFORM_LABELS) + + +def get_publish_link_states(task_ids: list[str]) -> dict[str, dict]: + normalized_ids = list(dict.fromkeys(str(task_id).strip() for task_id in task_ids if str(task_id).strip())) + if not normalized_ids: + return {} + placeholders = ",".join("?" for _ in normalized_ids) + with get_connection() as connection: + tasks = connection.execute( + f"SELECT id, platform FROM tasks WHERE id IN ({placeholders})", + normalized_ids, + ).fetchall() + outputs = connection.execute( + f""" + SELECT id, task_id + FROM output_clip + WHERE task_id IN ({placeholders}) AND is_active = 1 AND status = 'completed' + """, + normalized_ids, + ).fetchall() + jobs = connection.execute( + f""" + SELECT publish_jobs.id, publish_jobs.task_id, publish_jobs.output_clip_id, + publish_jobs.platform, publish_jobs.status, publish_jobs.error_code, + publish_jobs.updated_at, publish_jobs.created_at, + output_clip.is_active AS output_is_active + FROM publish_jobs + LEFT JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id + WHERE publish_jobs.task_id IN ({placeholders}) + ORDER BY COALESCE(NULLIF(publish_jobs.updated_at, ''), publish_jobs.created_at) DESC, + publish_jobs.created_at DESC, publish_jobs.id DESC + """, + normalized_ids, + ).fetchall() + + task_platforms = {row["id"]: _task_target_platforms(row["platform"]) for row in tasks} + active_outputs: dict[str, list[str]] = {task_id: [] for task_id in normalized_ids} + for row in outputs: + active_outputs.setdefault(row["task_id"], []).append(row["id"]) + + latest: dict[tuple[str, str], dict] = {} + stale_counts = {task_id: 0 for task_id in normalized_ids} + for raw in jobs: + job = dict(raw) + if not bool(job.get("output_is_active")) and _normalize_publish_status(job.get("status")) in ACTIVE_PREPARATION_STATUSES: + stale_counts[job["task_id"]] = stale_counts.get(job["task_id"], 0) + 1 + key = (str(job.get("output_clip_id") or ""), str(job.get("platform") or "")) + latest.setdefault(key, job) + + states: dict[str, dict] = {} + for task_id in task_platforms: + platforms = task_platforms[task_id] + output_ids = active_outputs.get(task_id, []) + per_platform = {} + linked_total = removed_total = missing_total = 0 + per_output: dict[str, dict[str, str]] = {} + for platform in platforms: + linked = removed = missing = 0 + for output_id in output_ids: + job = latest.get((output_id, platform)) + status = _normalize_publish_status(job.get("status")) if job else "" + error_code = str(job.get("error_code") or "") if job else "" + if job and status != PUBLISH_STATUS_CANCELLED: + linked += 1 + output_state = "已关联" + elif job and error_code == USER_REMOVED_ERROR_CODE: + removed += 1 + missing += 1 + output_state = "已移出" + else: + missing += 1 + output_state = "待同步" + per_output.setdefault(output_id, {})[platform] = output_state + per_platform[platform] = { + "label": PLATFORM_LABELS[platform], + "expected": len(output_ids), + "linked": linked, + "removed": removed, + "missing": missing, + } + linked_total += linked + removed_total += removed + missing_total += missing + expected = len(output_ids) * len(platforms) + stale = stale_counts.get(task_id, 0) + if not output_ids: + state = "not_ready" + label = "等待生成切片" + elif missing_total: + state = "needs_sync" + label = f"待同步 {missing_total} 条" + elif stale: + state = "attention" + label = f"已关联 {linked_total}/{expected},存在旧版记录" + else: + state = "linked" + label = f"已关联 {linked_total}/{expected}" + states[task_id] = { + "task_id": task_id, + "state": state, + "label": label, + "active_clip_count": len(output_ids), + "expected_count": expected, + "linked_count": linked_total, + "missing_count": missing_total, + "removed_count": removed_total, + "stale_pending_count": stale, + "platforms": per_platform, + "per_output": per_output, + } + return states + + +def get_task_publish_link_state(task_id: str) -> dict: + state = get_publish_link_states([task_id]).get(task_id) + if state is None: + raise ValueError("任务不存在") + return state + + +def _find_inheritable_publish_job(item: dict, platform: str) -> dict: + clip_candidate_id = str(item.get("clip_candidate_id") or "") + if not clip_candidate_id: + return {} + with get_connection() as connection: + row = connection.execute( + """ + SELECT publish_jobs.* + FROM publish_jobs + JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id + WHERE publish_jobs.task_id = ? AND output_clip.clip_candidate_id = ? + AND publish_jobs.platform = ? AND publish_jobs.output_clip_id != ? + ORDER BY COALESCE(NULLIF(publish_jobs.updated_at, ''), publish_jobs.created_at) DESC, + publish_jobs.created_at DESC, publish_jobs.id DESC + LIMIT 1 + """, + (item["task_id"], clip_candidate_id, platform, item["output_clip_id"]), + ).fetchone() + return dict(row) if row else {} + + +def _preferred_video_source(item: dict, prefer_subtitled: bool) -> str: + raw_path = str(item.get("subtitled_output_file_path") or "").strip() + path = resolve_video_file_path(raw_path) if raw_path else None + if prefer_subtitled and item.get("subtitle_status") == "completed" and path and path.exists(): + return "subtitled" + return "original" + + +def _update_preparation_video_source(job: dict, item: dict, video_source: str) -> dict: + from app.services.publish_repository import PublishRepository + + status = _normalize_publish_status(job.get("status")) + if status not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING}: + raise ValueError("只有未排期的内容准备记录可以更换视频版本") + raw_video_path, _ = _resolve_publish_video_path(item, video_source) + try: + cover = _generate_default_publish_cover(item, video_source) + except Exception as exc: + cover = {"cover_error": str(exc)} + cover_file_path = str(cover.get("cover_file_path") or "") + cover_mode = "time" if cover_file_path else "auto" + cover_time_seconds = float(cover.get("cover_time_seconds") or 0) + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + cursor = connection.execute( + """ + UPDATE publish_jobs + SET video_source = ?, video_file_path = ?, video_path = ?, + cover_mode = ?, cover_time_seconds = ?, cover_file_path = ?, + provider_response = ?, updated_at = ? + WHERE id = ? AND status IN ('DRAFT', 'WAITING') + """, + ( + video_source, + raw_video_path, + raw_video_path, + cover_mode, + cover_time_seconds, + cover_file_path, + _publish_provider_payload({}, cover), + now, + job["id"], + ), + ) + if not cursor.rowcount: + raise ValueError("发布内容状态已经变化,请刷新后重试") + PublishRepository().add_event( + job["id"], + "video_source_updated", + from_status=status, + to_status=status, + message="字幕工作台同步时改用带字幕成片,并重新生成候选封面", + payload={ + "task_id": item["task_id"], + "output_clip_id": item["output_clip_id"], + "video_source": video_source, + }, + connection=connection, + ) + connection.commit() + return get_publish_job(job["id"]) + + +def _supersede_stale_publish_jobs(task_id: str) -> int: + from app.services.publish_repository import PublishRepository + + repository = PublishRepository() + now = _now_iso() + affected = 0 + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + rows = connection.execute( + """ + SELECT publish_jobs.* + FROM publish_jobs + JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id + WHERE publish_jobs.task_id = ? AND output_clip.is_active = 0 + AND publish_jobs.status IN ('DRAFT', 'WAITING', 'SCHEDULED') + """, + (task_id,), + ).fetchall() + for raw in rows: + job = dict(raw) + cursor = connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', scheduled_at = '', next_attempt_at = NULL, + finished_at = ?, error_code = ?, error_message = '', + last_error = '', needs_manual_review = 0, execution_phase = '', updated_at = ? + WHERE id = ? AND status = ? + """, + (now, SUPERSEDED_BY_RECUT_ERROR_CODE, now, job["id"], job["status"]), + ) + if not cursor.rowcount: + continue + affected += 1 + repository.add_event( + job["id"], + "superseded_by_recut", + from_status=_normalize_publish_status(job["status"]), + to_status=PUBLISH_STATUS_CANCELLED, + error_code=SUPERSEDED_BY_RECUT_ERROR_CODE, + message="旧切片发布内容已被当前激活切片版本替代", + payload={"task_id": task_id, "output_clip_id": job.get("output_clip_id") or ""}, + connection=connection, + ) + connection.commit() + return affected + + +def sync_task_publish_jobs( + task_id: str, + *, + prefer_subtitled: bool = True, + restore_removed: bool = True, +) -> dict: + with get_connection() as connection: + task = connection.execute( + "SELECT id, platform FROM tasks WHERE id = ? AND COALESCE(is_deleted, 0) = 0", + (task_id,), + ).fetchone() + if not task: + raise ValueError("任务不存在") + items = _list_completed_publish_clips(task_id) + if not items: + raise ValueError("当前任务还没有可同步的激活切片") + + superseded_count = _supersede_stale_publish_jobs(task_id) + created: list[dict] = [] + restored: list[dict] = [] + updated: list[dict] = [] + skipped = 0 + errors: list[str] = [] + warnings: list[str] = [] + platforms = _task_target_platforms(task["platform"]) + + for item in items: + item_metadata: dict | None = None + item_covers: dict[str, dict] = {} + for platform in platforms: + latest = _find_latest_publish_job(item["output_clip_id"], platform) + latest_status = _normalize_publish_status(latest.get("status")) if latest else "" + if latest and latest_status != PUBLISH_STATUS_CANCELLED: + video_source = _preferred_video_source(item, prefer_subtitled) + if video_source == "subtitled" and latest.get("video_source") != "subtitled": + if latest_status in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING}: + try: + updated.append(_update_preparation_video_source(latest, item, video_source)) + except Exception as exc: + errors.append( + f"{item.get('output_file_name') or item['output_clip_id']} / " + f"{PLATFORM_LABELS[platform]}:{exc}" + ) + elif latest_status == PUBLISH_STATUS_SCHEDULED: + warnings.append( + f"{item.get('output_file_name') or item['output_clip_id']} / " + f"{PLATFORM_LABELS[platform]} 已排期,未静默更换为带字幕版本;请先取消排期。" + ) + skipped += 1 + continue + if _is_user_removed_job(latest): + if restore_removed: + try: + restored_job = _restore_removed_publish_job_for_sync(latest) + video_source = _preferred_video_source(item, prefer_subtitled) + if video_source == "subtitled" and restored_job.get("video_source") != "subtitled": + restored_job = _update_preparation_video_source(restored_job, item, video_source) + restored.append(restored_job) + except Exception as exc: + errors.append(f"{item.get('output_file_name') or item['output_clip_id']} / {PLATFORM_LABELS[platform]}:{exc}") + else: + skipped += 1 + continue + try: + video_source = _preferred_video_source(item, prefer_subtitled) + if item_metadata is None: + item_metadata = generate_publish_metadata(item, use_ai=False) + if video_source not in item_covers: + try: + item_covers[video_source] = _generate_default_publish_cover(item, video_source) + except Exception as exc: + item_covers[video_source] = {"cover_error": str(exc)} + inherited = _find_inheritable_publish_job(item, platform) + created.append( + _insert_opencli_job( + item, + platform, + item_metadata, + item_covers[video_source], + video_source=video_source, + inherited=inherited, + ) + ) + except Exception as exc: + errors.append(f"{item.get('output_file_name') or item['output_clip_id']} / {PLATFORM_LABELS[platform]}:{exc}") + + link_state = get_task_publish_link_state(task_id) + from app.services.task_log_service import append_task_log + + append_task_log( + task_id, + ( + f"同步发送中心:新增 {len(created)} 条,恢复 {len(restored)} 条,更新视频 {len(updated)} 条," + f"旧版失效 {superseded_count} 条,跳过 {skipped} 条,失败 {len(errors)} 条" + ), + ) + return { + "status": "partial" if errors else "ok", + "message": ( + f"发送中心同步完成:新增 {len(created)} 条、恢复 {len(restored)} 条、" + f"更新视频 {len(updated)} 条、旧版安全转入历史 {superseded_count} 条," + f"失败 {len(errors)} 条、提示 {len(warnings)} 条。" + ), + "created_count": len(created), + "restored_count": len(restored), + "updated_count": len(updated), + "superseded_count": superseded_count, + "skipped_count": skipped, + "errors": errors, + "warnings": warnings, + "jobs": [*created, *restored, *updated], + "link_state": link_state, + } + + def _wrap_cover_title(title: str) -> str: text = re.sub(r"\s+", " ", _sanitize_publish_title(title or "精彩片段")) or "精彩片段" if len(text) > 34: @@ -1268,9 +2086,20 @@ def _cover_frame_times(duration: float, frame_count: int) -> list[float]: def _default_cover_time_seconds(duration: float) -> float: - if duration <= 1: + if duration <= 0: return 0 - return max(0, min(duration - 0.1, max(1.0, duration * 0.25))) + return max(0, min(duration - 0.001, duration / 2)) + + +def _resolve_cover_time_seconds(duration: float, preferred_time_seconds: Any = None) -> tuple[float, str]: + fallback = _default_cover_time_seconds(duration) + try: + preferred = float(preferred_time_seconds) + except (TypeError, ValueError): + return fallback, "midpoint_fallback" + if not math.isfinite(preferred) or preferred < 0 or duration <= 0 or preferred >= duration: + return fallback, "midpoint_fallback" + return round(preferred, 3), "ai_frame" def _unique_frame_cover_path(task_id: str, output_clip_id: str, video_source: str, seconds: float) -> Path: @@ -1317,36 +2146,210 @@ def _cover_frame_payload(task_id: str, cover_path: Path, seconds: float) -> dict } -def _generate_default_publish_cover(item: dict, video_source: str = "original") -> dict: - _, video_path = _resolve_publish_video_path( - { - **item, - "output_status": item.get("output_status") or "completed", - "subtitle_status": item.get("subtitle_status"), - "subtitled_output_file_path": item.get("subtitled_output_file_path"), - }, - video_source, - ) - duration = _get_video_duration_seconds(video_path) - seconds = _default_cover_time_seconds(duration) - cover_path = _unique_frame_cover_path(item["task_id"], item["output_clip_id"], video_source, seconds) - _write_plain_cover_frame(video_path, cover_path, seconds) - return _cover_frame_payload(item["task_id"], cover_path, seconds) +def generate_publish_cover_for_item( + item: dict, + preferred_time_seconds: Any = None, + video_source: str = "original", +) -> dict: + output_clip_id = str(item.get("output_clip_id") or item.get("id") or "").strip() + if not output_clip_id: + raise ValueError("封面生成失败:缺少切片编号。") + _, video_path = _resolve_publish_video_path( + { + **item, + "output_status": item.get("output_status") or item.get("status") or "completed", + "subtitle_status": item.get("subtitle_status"), + "subtitled_output_file_path": item.get("subtitled_output_file_path"), + }, + video_source, + ) + duration = _get_video_duration_seconds(video_path) + seconds, cover_source = _resolve_cover_time_seconds(duration, preferred_time_seconds) + cover_path = _unique_frame_cover_path(item["task_id"], output_clip_id, video_source, seconds) + _write_plain_cover_frame(video_path, cover_path, seconds) + return { + **_cover_frame_payload(item["task_id"], cover_path, seconds), + "cover_source": cover_source, + } + + +def _generate_default_publish_cover(item: dict, video_source: str = "original") -> dict: + return generate_publish_cover_for_item(item, preferred_time_seconds=None, video_source=video_source) + + +def _update_job_cover(job_id: str, cover: dict) -> None: + if not cover.get("cover_file_path"): + return + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs + SET cover_mode = 'time', cover_time_seconds = ?, cover_file_path = ?, updated_at = ? + WHERE id = ? + """, + (float(cover.get("cover_time_seconds") or 0), str(cover.get("cover_file_path") or ""), _now_iso(), job_id), + ) + connection.commit() + + +def _list_missing_publish_cover_jobs(platform: str | None = None) -> list[dict]: + normalized_platform = str(platform or "").strip().lower() + if normalized_platform and normalized_platform not in PLATFORM_LABELS: + raise ValueError("暂不支持这个发布平台。") + with get_connection() as connection: + rows = connection.execute( + """ + SELECT + publish_jobs.id, + publish_jobs.task_id, + publish_jobs.output_clip_id, + publish_jobs.video_source, + publish_jobs.title, + publish_jobs.provider_response, + output_clip.output_file_path, + output_clip.output_file_name, + output_clip.status AS output_status, + clip_candidates.cover_time_seconds AS ai_cover_time_seconds, + subtitle_jobs.status AS subtitle_status, + subtitle_jobs.output_file_path AS subtitled_output_file_path + FROM publish_jobs + JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id + JOIN tasks ON tasks.id = publish_jobs.task_id + LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id + LEFT JOIN subtitle_jobs + ON subtitle_jobs.output_clip_id = output_clip.id + AND subtitle_jobs.is_active = 1 + WHERE publish_jobs.status IN ('DRAFT', 'WAITING', 'SCHEDULED') + AND TRIM(COALESCE(publish_jobs.cover_file_path, '')) = '' + AND output_clip.is_active = 1 + AND tasks.is_deleted = 0 + AND (? = '' OR publish_jobs.platform = ?) + ORDER BY output_clip.created_at ASC, publish_jobs.created_at ASC + """, + (normalized_platform, normalized_platform), + ).fetchall() + return [dict(row) for row in rows] + + +def _find_reusable_publish_cover(output_clip_id: str, video_source: str) -> dict | None: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT task_id, cover_file_path, cover_time_seconds + FROM publish_jobs + WHERE output_clip_id = ? + AND video_source = ? + AND TRIM(COALESCE(cover_file_path, '')) <> '' + ORDER BY updated_at DESC, created_at DESC + """, + (output_clip_id, video_source), + ).fetchall() + for row in rows: + cover_path = Path(str(row["cover_file_path"] or "").strip()) + if cover_path.is_file(): + return { + "cover_file_path": str(cover_path), + "cover_media_url": _cover_media_url(str(row["task_id"] or ""), str(cover_path)), + "cover_time_seconds": float(row["cover_time_seconds"] or 0), + "cover_source": "existing_clip_cover", + } + return None + + +def _apply_cover_to_missing_jobs(jobs: list[dict], cover: dict) -> list[str]: + updated_ids: list[str] = [] + now = _now_iso() + with get_connection() as connection: + for job in jobs: + provider_response = _parse_json_text(job.get("provider_response")) + provider_response.update( + { + "cover_source": cover.get("cover_source") or "midpoint_fallback", + "cover_time_seconds": float(cover.get("cover_time_seconds") or 0), + } + ) + cursor = connection.execute( + """ + UPDATE publish_jobs + SET cover_mode = 'time', + cover_time_seconds = ?, + cover_file_path = ?, + provider_response = ?, + updated_at = ? + WHERE id = ? + AND status IN ('DRAFT', 'WAITING', 'SCHEDULED') + AND TRIM(COALESCE(cover_file_path, '')) = '' + """, + ( + float(cover.get("cover_time_seconds") or 0), + str(cover.get("cover_file_path") or ""), + json.dumps(provider_response, ensure_ascii=False), + now, + job["id"], + ), + ) + if cursor.rowcount: + updated_ids.append(job["id"]) + connection.commit() + return updated_ids + +def backfill_missing_publish_covers(platform: str | None = None) -> dict: + missing_jobs = _list_missing_publish_cover_jobs(platform) + grouped_jobs: dict[tuple[str, str], list[dict]] = {} + for job in missing_jobs: + key = ( + str(job.get("output_clip_id") or ""), + str(job.get("video_source") or "original"), + ) + grouped_jobs.setdefault(key, []).append(job) + + generated_cover_count = 0 + reused_cover_count = 0 + updated_ids: list[str] = [] + errors: list[dict] = [] + for (output_clip_id, video_source), jobs in grouped_jobs.items(): + item = jobs[0] + try: + cover = _find_reusable_publish_cover(output_clip_id, video_source) + if cover: + reused_cover_count += 1 + else: + cover = generate_publish_cover_for_item( + item, + preferred_time_seconds=item.get("ai_cover_time_seconds"), + video_source=video_source, + ) + generated_cover_count += 1 + updated_ids.extend(_apply_cover_to_missing_jobs(jobs, cover)) + except Exception as exc: + errors.append( + { + "output_clip_id": output_clip_id, + "output_file_name": item.get("output_file_name") or item.get("title") or output_clip_id, + "message": str(exc), + } + ) -def _update_job_cover(job_id: str, cover: dict) -> None: - if not cover.get("cover_file_path"): - return - with get_connection() as connection: - connection.execute( - """ - UPDATE publish_jobs - SET cover_mode = 'time', cover_time_seconds = ?, cover_file_path = ?, updated_at = ? - WHERE id = ? - """, - (float(cover.get("cover_time_seconds") or 0), str(cover.get("cover_file_path") or ""), _now_iso(), job_id), + updated_jobs = [job for job_id in updated_ids if (job := get_publish_job(job_id))] + status = "partial" if errors else "ok" + if not missing_jobs: + message = "当前没有需要补充封面的未发布任务。" + else: + message = ( + f"已生成 {generated_cover_count} 张新封面、复用 {reused_cover_count} 张已有封面," + f"补齐 {len(updated_ids)} 条发布任务,{len(errors)} 条切片处理失败。" ) - connection.commit() + return { + "status": status, + "message": message, + "generated_cover_count": generated_cover_count, + "reused_cover_count": reused_cover_count, + "updated_job_count": len(updated_ids), + "failed_clip_count": len(errors), + "errors": errors, + "jobs": updated_jobs, + } def generate_publish_cover_frames(payload: PublishCoverFrameBatchCreate) -> dict: @@ -1448,7 +2451,10 @@ def create_publish_job(payload: PublishJobCreate) -> dict: cover_file_path = (payload.cover_file_path or "").strip() cover_time_seconds = float(payload.cover_time_seconds or 0) cover_mode = payload.cover_mode - provider_payload = "真实发布任务已创建,等待执行。" if payload.publish_mode == "api_publish" else "本地发布任务已创建,等待人工确认。" + existing = _find_active_publish_job(payload.output_clip_id, payload.platform) + if existing and existing.get("publish_mode") == payload.publish_mode: + return {"status": "exists", "message": "同一切片、平台和执行方式已有有效任务。", "job": existing} + provider_payload = "真实发布任务已创建,等待执行。" if not cover_file_path: try: auto_cover = _generate_default_publish_cover( @@ -1471,37 +2477,55 @@ def create_publish_job(payload: PublishJobCreate) -> dict: account = None if payload.publish_mode == "api_publish": config, account = _validate_api_publish_ready(payload) + elif payload.publish_mode == "local_browser": + account = get_account(payload.account_id or "") + if not account: + raise ValueError("真实浏览器发布必须先选择一个发布账号") + if account.get("platform") != payload.platform: + raise ValueError("发布账号与目标平台不一致") + + from app.services.publish_time import ensure_future, to_utc_iso + + scheduled_at = "" + if (payload.scheduled_at or "").strip(): + ensure_future(payload.scheduled_at, settings.app_timezone) + scheduled_at = to_utc_iso(payload.scheduled_at, settings.app_timezone) job_id = uuid4().hex[:12] now = _now_iso() - status = PUBLISH_STATUS_DRAFT if payload.publish_mode == "draft" else PUBLISH_STATUS_WAITING - if (payload.scheduled_at or "").strip(): + status = PUBLISH_STATUS_WAITING + if scheduled_at: status = PUBLISH_STATUS_SCHEDULED - if payload.publish_mode == "api_publish": - status = PUBLISH_STATUS_PUBLISHING + if payload.publish_mode == "api_publish" and not (payload.scheduled_at or "").strip(): + status = PUBLISH_STATUS_WAITING with get_connection() as connection: connection.execute( """ INSERT INTO publish_jobs ( - id, task_id, output_clip_id, account_id, platform, publish_mode, - video_source, video_file_path, title, description, tags, visibility, + id, task_id, output_clip_id, clip_id, account_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, tags, hashtags, visibility, cover_mode, cover_time_seconds, allow_download, bilibili_tid, bilibili_copyright, bilibili_source, cover_file_path, scheduled_at, - status, audit_status, provider_response, created_at, updated_at + schedule_timezone, timezone, status, audit_status, provider_response, + max_attempts, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( job_id, payload.task_id, payload.output_clip_id, + payload.output_clip_id, payload.account_id or "", payload.platform, payload.publish_mode, payload.video_source, raw_video_path, + raw_video_path, safe_content["title"], safe_content["description"], + safe_content["description"], + safe_content["tags"], safe_content["tags"], payload.visibility, cover_mode, @@ -1511,18 +2535,19 @@ def create_publish_job(payload: PublishJobCreate) -> dict: payload.bilibili_copyright, (payload.bilibili_source or "").strip(), cover_file_path, - (payload.scheduled_at or "").strip(), + scheduled_at, + settings.app_timezone, + settings.app_timezone, status, "not_submitted", provider_payload, + settings.publish_scheduler_max_retry_count, now, now, ), ) connection.commit() - if payload.publish_mode == "api_publish" and config and account: - return _execute_publish_job(job_id, config=config, account=account, video_path=resolved_video_path) return {"status": "ok", "message": "发布任务已创建。", "job": get_publish_job(job_id)} @@ -1557,8 +2582,16 @@ def get_publish_job(job_id: str) -> dict | None: SELECT publish_jobs.*, tasks.task_name, + tasks.source_type AS task_source_type, + tasks.original_video_path AS task_original_video_path, + tasks.nas_file_path AS task_nas_file_path, + tasks.created_at AS task_created_at, output_clip.output_file_name, - publish_accounts.account_name + output_clip.created_at AS output_clip_created_at, + output_clip.is_active AS output_is_active, + publish_accounts.account_name, + publish_accounts.login_status AS account_login_status, + publish_accounts.login_message AS account_login_message FROM publish_jobs LEFT JOIN tasks ON tasks.id = publish_jobs.task_id LEFT JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id @@ -1570,13 +2603,21 @@ def get_publish_job(job_id: str) -> dict | None: return _normalize_job(row) if row else None -def list_publish_jobs(limit: int | None = 100) -> list[dict]: +def list_publish_jobs(limit: int | None = 100, *, worker_state: dict | None = None) -> list[dict]: sql = """ SELECT publish_jobs.*, tasks.task_name, + tasks.source_type AS task_source_type, + tasks.original_video_path AS task_original_video_path, + tasks.nas_file_path AS task_nas_file_path, + tasks.created_at AS task_created_at, output_clip.output_file_name, - publish_accounts.account_name + output_clip.created_at AS output_clip_created_at, + output_clip.is_active AS output_is_active, + publish_accounts.account_name, + publish_accounts.login_status AS account_login_status, + publish_accounts.login_message AS account_login_message FROM publish_jobs LEFT JOIN tasks ON tasks.id = publish_jobs.task_id LEFT JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id @@ -1589,7 +2630,374 @@ def list_publish_jobs(limit: int | None = 100) -> list[dict]: params = (limit,) with get_connection() as connection: rows = connection.execute(sql, params).fetchall() - return [_normalize_job(row) for row in rows] + accounts = list_accounts() + if worker_state is None: + from app.services.publish_scheduler import scheduler_health + + worker_state = scheduler_health() + return [_normalize_job(row, accounts=accounts, worker_state=worker_state) for row in rows] + + +def _publish_history_anchor(job: dict) -> datetime | None: + timezone_name = str(job.get("schedule_timezone") or job.get("timezone") or settings.app_timezone) + for field in ("scheduled_at", "started_at", "finished_at", "created_at"): + value = str(job.get(field) or "").strip() + if not value: + continue + try: + return parse_datetime(value, timezone_name).astimezone(app_zone(settings.app_timezone)) + except ValueError: + continue + return None + + +def _validate_history_platform(platform: str) -> str: + normalized = str(platform or "").strip().lower() + if normalized not in PLATFORM_LABELS: + raise ValueError("执行记录平台只支持抖音或 B站") + return normalized + + +def _validate_history_month(month: str) -> tuple[int, int]: + matched = re.fullmatch(r"(\d{4})-(\d{2})", str(month or "").strip()) + if not matched: + raise ValueError("月份格式必须为 YYYY-MM") + year, month_number = int(matched.group(1)), int(matched.group(2)) + if not 1 <= month_number <= 12: + raise ValueError("月份必须在 01 到 12 之间") + return year, month_number + + +def _query_publish_history_jobs( + *, + platform: str, + deleted: bool, + status: str = "all", + worker_state: dict | None = None, +) -> list[dict]: + normalized_platform = _validate_history_platform(platform) + normalized_status = str(status or "all").strip().upper() + if normalized_status != "ALL" and normalized_status not in PUBLISH_HISTORY_STATUSES: + raise ValueError("执行记录状态筛选无效") + params: list[Any] = [normalized_platform, 1 if deleted else 0] + status_clause = "" + if normalized_status != "ALL": + status_clause = " AND publish_jobs.status = ?" + params.append(normalized_status) + with get_connection() as connection: + rows = connection.execute( + f""" + SELECT + publish_jobs.*, + tasks.task_name, + tasks.source_type AS task_source_type, + tasks.original_video_path AS task_original_video_path, + tasks.nas_file_path AS task_nas_file_path, + tasks.created_at AS task_created_at, + output_clip.output_file_name, + output_clip.created_at AS output_clip_created_at, + publish_accounts.account_name, + publish_accounts.login_status AS account_login_status, + publish_accounts.login_message AS account_login_message + FROM publish_jobs + LEFT JOIN tasks ON tasks.id = publish_jobs.task_id + LEFT JOIN output_clip ON output_clip.id = publish_jobs.output_clip_id + LEFT JOIN publish_accounts ON publish_accounts.id = publish_jobs.account_id + WHERE publish_jobs.platform = ? + AND COALESCE(publish_jobs.history_hidden, 0) = ? + AND publish_jobs.status IN ({",".join("?" for _ in PUBLISH_HISTORY_STATUSES)}) + {status_clause} + """, + [*params[:2], *sorted(PUBLISH_HISTORY_STATUSES), *params[2:]], + ).fetchall() + accounts = list_accounts() + if worker_state is None: + from app.services.publish_scheduler import scheduler_health + + worker_state = scheduler_health() + jobs = [_normalize_job(row, accounts=accounts, worker_state=worker_state) for row in rows] + for job in jobs: + anchor = _publish_history_anchor(job) + job["history_date"] = anchor.date().isoformat() if anchor else "" + job["history_anchor_at"] = anchor.isoformat(timespec="seconds") if anchor else "" + return jobs + + +def get_publish_history_calendar(platform: str, month: str) -> dict: + normalized_platform = _validate_history_platform(platform) + year, month_number = _validate_history_month(month) + counts_template = {status: 0 for status in sorted(PUBLISH_HISTORY_STATUSES)} + days: dict[str, dict[str, Any]] = {} + for job in _query_publish_history_jobs(platform=normalized_platform, deleted=False): + anchor = _publish_history_anchor(job) + if not anchor or anchor.year != year or anchor.month != month_number: + continue + date_key = anchor.date().isoformat() + item = days.setdefault(date_key, {"date": date_key, "total": 0, "counts": dict(counts_template)}) + status = str(job.get("status") or "").upper() + item["total"] += 1 + if status in item["counts"]: + item["counts"][status] += 1 + return { + "platform": normalized_platform, + "month": f"{year:04d}-{month_number:02d}", + "timezone": settings.app_timezone, + "days": [days[key] for key in sorted(days)], + } + + +def list_publish_history_records( + *, + platform: str, + date: str = "", + status: str = "all", + deleted: bool = False, + page: int = 1, + page_size: int = 50, +) -> dict: + normalized_platform = _validate_history_platform(platform) + normalized_date = str(date or "").strip() + if normalized_date: + try: + datetime.strptime(normalized_date, "%Y-%m-%d") + except ValueError as exc: + raise ValueError("日期格式必须为 YYYY-MM-DD") from exc + current_page = max(1, int(page)) + size = min(50, max(1, int(page_size))) + jobs = _query_publish_history_jobs( + platform=normalized_platform, + deleted=bool(deleted), + status=status, + ) + if normalized_date: + jobs = [job for job in jobs if job.get("history_date") == normalized_date] + jobs.sort( + key=lambda job: ( + str(job.get("history_anchor_at") or ""), + str(job.get("created_at") or ""), + str(job.get("id") or ""), + ), + reverse=True, + ) + total = len(jobs) + total_pages = math.ceil(total / size) if total else 0 + if total_pages and current_page > total_pages: + current_page = total_pages + offset = (current_page - 1) * size + return { + "jobs": jobs[offset:offset + size], + "pagination": { + "page": current_page, + "page_size": size, + "total": total, + "total_pages": total_pages, + }, + "filters": { + "platform": normalized_platform, + "date": normalized_date, + "status": str(status or "all").upper(), + "deleted": bool(deleted), + }, + "timezone": settings.app_timezone, + } + + +def _update_publish_history_visibility( + job_ids: list[str], + *, + platform: str, + hidden: bool, +) -> dict: + from app.services.publish_repository import PublishRepository + + normalized_platform = _validate_history_platform(platform) + ids = list(dict.fromkeys(str(job_id).strip() for job_id in job_ids if str(job_id).strip())) + if not ids: + raise ValueError("至少选择一条执行记录") + if len(ids) > 100: + raise ValueError("每次最多处理 100 条执行记录") + placeholders = ",".join("?" for _ in ids) + repository = PublishRepository() + now = _now_iso() + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + rows = connection.execute( + f"SELECT * FROM publish_jobs WHERE id IN ({placeholders})", + ids, + ).fetchall() + if len(rows) != len(ids): + raise ValueError("部分执行记录不存在") + jobs = [dict(row) for row in rows] + if {str(job.get("platform") or "") for job in jobs} != {normalized_platform}: + raise PublishPlatformIsolationBlocked("当前平台与所选执行记录不一致") + if hidden: + blocked = [ + job for job in jobs + if str(job.get("status") or "").upper() not in PUBLISH_HISTORY_HIDEABLE_STATUSES + ] + if blocked: + raise ValueError("只有已发布、发送失败、已导出或已取消的终态记录可以删除") + target_value = 1 if hidden else 0 + target_time = now if hidden else None + connection.execute( + f""" + UPDATE publish_jobs + SET history_hidden = ?, history_hidden_at = ? + WHERE id IN ({placeholders}) + """, + (target_value, target_time, *ids), + ) + for job in jobs: + if bool(job.get("history_hidden")) == hidden: + continue + repository.add_event( + str(job["id"]), + "history_record_hidden" if hidden else "history_record_restored", + from_status=str(job.get("status") or ""), + to_status=str(job.get("status") or ""), + message="用户从执行记录中安全删除该记录" if hidden else "用户恢复已删除的执行记录", + payload={ + "platform": normalized_platform, + "files_deleted": False, + "platform_item_deleted": False, + }, + connection=connection, + ) + connection.commit() + return { + "status": "ok", + "affected_count": len(ids), + "job_ids": ids, + "message": ( + f"已安全删除 {len(ids)} 条执行记录;视频、执行明细和平台作品均已保留。" + if hidden + else f"已恢复 {len(ids)} 条执行记录。" + ), + } + + +def hide_publish_history_records(job_ids: list[str], *, platform: str) -> dict: + return _update_publish_history_visibility(job_ids, platform=platform, hidden=True) + + +def restore_publish_history_records(job_ids: list[str], *, platform: str) -> dict: + return _update_publish_history_visibility(job_ids, platform=platform, hidden=False) + + +def dismiss_publish_job(job_id: str) -> dict: + from app.services.publish_repository import PublishRepository + + repository = PublishRepository() + now = _now_iso() + allowed_statuses = {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED} + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + if not row: + raise ValueError("发布内容不存在") + job = dict(row) + source_status = _normalize_publish_status(job.get("status")) + if source_status not in allowed_statuses: + raise ValueError("只有草稿、等待或已排期内容可以移出内容准备") + cursor = connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', scheduled_at = '', next_attempt_at = NULL, + finished_at = ?, error_code = ?, error_message = '', last_error = '', + needs_manual_review = 0, execution_phase = '', updated_at = ? + WHERE id = ? AND status = ? + """, + (now, USER_REMOVED_ERROR_CODE, now, job_id, job.get("status")), + ) + if not cursor.rowcount: + raise ValueError("发布内容状态已经变化,请刷新后重试") + repository.add_event( + job_id, + "removed_from_preparation", + from_status=source_status, + to_status=PUBLISH_STATUS_CANCELLED, + error_code=USER_REMOVED_ERROR_CODE, + message="用户从内容准备移除当前平台发布内容", + payload={ + "platform": job.get("platform") or "", + "output_clip_id": job.get("output_clip_id") or "", + "files_deleted": False, + }, + connection=connection, + ) + connection.commit() + return { + "status": "ok", + "message": "已从当前平台的内容准备中移出;原视频和裁剪文件均已保留。", + "job": get_publish_job(job_id), + } + + +def restore_publish_job(job_id: str) -> dict: + from app.services.publish_repository import PublishRepository + + repository = PublishRepository() + now = _now_iso() + active_statuses = ( + PUBLISH_STATUS_DRAFT, + PUBLISH_STATUS_WAITING, + PUBLISH_STATUS_SCHEDULED, + PUBLISH_STATUS_PUBLISHING, + PUBLISH_STATUS_NEED_REVIEW, + ) + with get_connection() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + if not row: + raise ValueError("发布内容不存在") + job = dict(row) + if ( + _normalize_publish_status(job.get("status")) != PUBLISH_STATUS_CANCELLED + or str(job.get("error_code") or "") != USER_REMOVED_ERROR_CODE + ): + raise ValueError("只有从内容准备手动移出的记录可以恢复") + duplicate = connection.execute( + f""" + SELECT id FROM publish_jobs + WHERE id <> ? AND output_clip_id = ? AND platform = ? + AND status IN ({','.join('?' for _ in active_statuses)}) + ORDER BY COALESCE(NULLIF(updated_at, ''), created_at) DESC + LIMIT 1 + """, + (job_id, job.get("output_clip_id"), job.get("platform"), *active_statuses), + ).fetchone() + if duplicate: + raise ValueError("同一裁剪片段在当前平台已有有效发布内容,不能重复恢复") + cursor = connection.execute( + """ + UPDATE publish_jobs + SET status = 'WAITING', scheduled_at = '', next_attempt_at = NULL, + finished_at = NULL, error_code = '', error_message = '', last_error = '', + needs_manual_review = 0, execution_phase = '', updated_at = ? + WHERE id = ? AND status = 'CANCELLED' AND error_code = ? + """, + (now, job_id, USER_REMOVED_ERROR_CODE), + ) + if not cursor.rowcount: + raise ValueError("发布内容状态已经变化,请刷新后重试") + repository.add_event( + job_id, + "restored_to_preparation", + from_status=PUBLISH_STATUS_CANCELLED, + to_status=PUBLISH_STATUS_WAITING, + message="用户将当前平台发布内容重新加入内容准备", + payload={ + "platform": job.get("platform") or "", + "output_clip_id": job.get("output_clip_id") or "", + }, + connection=connection, + ) + connection.commit() + return { + "status": "ok", + "message": "已重新加入当前平台的内容准备,请重新确认账号和排期。", + "job": get_publish_job(job_id), + } def update_publish_job_status(job_id: str, status: str, error_message: str = "") -> dict: @@ -1615,8 +3023,8 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: job = get_publish_job(job_id) if not job: raise ValueError("发送任务不存在。") - if job.get("publish_mode") != "opencli_publish": - raise ValueError("只能编辑 opencli 发送任务。") + if job.get("status") not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED}: + raise ValueError("只有草稿、等待或已排期任务可以编辑;失败任务请先创建重试任务。") safe_content = _sanitize_publish_content( payload.title, payload.tags, @@ -1628,7 +3036,7 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: connection.execute( """ UPDATE publish_jobs - SET title = ?, description = ?, tags = ?, visibility = ?, + SET title = ?, description = ?, caption = ?, tags = ?, hashtags = ?, visibility = ?, cover_file_path = ?, cover_time_seconds = ?, allow_download = ?, bilibili_tid = ?, bilibili_copyright = ?, bilibili_source = ?, updated_at = ? @@ -1637,6 +3045,8 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: ( safe_content["title"], safe_content["description"], + safe_content["description"], + safe_content["tags"], safe_content["tags"], payload.visibility, (payload.cover_file_path or "").strip(), @@ -1659,14 +3069,71 @@ def update_publish_job_schedule(job_id: str, payload: PublishJobScheduleUpdate) return PublishScheduler().update_schedule(job_id, payload.scheduled_at) +def update_publish_job_target(job_id: str, payload: PublishJobTargetUpdate) -> dict: + job = get_publish_job(job_id) + if not job: + raise ValueError("发布任务不存在") + if job.get("status") not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED}: + raise ValueError("当前状态不能修改发布平台或账号") + if payload.platform != job.get("platform"): + raise PublishPlatformIsolationBlocked("任务平台创建后不可修改;请到对应平台任务中选择账号") + if job.get("publish_mode") == "opencli_publish": + raise PublishPlatformIsolationBlocked("旧版任务不能覆盖转换;请使用“转换并发送”保留原记录并创建新任务") + account_id = (payload.account_id or "").strip() + if payload.publish_mode == "local_browser": + account = get_account(account_id) + if not account: + raise ValueError("真实浏览器发布必须选择账号") + if account.get("platform") != payload.platform: + raise ValueError("账号与目标平台不一致") + try: + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs SET platform = ?, account_id = ?, publish_mode = ?, updated_at = ? + WHERE id = ? + """, + (payload.platform, account_id or None, payload.publish_mode, _now_iso(), job_id), + ) + connection.commit() + except Exception as exc: + if "UNIQUE constraint" in str(exc): + raise ValueError("同一切片在该平台已有未完成任务") from exc + raise + return {"status": "ok", "job": get_publish_job(job_id)} + + +def update_publish_jobs_target_batch(payload: PublishBatchTargetUpdate) -> dict: + jobs = [get_publish_job(job_id) for job_id in payload.job_ids] + if any(job is None for job in jobs): + raise ValueError("部分发布任务不存在") + platforms = {str(job.get("platform") or "") for job in jobs if job} + if len(platforms) != 1 or payload.platform not in platforms: + raise PublishPlatformIsolationBlocked("抖音和 B站任务不能混合操作,也不能批量改成另一个平台") + updated = [] + single = PublishJobTargetUpdate( + platform=payload.platform, + account_id=payload.account_id, + publish_mode=payload.publish_mode, + ) + for job_id in payload.job_ids: + updated.append(update_publish_job_target(job_id, single)["job"]) + return {"status": "ok", "updated_count": len(updated), "jobs": updated} + + def update_publish_job_content(job_id: str, payload: PublishJobContentUpdate) -> dict: job = get_publish_job(job_id) if not job: raise ValueError("publish job not found") - if job.get("status") == PUBLISH_STATUS_PUBLISHED: - raise ValueError("published jobs cannot be edited into the queue") + if job.get("status") not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED}: + raise ValueError("当前状态不能直接编辑;失败任务请先创建重试任务,需复核任务请先人工确认。") + + scheduled_at = (job.get("scheduled_at") or "").strip() + if (payload.scheduled_at or "").strip(): + from app.services.publish_time import ensure_future, to_utc_iso - scheduled_at = (payload.scheduled_at or job.get("scheduled_at") or "").strip() + ensure_future(payload.scheduled_at, settings.app_timezone) + scheduled_at = to_utc_iso(payload.scheduled_at, settings.app_timezone) status = PUBLISH_STATUS_SCHEDULED if scheduled_at else PUBLISH_STATUS_WAITING now = _now_iso() with get_connection() as connection: @@ -1763,8 +3230,11 @@ def _hashtags(tags: str) -> str: def _douyin_description_for_job(job: dict, fallback_title: str) -> str: - description = _sanitize_publish_description(job.get("description") or fallback_title, fallback_title) - tag_text = _hashtags(job.get("tags") or "") + description = _sanitize_publish_description( + job.get("description") or job.get("caption") or fallback_title, + fallback_title, + ) + tag_text = _hashtags(job.get("tags") or job.get("hashtags") or "") parts = [description] if description else [] if tag_text: parts.append(tag_text) @@ -1999,11 +3469,19 @@ def _douyin_verify_publish_ready_script(title: str, description: str) -> str: "if(expectedCompact&&(!actualCompact||(!actualCompact.includes(expectedCompact)&&!(bodyPiece&&actualCompact.includes(bodyPiece))))){throw new Error('douyin_description_missing_after_set');}" "const titleActual=titleValue();" "if(compact(expectedTitle)&&!compact(titleActual).includes(compact(expectedTitle).slice(0,12))){throw new Error('douyin_title_missing_after_set');}" - "const previewLabels=['预览视频','预览封面/标题','预览封面','平台投稿预览'];" - "const previewRoots=[...document.querySelectorAll('section,aside,div')].filter((el)=>visible(el)&&previewLabels.some((label)=>textOf(el).includes(label)));" - "const previewReady=previewRoots.some((root)=>[...root.querySelectorAll('img,video,canvas')].some(visible));" + "const busyMarkers=['文件解析中','正在上传','上传中','视频处理中','正在处理','转码中','等待上传','请等待上传完成','上传过程中请不要删除','上传过程中请勿删除'];" + "const explanatoryMarkers=['点击发布后','如作品还在上传中','上传发布完成','视频预览功能','实际播放时'];" + "const statusTexts=[...document.querySelectorAll('span,div,p')].filter(visible).map(textOf).filter((text)=>text&&text.length<=40&&!explanatoryMarkers.some((item)=>text.includes(item)));" + "const progress=[...document.querySelectorAll('span,div,p')].filter(visible).map(textOf).filter((text)=>/^\\d{1,3}%$/.test(text)).map((text)=>Number(text.slice(0,-1))).filter(Number.isFinite);" + "const stillBusy=busyMarkers.some((item)=>statusTexts.some((text)=>text===item||text.startsWith(`${item},`)||text.startsWith(`${item},`)||text.startsWith(`${item}:`)||text.startsWith(`${item}:`)||text.startsWith(`${item}...`)||text.startsWith(`${item}…`)))||progress.some((value)=>value<100);" + "const badImage=(src)=>/logo|avatar|favicon|icon|douyin-creator-logo|static\\/image/i.test(src||'');" + "const videos=[...document.querySelectorAll('video')].filter((el)=>visible(el)&&(el.videoWidth>0||el.readyState>=2||Number.isFinite(el.duration)));" + "const canvases=[...document.querySelectorAll('canvas')].filter((el)=>{const rect=el.getBoundingClientRect();return visible(el)&&el.width>=160&&el.height>=90&&rect.width>=120&&rect.height>=80;});" + "const images=[...document.querySelectorAll('img')].filter((el)=>{const rect=el.getBoundingClientRect();const src=el.currentSrc||el.src||'';return visible(el)&&!badImage(src)&&el.complete!==false&&el.naturalWidth>=240&&el.naturalHeight>=135&&rect.width>=120&&rect.height>=80;});" + "const previewCount=videos.length+canvases.length+images.length;" + "const previewReady=!stillBusy&&previewCount>0;" "if(!previewReady){throw new Error('douyin_preview_not_ready');}" - "return {publish_ready:true,title_checked:true,description_checked:true,preview_checked:true,description_length:actualDescription.length,preview_roots:previewRoots.length};" + "return {publish_ready:true,title_checked:true,description_checked:true,preview_checked:true,description_length:actualDescription.length,preview_count:previewCount};" "})()" ) @@ -2029,7 +3507,7 @@ def _douyin_click_publish_script() -> str: "const isMatch=(text)=>names.some((name)=>text===name||(text.includes(name)&&text.length<=12))&&!blocked.some((name)=>text.includes(name));" "const clickKnownTip=()=>{const tip=[...document.querySelectorAll('button,[role=\"button\"],div,span')].filter(visible).find((el)=>['我知道了','知道了'].includes(textOf(el)));if(tip){tip.click();return true;}return false;};" "const findButton=()=>{const seen=new Set();const candidates=[];for(const el of [...document.querySelectorAll('button,[role=\"button\"],a,div,span')]){const text=textOf(el);if(!text||!isMatch(text)){continue;}const clickable=el.closest('button,[role=\"button\"],a')||el;if(seen.has(clickable)||!visible(clickable)){continue;}seen.add(clickable);const rect=clickable.getBoundingClientRect();if(rect.left<180&&text.includes('发布')){continue;}const exact=text==='发布'?0:1;const tag=clickable.tagName==='BUTTON'?0:1;candidates.push({el:clickable,text,rect,score:exact*10+tag});}return candidates.sort((a,b)=>a.score-b.score||b.rect.top-a.rect.top||b.rect.left-a.rect.left)[0];};" - "const started=Date.now();let lastTexts=[];while(Date.now()-started<45000){clickKnownTip();let found=findButton();if(found){found.el.scrollIntoView({block:'center',inline:'center'});await sleep(500);found=findButton()||found;setTimeout(()=>found.el.click(),50);return {click_scheduled:true,text:found.text,waited_ms:Date.now()-started};}lastTexts=[...document.querySelectorAll('button,[role=\"button\"],a')].filter(visible).map(textOf).filter(Boolean).slice(-20);window.scrollTo({top:document.documentElement.scrollHeight||document.body.scrollHeight,behavior:'instant'});await sleep(1000);}" + "const started=Date.now();let lastTexts=[];while(Date.now()-started<45000){clickKnownTip();let found=findButton();if(found){found.el.scrollIntoView({block:'center',inline:'center'});await sleep(500);found=findButton()||found;found.el.click();await sleep(500);return {clicked:true,text:found.text,waited_ms:Date.now()-started};}lastTexts=[...document.querySelectorAll('button,[role=\"button\"],a')].filter(visible).map(textOf).filter(Boolean).slice(-20);window.scrollTo({top:document.documentElement.scrollHeight||document.body.scrollHeight,behavior:'instant'});await sleep(1000);}" "throw new Error('douyin_publish_button_not_found:'+lastTexts.join('|'));" "})()" ) @@ -2465,27 +3943,12 @@ def execute_opencli_send_job(job_id: str, runner: CommandRunner | None = None) - raise ValueError("发送任务不存在。") if job.get("publish_mode") != "opencli_publish": raise ValueError("只能执行 opencli 发送任务。") - if job.get("status") == PUBLISH_STATUS_PUBLISHED: - return {"status": "ok", "message": "这条任务已经标记为已发布。", "job": job} - runner = runner or _default_command_runner - with get_connection() as connection: - connection.execute( - """ - UPDATE publish_jobs - SET status = 'PUBLISHING', error_code = '', error_message = '', - provider_response = ?, updated_at = ? - WHERE id = ? - """, - (json.dumps({"opencli": "started"}, ensure_ascii=False), _now_iso(), job_id), - ) - connection.commit() try: - commands = _opencli_commands_for_job(get_publish_job(job_id)) + commands = _opencli_commands_for_job(job) except Exception as exc: - failed_job = _mark_job_failed(job_id, "prepare_failed", str(exc), {"stage": "prepare"}) - return {"status": "failed", "message": str(exc), "job": failed_job} + return {"status": "failed", "message": str(exc), "error_code": "prepare_failed", "job": job} outputs: list[dict[str, Any]] = [] for index, command in enumerate(commands, start=1): @@ -2495,12 +3958,10 @@ def execute_opencli_send_job(job_id: str, runner: CommandRunner | None = None) - result = runner(command) except subprocess.TimeoutExpired: message = f"opencli 第 {index} 步超时:{_command_summary(command)}" - failed_job = _mark_job_failed(job_id, "opencli_timeout", message, {"outputs": outputs}) - return {"status": "failed", "message": message, "job": failed_job} + return {"status": "failed", "message": message, "error_code": "opencli_timeout", "outputs": outputs, "job": job} except Exception as exc: message = f"opencli 第 {index} 步启动失败:{exc}" - failed_job = _mark_job_failed(job_id, "opencli_start_failed", message, {"outputs": outputs}) - return {"status": "failed", "message": message, "job": failed_job} + return {"status": "failed", "message": message, "error_code": "opencli_start_failed", "outputs": outputs, "job": job} output = { "step": index, @@ -2518,8 +3979,7 @@ def execute_opencli_send_job(job_id: str, runner: CommandRunner | None = None) - attempt += 1 continue message = output["stderr"] or output["stdout"] or f"opencli 第 {index} 步失败" - failed_job = _mark_job_failed(job_id, "opencli_failed", message[:1000], {"outputs": outputs}) - return {"status": "failed", "message": message, "job": failed_job} + return {"status": "failed", "message": message, "error_code": "opencli_failed", "outputs": outputs, "job": job} cleanup_outputs: list[dict[str, Any]] = [] for command in _opencli_cleanup_commands_for_job(get_publish_job(job_id)): @@ -2551,87 +4011,30 @@ def execute_opencli_send_job(job_id: str, runner: CommandRunner | None = None) - "cleanup_outputs": cleanup_outputs, "completed_at": now, } - with get_connection() as connection: - connection.execute( - """ - UPDATE publish_jobs - SET status = 'PUBLISHED', audit_status = 'submitted', - error_code = '', error_message = '', provider_response = ?, updated_at = ? - WHERE id = ? - """, - (json.dumps(response, ensure_ascii=False), now, job_id), - ) - connection.commit() - return {"status": "ok", "message": "opencli 发送流程已执行完成。", "job": get_publish_job(job_id)} - - -def _ready_opencli_job_ids(job_ids: list[str] | None = None) -> list[str]: - params: list[str] = [] - where = "publish_mode = 'opencli_publish' AND status IN ('WAITING', 'FAILED', 'ready', 'failed')" - if job_ids: - placeholders = ",".join("?" for _ in job_ids) - where += f" AND id IN ({placeholders})" - params.extend(job_ids) - with get_connection() as connection: - rows = connection.execute( - f"SELECT id FROM publish_jobs WHERE {where} ORDER BY created_at ASC", - params, - ).fetchall() - return [row["id"] for row in rows] - + return { + "status": "ok", + "confirmed": True, + "message": "opencli 已完成平台结果确认步骤。", + "provider_response": response, + "job": job, + } -def run_opencli_send_batch(job_ids: list[str] | None = None, runner: CommandRunner | None = None) -> dict: - if not _SEND_LOCK.acquire(blocking=False): - return {"status": "busy", "message": "发送队列正在运行,请等待当前批次结束。", "jobs": list_publish_jobs(limit=100)} - try: - ids = _ready_opencli_job_ids(job_ids) - results = [execute_opencli_send_job(job_id, runner=runner) for job_id in ids] - return {"status": "ok", "message": f"发送批次已处理 {len(results)} 条任务。", "results": results, **get_publish_center_context()} - finally: - _SEND_LOCK.release() +def retry_publish_job(job_id: str) -> dict: + job = get_publish_job(job_id) + if not job: + raise ValueError("发布任务不存在。") + from app.services.publish_scheduler import PublishScheduler -def start_opencli_send_batch(payload: PublishSendStart, background_tasks: Any | None = None) -> dict: - ids = _ready_opencli_job_ids(payload.job_ids) - if not ids: - return {"status": "empty", "message": "当前没有待发送或失败可重试的任务。", **get_publish_center_context()} - if _SEND_LOCK.locked(): - return {"status": "busy", "message": "发送队列正在运行,请稍后刷新查看进度。", **get_publish_center_context()} - opencli_status = _opencli_status() - if not opencli_status["available"]: - return { - "status": "missing_opencli", - "message": ( - f"{opencli_status['message']} 如果你刚安装或更新过 opencli," - f"请运行 {opencli_status['restart_command']} 重启 Windows 本地后台后再试。" - ), - **get_publish_center_context(), - } - if background_tasks is not None: - background_tasks.add_task(run_opencli_send_batch, ids) - return {"status": "started", "message": f"已开始后台发送 {len(ids)} 条任务。", **get_publish_center_context()} - return run_opencli_send_batch(ids) + return PublishScheduler().retry_failed(job_id) -def retry_publish_job(job_id: str) -> dict: +def execute_api_publish_job(job_id: str) -> dict: job = get_publish_job(job_id) if not job: raise ValueError("发布任务不存在。") - if ( - job.get("platform") == "manual_export" - or job.get("publish_mode") == "manual_export" - or ( - job.get("status") == PUBLISH_STATUS_FAILED - and settings.publish_scheduler_default_platform == "manual_export" - ) - ): - from app.services.publish_scheduler import PublishScheduler - - return PublishScheduler().retry_failed(job_id) - if job.get("publish_mode") == "opencli_publish": - return execute_opencli_send_job(job_id) if job.get("publish_mode") != "api_publish": - raise ValueError("只有真实接口发布任务可以重试。") + raise ValueError("只能执行 api_publish 任务。") output_clip = _get_output_clip_for_publish(job["task_id"], job["output_clip_id"]) if not output_clip: raise ValueError("切片记录不存在。") @@ -2640,17 +4043,6 @@ def retry_publish_job(job_id: str) -> dict: account = get_account(job.get("account_id") or "") if not config or not account: raise ValueError("平台配置或账号不存在。") - with get_connection() as connection: - connection.execute( - """ - UPDATE publish_jobs - SET status = 'PUBLISHING', retry_count = retry_count + 1, - error_code = '', error_message = '', updated_at = ? - WHERE id = ? - """, - (_now_iso(), job_id), - ) - connection.commit() return _execute_publish_job(job_id, config=config, account=account, video_path=video_path) @@ -2663,45 +4055,23 @@ def _execute_publish_job(job_id: str, config: dict, account: dict, video_path: P try: result = provider.publish(account, job, video_path) except PublishProviderError as exc: - with get_connection() as connection: - connection.execute( - """ - UPDATE publish_jobs - SET status = 'FAILED', audit_status = 'not_submitted', - error_code = ?, error_message = ?, provider_response = ?, updated_at = ? - WHERE id = ? - """, - ( - exc.error_code, - exc.message, - json.dumps(exc.response, ensure_ascii=False), - now, - job_id, - ), - ) - connection.commit() - return {"status": "failed", "message": exc.message, "job": get_publish_job(job_id)} + return { + "status": "failed", + "message": exc.message, + "error_code": exc.error_code, + "provider_response": exc.response, + "job": job, + } - with get_connection() as connection: - connection.execute( - """ - UPDATE publish_jobs - SET status = 'PUBLISHED', audit_status = ?, platform_item_id = ?, - platform_upload_id = ?, error_code = '', error_message = '', - provider_response = ?, updated_at = ? - WHERE id = ? - """, - ( - result.audit_status, - result.item_id, - result.upload_id, - json.dumps(result.response or {}, ensure_ascii=False), - now, - job_id, - ), - ) - connection.commit() - return {"status": "ok", "message": "平台发布请求已提交。", "job": get_publish_job(job_id)} + return { + "status": "ok", + "message": "平台发布请求已提交。", + "remote_video_id": result.item_id, + "platform_upload_id": result.upload_id, + "published_at": now, + "provider_response": result.response or {}, + "job": job, + } def _get_provider(platform: str, config: dict): @@ -2712,12 +4082,12 @@ def _get_provider(platform: str, config: dict): raise PublishProviderError("暂不支持这个发布平台。", "unsupported_platform") -def get_publish_center_context() -> dict: +def get_publish_center_context(*, focus_task_id: str = "") -> dict: publish_items = [] queue_items = [] raw_items = _list_completed_publish_clips() output_clip_ids = [item["output_clip_id"] for item in raw_items] - opencli_jobs_map = _batch_find_opencli_jobs(output_clip_ids) + publish_jobs_map = _batch_find_publish_jobs(output_clip_ids) for item in raw_items: original_path = resolve_video_file_path(item.get("output_file_path") or "") subtitled_path = resolve_video_file_path(item.get("subtitled_output_file_path") or "") @@ -2734,7 +4104,7 @@ def get_publish_center_context() -> dict: "video_media_url": _video_media_url(item["task_id"], item["output_clip_id"], "original"), } publish_items.append(normalized_item) - jobs_for_oc = opencli_jobs_map.get(item["output_clip_id"], {}) + jobs_for_oc = publish_jobs_map.get(item["output_clip_id"], {}) for platform in PLATFORM_LABELS: job = jobs_for_oc.get(platform) if job: @@ -2778,7 +4148,25 @@ def get_publish_center_context() -> dict: } ) - jobs = list_publish_jobs(limit=200) + from app.services.publish_scheduler import scheduler_health + + current_scheduler_health = scheduler_health() + jobs = list_publish_jobs( + limit=None if focus_task_id else 200, + worker_state=current_scheduler_health, + ) + pending_jobs = [ + job for job in jobs + if job.get("status") in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_FAILED, PUBLISH_STATUS_NEED_REVIEW} + ] + scheduled_jobs = sorted( + [job for job in jobs if job.get("status") in {PUBLISH_STATUS_SCHEDULED, PUBLISH_STATUS_PUBLISHING}], + key=lambda job: (job.get("scheduled_at") or "", job.get("created_at") or ""), + ) + history_jobs = [ + job for job in jobs + if job.get("status") in {PUBLISH_STATUS_PUBLISHED, PUBLISH_STATUS_EXPORTED, PUBLISH_STATUS_FAILED, PUBLISH_STATUS_CANCELLED} + ] jobs_by_platform = { platform: [job for job in jobs if job["platform"] == platform] for platform in PLATFORM_LABELS @@ -2788,15 +4176,40 @@ def get_publish_center_context() -> dict: published_count = sum(1 for job in jobs if job.get("status") == PUBLISH_STATUS_PUBLISHED) failed_count = sum(1 for job in jobs if job.get("status") == PUBLISH_STATUS_FAILED) need_review_count = sum(1 for job in jobs if job.get("status") == PUBLISH_STATUS_NEED_REVIEW) + missing_cover_counts = { + platform: sum( + 1 + for job in jobs + if job.get("platform") == platform + and job.get("status") in { + PUBLISH_STATUS_DRAFT, + PUBLISH_STATUS_WAITING, + PUBLISH_STATUS_SCHEDULED, + } + and job.get("output_is_active") is not False + and not str(job.get("cover_file_path") or "").strip() + ) + for platform in PLATFORM_LABELS + } + missing_cover_count = sum(missing_cover_counts.values()) opencli_status = _opencli_status() return { "publish_items": publish_items, "send_queue_items": queue_items, "publish_jobs": jobs, + "publish_task_groups": _build_publish_task_groups(jobs), + "pending_jobs": pending_jobs, + "scheduled_jobs": scheduled_jobs, + "history_jobs": history_jobs, + "missing_cover_count": missing_cover_count, + "missing_cover_counts": missing_cover_counts, "jobs_by_platform": jobs_by_platform, "platforms": [{"id": platform, "label": label} for platform, label in PLATFORM_LABELS.items()], + "accounts": list_accounts(), + "app_timezone": settings.app_timezone, "opencli_available": opencli_status["available"], "opencli_status": opencli_status, + "scheduler_health": current_scheduler_health, "stats": [ {"label": "需复核", "value": need_review_count, "tone": "amber"}, {"label": "可入队切片", "value": len(publish_items), "tone": "green"}, diff --git a/app/services/publish_time.py b/app/services/publish_time.py new file mode 100644 index 0000000..3da9274 --- /dev/null +++ b/app/services/publish_time.py @@ -0,0 +1,133 @@ +"""统一处理发布排期时间。 + +数据库统一保存带时区的 UTC ISO 8601;没有时区的前端时间始终按 APP_TIMEZONE +解释,避免依赖 Windows 或浏览器所在机器的隐式时区。 +""" + +from __future__ import annotations + +from datetime import datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from app.core.config import settings + + +def app_zone(timezone_name: str | None = None) -> ZoneInfo: + name = (timezone_name or settings.app_timezone or "Asia/Shanghai").strip() + try: + return ZoneInfo(name) + except ZoneInfoNotFoundError as exc: + raise ValueError(f"无效时区:{name}") from exc + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def utc_now_iso() -> str: + return utc_now().isoformat(timespec="seconds") + + +def parse_datetime(value: str | None, timezone_name: str | None = None) -> datetime: + text = str(value or "").strip() + if not text: + raise ValueError("发布时间不能为空") + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("发布时间格式无效") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=app_zone(timezone_name)) + return parsed + + +def to_utc_iso(value: str | datetime, timezone_name: str | None = None) -> str: + parsed = parse_datetime(value, timezone_name) if isinstance(value, str) else value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=app_zone(timezone_name)) + return parsed.astimezone(timezone.utc).isoformat(timespec="seconds") + + +def ensure_future(value: str | datetime, timezone_name: str | None = None) -> datetime: + parsed = parse_datetime(value, timezone_name) if isinstance(value, str) else value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=app_zone(timezone_name)) + if parsed <= utc_now(): + raise ValueError("发布时间必须晚于当前时间(北京时间)") + return parsed + + +def local_display(value: str | datetime | None, timezone_name: str | None = None) -> str: + if not value: + return "" + parsed = parse_datetime(value, timezone_name) if isinstance(value, str) else value + return parsed.astimezone(app_zone(timezone_name)).strftime("%Y-%m-%d %H:%M") + + +def parse_clock(value: str, label: str) -> time: + try: + parsed = time.fromisoformat(str(value or "").strip()) + except ValueError as exc: + raise ValueError(f"{label}格式无效,请使用 HH:MM") from exc + return parsed.replace(second=0, microsecond=0) + + +def _next_allowed_schedule_time(cursor: datetime, window_start: time, window_end: time) -> datetime: + """把后续发布时间顺延到每日允许时段,支持跨午夜窗口。""" + if window_start == window_end: + return cursor + + cursor_clock = cursor.time() + if window_end > window_start: + day_start = datetime.combine(cursor.date(), window_start, tzinfo=cursor.tzinfo) + day_end = datetime.combine(cursor.date(), window_end, tzinfo=cursor.tzinfo) + if cursor < day_start: + return day_start + if cursor <= day_end: + return cursor + return datetime.combine(cursor.date() + timedelta(days=1), window_start, tzinfo=cursor.tzinfo) + + if cursor_clock >= window_start or cursor_clock <= window_end: + return cursor + return datetime.combine(cursor.date(), window_start, tzinfo=cursor.tzinfo) + + +def next_allowed_schedule_time( + cursor: datetime, + *, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", +) -> datetime: + """把一个候选时间顺延到每日允许发布时段。""" + window_start = parse_clock(daily_start_time, "每日开始时间") + window_end = parse_clock(daily_end_time, "每日结束时间") + return _next_allowed_schedule_time(cursor, window_start, window_end) + + +def build_schedule_times( + count: int, + *, + start_at_local: str, + timezone_name: str | None = None, + interval_minutes: int = 180, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", + reject_past: bool = True, +) -> list[str]: + if count <= 0: + return [] + zone = app_zone(timezone_name) + cursor = parse_datetime(start_at_local, timezone_name).astimezone(zone) + if reject_past and cursor <= utc_now().astimezone(zone): + raise ValueError("排期起始时间必须晚于当前时间(北京时间)") + + interval = timedelta(minutes=max(1, int(interval_minutes))) + window_start = parse_clock(daily_start_time, "每日开始时间") + window_end = parse_clock(daily_end_time, "每日结束时间") + + result = [to_utc_iso(cursor)] + while len(result) < count: + cursor += interval + cursor = _next_allowed_schedule_time(cursor, window_start, window_end) + result.append(to_utc_iso(cursor)) + return result diff --git a/app/services/publishers/__init__.py b/app/services/publishers/__init__.py new file mode 100644 index 0000000..390d1bf --- /dev/null +++ b/app/services/publishers/__init__.py @@ -0,0 +1,26 @@ +"""牛马片场统一 Publisher 包。""" + +from app.services.publishers.base import ( + BasePlatformPublisher, + BasePublisher, + PublishError, + PublishNeedsReview, + PublishOutcome, + PublishResult, + PublishValidationError, + PublishWorkerUnavailable, +) +from app.services.publishers.registry import get_publisher, register_publisher + +__all__ = [ + "BasePlatformPublisher", + "BasePublisher", + "PublishError", + "PublishNeedsReview", + "PublishOutcome", + "PublishResult", + "PublishValidationError", + "PublishWorkerUnavailable", + "get_publisher", + "register_publisher", +] diff --git a/app/services/publishers/api_compat.py b/app/services/publishers/api_compat.py new file mode 100644 index 0000000..cd815e9 --- /dev/null +++ b/app/services/publishers/api_compat.py @@ -0,0 +1,35 @@ +"""现有平台 API 发布方式的显式兼容入口。""" + +from __future__ import annotations + +from typing import Any + +from app.services.publishers.base import BasePublisher, PublishOutcome, PublishResult + + +class ApiCompatPublisher(BasePublisher): + name = "api_publish" + + def __init__(self, **_: Any) -> None: + pass + + def publish(self, job: dict[str, Any]) -> PublishResult: + self.validate(job) + from app.services import publish_service + + response = publish_service.execute_api_publish_job(str(job.get("id") or "")) + if response.get("status") == "ok": + return PublishResult( + outcome=PublishOutcome.PUBLISHED, + message=str(response.get("message") or "平台 API 已提交"), + remote_video_id=str(response.get("remote_video_id") or ""), + platform_url=str(response.get("platform_url") or ""), + published_at=str(response.get("published_at") or ""), + provider_response=response.get("provider_response") or response, + ) + return PublishResult( + outcome=PublishOutcome.FAILED, + message=str(response.get("message") or "平台 API 发布失败"), + error_code="api_publish_failed", + provider_response=response, + ) diff --git a/app/services/publishers/base.py b/app/services/publishers/base.py new file mode 100644 index 0000000..e9dd511 --- /dev/null +++ b/app/services/publishers/base.py @@ -0,0 +1,243 @@ +"""Publisher 的稳定输入、输出与异常类型。""" + +from __future__ import annotations + +import json +import re +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +from app.services.publish_time import utc_now_iso +from app.services.storage_service import resolve_video_file_path + + +class PublishOutcome(str, Enum): + PUBLISHED = "PUBLISHED" + EXPORTED = "EXPORTED" + FAILED = "FAILED" + NEED_REVIEW = "NEED_REVIEW" + + +class PublishError(RuntimeError): + def __init__( + self, + message: str, + error_code: str = "publish_failed", + *, + needs_manual_review: bool = False, + safe_to_retry: bool = False, + ) -> None: + super().__init__(message) + self.message = message + self.error_code = error_code + self.needs_manual_review = needs_manual_review + self.safe_to_retry = safe_to_retry + + +class PublishValidationError(PublishError, ValueError): + pass + + +class PublishWorkerUnavailable(PublishError): + def __init__( + self, + message: str = "Windows 发布 Worker 当前不可用", + *, + request_may_have_been_received: bool = False, + ) -> None: + super().__init__(message, "publish_worker_unavailable", safe_to_retry=True) + self.request_may_have_been_received = request_may_have_been_received + + +class PublishNeedsReview(PublishError): + def __init__(self, message: str, error_code: str = "manual_review_required") -> None: + super().__init__(message, error_code, needs_manual_review=True) + + +@dataclass(frozen=True) +class PublishResult: + outcome: PublishOutcome + message: str = "" + remote_video_id: str = "" + platform_url: str = "" + published_at: str = "" + provider_response: dict[str, Any] = field(default_factory=dict) + error_code: str = "" + needs_manual_review: bool = False + + @property + def ok(self) -> bool: + return self.outcome in {PublishOutcome.PUBLISHED, PublishOutcome.EXPORTED} + + @property + def payload(self) -> dict[str, Any]: + """兼容 v1.4 调用方。""" + return self.provider_response + + def as_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload["outcome"] = self.outcome.value + return payload + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "PublishResult": + raw_outcome = str(payload.get("outcome") or payload.get("status") or "FAILED").upper() + aliases = {"SUCCESS": "PUBLISHED", "PUBLISHED": "PUBLISHED", "EXPORTED": "EXPORTED"} + outcome = PublishOutcome(aliases.get(raw_outcome, raw_outcome)) + return cls( + outcome=outcome, + message=str(payload.get("message") or ""), + remote_video_id=str(payload.get("remote_video_id") or ""), + platform_url=str(payload.get("platform_url") or ""), + published_at=str(payload.get("published_at") or ""), + provider_response=sanitize_provider_response(payload.get("provider_response") or payload), + error_code=str(payload.get("error_code") or ""), + needs_manual_review=bool(payload.get("needs_manual_review")) or outcome == PublishOutcome.NEED_REVIEW, + ) + + +_SENSITIVE_KEYS = { + "access_token", "authorization", "cookie", "cookies", "password", "refresh_token", + "secret", "storage_state", "token", "client_secret", +} + + +def sanitize_provider_response(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): "[REDACTED]" if str(key).lower() in _SENSITIVE_KEYS else sanitize_provider_response(item) + for key, item in value.items() + } + if isinstance(value, list): + return [sanitize_provider_response(item) for item in value] + if isinstance(value, str) and len(value) > 20000: + return value[:20000] + "…" + return value + + +def parse_json_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if not value: + return {} + try: + parsed = json.loads(str(value)) + except (json.JSONDecodeError, TypeError): + return {"raw": str(value)} + return parsed if isinstance(parsed, dict) else {"data": parsed} + + +def job_video_path(job: dict[str, Any]) -> Path: + raw_path = str(job.get("video_path") or job.get("video_file_path") or "").strip() + if not raw_path: + raise PublishValidationError("视频文件路径为空", "missing_video_path") + resolved = resolve_video_file_path(raw_path) or Path(raw_path).expanduser() + if not resolved.exists() or not resolved.is_file(): + raise PublishValidationError(f"视频文件不存在:{raw_path}", "video_not_found") + if resolved.suffix.lower() not in {".mp4", ".mov", ".mkv", ".avi", ".flv", ".webm", ".m4v"}: + raise PublishValidationError("不支持的视频格式", "unsupported_video_format") + return resolved.resolve() + + +def job_caption(job: dict[str, Any]) -> str: + return str(job.get("caption") or job.get("description") or "").strip() + + +def job_hashtags(job: dict[str, Any]) -> str: + return str(job.get("hashtags") or job.get("tags") or "").strip() + + +def split_hashtags(value: str) -> list[str]: + return [part for part in re.split(r"[,,\s#]+", str(value or "")) if part] + + +def job_cover_path(job: dict[str, Any], *, required: bool = False) -> Path | None: + raw_path = str(job.get("cover_file_path") or "").strip() + if not raw_path: + if required: + raise PublishValidationError("请选择或生成发布封面", "missing_cover") + return None + resolved = resolve_video_file_path(raw_path) or Path(raw_path).expanduser() + if not resolved.exists() or not resolved.is_file(): + raise PublishValidationError(f"封面文件不存在:{raw_path}", "cover_not_found") + if resolved.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}: + raise PublishValidationError("封面必须是 JPG、PNG 或 WebP 图片", "unsupported_cover_format") + return resolved.resolve() + + +class BasePublisher(ABC): + name = "base" + + def validate(self, job: dict[str, Any]) -> None: + job_video_path(job) + if not str(job.get("title") or "").strip(): + raise PublishValidationError("标题不能为空", "missing_title") + if not job_caption(job): + raise PublishValidationError("正文或简介不能为空", "missing_caption") + + def build_payload(self, job: dict[str, Any]) -> dict[str, Any]: + cover_path = job_cover_path(job) + return { + "job_id": str(job.get("id") or ""), + "execution_id": str(job.get("execution_id") or ""), + "task_id": str(job.get("task_id") or ""), + "clip_id": str(job.get("clip_id") or job.get("output_clip_id") or ""), + "platform": str(job.get("platform") or ""), + "account_id": str(job.get("account_id") or ""), + "scheduled_at": str(job.get("scheduled_at") or ""), + "title": str(job.get("title") or "").strip(), + "caption": job_caption(job), + "hashtags": job_hashtags(job), + "video_path": str(job_video_path(job)), + "cover_file_path": str(cover_path or ""), + "visibility": str(job.get("visibility") or "public"), + "allow_download": bool(job.get("allow_download", True)), + "bilibili_tid": str(job.get("bilibili_tid") or ""), + "bilibili_copyright": str(job.get("bilibili_copyright") or "original"), + "bilibili_source": str(job.get("bilibili_source") or ""), + "publisher": self.name, + } + + @abstractmethod + def publish(self, job: dict[str, Any]) -> PublishResult: + raise NotImplementedError + + +class BasePlatformPublisher(BasePublisher): + platform = "" + creator_url = "" + + def validate(self, job: dict[str, Any]) -> None: + super().validate(job) + if str(job.get("platform") or "").lower() != self.platform: + raise PublishValidationError("Publisher 与任务平台不匹配", "platform_mismatch") + if not str(job.get("account_id") or "").strip(): + raise PublishValidationError("请选择发布账号", "missing_account") + + @abstractmethod + def check_login(self, account_id: str) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def open_login(self, account_id: str) -> dict[str, Any]: + raise NotImplementedError + + def published_result( + self, + *, + message: str, + remote_video_id: str = "", + platform_url: str = "", + provider_response: dict[str, Any] | None = None, + ) -> PublishResult: + return PublishResult( + outcome=PublishOutcome.PUBLISHED, + message=message, + remote_video_id=remote_video_id, + platform_url=platform_url, + published_at=utc_now_iso(), + provider_response=provider_response or {}, + ) diff --git a/app/services/publishers/bilibili.py b/app/services/publishers/bilibili.py new file mode 100644 index 0000000..dec1dc7 --- /dev/null +++ b/app/services/publishers/bilibili.py @@ -0,0 +1,222 @@ +"""B站创作中心 Playwright Publisher。""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from app.services.publish_time import utc_now_iso +from app.services.publishers.base import ( + BasePlatformPublisher, + PublishError, + PublishNeedsReview, + PublishOutcome, + PublishResult, + PublishValidationError, + job_caption, + job_hashtags, + split_hashtags, +) +from app.services.publishers.browser_runtime import BrowserRuntime +from app.services.publishers import page_scripts + + +class BilibiliPublisher(BasePlatformPublisher): + name = "bilibili" + platform = "bilibili" + creator_url = "https://member.bilibili.com/platform/upload/video/frame" + + def __init__(self, *, runtime: BrowserRuntime | None = None, account_id: str = "", **_: Any) -> None: + self.runtime = runtime or BrowserRuntime(self.platform, account_id) + + @classmethod + def validate_job_data(cls, job: dict[str, Any]) -> None: + title = str(job.get("title") or "").strip() + if not title: + raise PublishValidationError("B站标题不能为空", "missing_title") + if len(title) > 80: + raise PublishValidationError("B站标题不能超过 80 个字符", "bilibili_title_too_long") + if not job_caption(job): + raise PublishValidationError("B站简介不能为空", "missing_caption") + if not job_hashtags(job): + raise PublishValidationError("B站标签不能为空", "missing_hashtags") + if not str(job.get("cover_file_path") or "").strip(): + raise PublishValidationError("请选择或生成 B站封面", "missing_cover") + if not str(job.get("bilibili_tid") or "").strip(): + raise PublishValidationError("请选择 B站分区", "missing_bilibili_tid") + copyright_value = str(job.get("bilibili_copyright") or "original") + if copyright_value not in {"original", "repost"}: + raise PublishValidationError("B站稿件类型必须是原创或转载", "invalid_bilibili_copyright") + if copyright_value == "repost" and not str(job.get("bilibili_source") or "").strip(): + raise PublishValidationError("转载稿件必须填写转载来源", "missing_bilibili_source") + + def validate(self, job: dict[str, Any]) -> None: + super().validate(job) + self.validate_job_data(job) + + def check_login(self, account_id: str) -> dict[str, Any]: + with self.runtime.page(self.creator_url) as page: + time.sleep(2) + text = self.runtime.body_text(page) + login_required = "扫码登录" in text or "密码登录" in text or "passport.bilibili.com" in page.url + normal = not login_required and ( + page.locator('input[type="file"]').count() > 0 or "上传视频" in text or "点击上传" in text + ) + return { + "login_status": "normal" if normal else "login_required", + "message": "登录状态正常" if normal else "B站账号需要重新登录", + } + + def open_login(self, account_id: str) -> dict[str, Any]: + with self.runtime.page(self.creator_url) as page: + deadline = time.monotonic() + 600 + while time.monotonic() < deadline: + text = self.runtime.body_text(page) + if "上传视频" in text or "点击上传" in text or page.locator('input[type="file"]').count() > 0: + return {"login_status": "normal", "message": "B站登录成功"} + time.sleep(2) + return {"login_status": "login_required", "message": "等待登录超时,请重新打开登录窗口"} + + def publish(self, job: dict[str, Any]) -> PublishResult: + self.validate(job) + payload = self.build_payload(job) + submitted = False + with self.runtime.page(self.creator_url) as page: + try: + self.runtime.detect_manual_challenge(page) + if not self._page_logged_in(page): + raise PublishNeedsReview("B站账号登录失效,请重新登录", "account_login_required") + self.runtime.evaluate_script( + page, + page_scripts.bilibili_dismiss_local_draft(), + phase="local_draft_prompt_checked", + ) + upload = self.runtime.first_visible(page, ('input[type="file"]',), timeout_ms=5000) + if upload is None: + raise PublishError("未找到 B站视频上传入口", "platform_form_changed") + self.runtime.phase("upload_started", {"video": Path(payload["video_path"]).name}) + upload.set_input_files(payload["video_path"]) + self.runtime.evaluate_script( + page, + page_scripts.bilibili_wait_uploaded(), + phase="upload_completion_checked", + default_error_code="video_upload_timeout", + ) + self.runtime.phase("upload_completed", None) + self.runtime.evaluate_script( + page, + page_scripts.bilibili_select_recommended_cover(), + phase="recommended_cover_selected", + default_error_code="bilibili_cover_not_ready", + ) + self.runtime.evaluate_script( + page, page_scripts.fill_title(payload["title"]), phase="title_filled" + ) + if payload["bilibili_copyright"] == "repost": + self._set_copyright(page, payload["bilibili_copyright"], payload["bilibili_source"]) + self.runtime.evaluate_script( + page, + page_scripts.bilibili_select_declaration(), + phase="declaration_selected", + ) + self.runtime.evaluate_script( + page, + page_scripts.bilibili_select_category(payload["bilibili_tid"]), + phase="category_checked", + ) + self.runtime.evaluate_script( + page, + page_scripts.bilibili_set_description(payload["caption"]), + phase="description_filled", + ) + self.runtime.evaluate_script( + page, + page_scripts.bilibili_verify_ready(payload["title"], payload["caption"]), + phase="form_verified_before_submit", + ) + self.runtime.detect_manual_challenge(page) + + self.runtime.phase("submit_clicked", None) + submitted = True + click_result = self.runtime.evaluate_script( + page, + page_scripts.bilibili_click_publish(), + phase="precise_publish_clicked", + default_error_code="bilibili_publish_button_not_found", + ) + confirmation = self.runtime.evaluate_script( + page, + page_scripts.bilibili_wait_result(payload["title"]), + phase="publish_result_checked", + default_error_code="bilibili_publish_not_confirmed", + ) + if not confirmation.get("bilibili_publish_confirmed"): + raise PublishNeedsReview( + "B站没有返回可验证的投稿成功证据,请在创作中心核对", + "publish_result_uncertain", + ) + platform_url = self.runtime.extract_link(page, ("/video/BV", "member.bilibili.com/platform/upload-manager")) + confirmed_url = str(confirmation.get("url") or page.url or "") + if not platform_url and any(marker in confirmed_url for marker in ("upload-manager", "archive", "content")): + platform_url = confirmed_url + remote_id = self.runtime.extract_remote_id(platform_url) + self.runtime.phase("confirmed_success", {"platform_url": platform_url, "remote_video_id": remote_id}) + return PublishResult( + outcome=PublishOutcome.PUBLISHED, + message="B站投稿成功", + remote_video_id=remote_id, + platform_url=platform_url, + published_at=utc_now_iso(), + provider_response={ + "click": click_result, + "confirmation": confirmation, + "final_url": confirmed_url, + "default_tags_kept": True, + }, + ) + except PublishNeedsReview: + raise + except Exception as exc: + self.runtime.screenshot(page, "bilibili-publish-error") + if submitted: + raise PublishNeedsReview( + f"B站投稿结果不确定,请人工确认。{exc}", + "publish_result_uncertain", + ) from exc + if isinstance(exc, PublishError): + raise + raise PublishError(str(exc), "bilibili_publish_failed") from exc + + def _page_logged_in(self, page: Any) -> bool: + text = self.runtime.body_text(page) + return not ("扫码登录" in text or "密码登录" in text or "passport.bilibili.com" in page.url) + + def _set_tags(self, page: Any, value: str) -> None: + tags = split_hashtags(value) + tag_input = self.runtime.first_visible(page, ('input[placeholder*="标签"]', 'input[placeholder*="按回车键Enter创建标签"]')) + if tag_input is None: + return + for tag in tags[:10]: + tag_input.fill(tag) + tag_input.press("Enter") + + def _set_copyright(self, page: Any, value: str, source: str) -> None: + label = "转载" if value == "repost" else "自制" + self.runtime.click_first(page, (f'text="{label}"', f'label:has-text("{label}")'), required=False) + if value == "repost": + self.runtime.fill_first(page, ('input[placeholder*="转载来源"]', 'input[placeholder*="来源"]'), source) + + def _set_partition(self, page: Any, tid: str) -> None: + if not self.runtime.click_first(page, ('text="选择分区"', '[class*="select"]:has-text("分区")'), required=False): + return + self.runtime.click_first(page, (f'text="{tid}"',), required=False) + + def _set_cover(self, page: Any, cover_path: str) -> None: + if not cover_path or not Path(cover_path).is_file(): + return + self.runtime.click_first(page, ('text="上传封面"', 'button:has-text("上传封面")'), required=False) + cover = self.runtime.first_visible(page, ('input[type="file"][accept*="image"]',), timeout_ms=3000) + if cover is not None: + cover.set_input_files(cover_path) + self.runtime.click_first(page, ('button:has-text("完成")', 'button:has-text("确定")'), required=False) diff --git a/app/services/publishers/browser_runtime.py b/app/services/publishers/browser_runtime.py new file mode 100644 index 0000000..2fcca52 --- /dev/null +++ b/app/services/publishers/browser_runtime.py @@ -0,0 +1,321 @@ +"""Playwright 持久化 Chrome 上下文和通用页面操作。 + +此模块只会由 Windows 发布 Worker 实际调用;FastAPI/Docker 调度进程不会启动浏览器。 +""" + +from __future__ import annotations + +import re +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, Iterator, Sequence + +from app.core.config import settings +from app.services.publishers.base import PublishError, PublishNeedsReview + + +PhaseCallback = Callable[[str, dict[str, Any] | None], None] + + +class BrowserRuntime: + def __init__( + self, + platform: str, + account_id: str, + *, + phase_callback: PhaseCallback | None = None, + ) -> None: + self.platform = platform + self.account_id = account_id + self.phase_callback = phase_callback or (lambda _phase, _details=None: None) + self.profile_dir = Path(settings.publish_browser_profile_dir) / platform / account_id + self.artifact_dir = Path(settings.publish_browser_artifact_dir) / platform / account_id + + def phase(self, phase: str, details: dict[str, Any] | None = None) -> None: + self.phase_callback(phase, details) + + @contextmanager + def page(self, url: str) -> Iterator[Any]: + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: + raise PublishError( + "Windows 发布 Worker 未安装 Playwright,请执行 pip install -r requirements.txt", + "playwright_not_installed", + ) from exc + + self.profile_dir.mkdir(parents=True, exist_ok=True) + self.artifact_dir.mkdir(parents=True, exist_ok=True) + self.phase("browser_opening", {"url": url}) + with sync_playwright() as playwright: + launch_options: dict[str, Any] = { + "user_data_dir": str(self.profile_dir), + "headless": bool(settings.publish_browser_headless), + "locale": "zh-CN", + "timezone_id": settings.app_timezone, + "viewport": {"width": 1440, "height": 960}, + } + channel = str(settings.publish_browser_channel or "chrome").strip() + if channel: + launch_options["channel"] = channel + context = playwright.chromium.launch_persistent_context(**launch_options) + try: + page = context.pages[0] if context.pages else context.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=settings.publish_browser_navigation_timeout_ms) + self.phase("browser_opened", {"url": page.url}) + yield page + finally: + try: + context.close() + except Exception: + # 用户可能在人工保留期间提前关闭窗口;清理动作必须幂等, + # 不能用“浏览器已经关闭”覆盖真正的上传/验证错误。 + pass + + def first_visible(self, page: Any, selectors: Sequence[str], timeout_ms: int = 1500) -> Any | None: + for selector in selectors: + try: + locator = page.locator(selector).first + locator.wait_for(state="visible", timeout=timeout_ms) + return locator + except Exception: + continue + return None + + def fill_first(self, page: Any, selectors: Sequence[str], value: str, *, required: bool = True) -> bool: + locator = self.first_visible(page, selectors) + if locator is None: + if required: + raise PublishError(f"未找到平台表单字段:{selectors[0]}", "platform_form_changed") + return False + try: + locator.fill(value) + except Exception: + locator.click() + locator.press("Control+A") + locator.press("Backspace") + locator.type(value, delay=20) + return True + + def click_first(self, page: Any, selectors: Sequence[str], *, required: bool = True) -> bool: + locator = self.first_visible(page, selectors) + if locator is None: + if required: + raise PublishError(f"未找到平台操作按钮:{selectors[0]}", "platform_form_changed") + return False + locator.click() + return True + + def evaluate_script( + self, + page: Any, + script: str, + *, + phase: str, + default_error_code: str = "platform_form_changed", + ) -> dict[str, Any]: + """执行共享 DOM 脚本,并保留脚本内的稳定错误标记。""" + + if phase: + self.phase(phase, None) + try: + result = page.evaluate(script) + except Exception as exc: + message = str(exc) + marker = re.search(r"\b((?:douyin|bilibili)_[a-z0-9_]+)", message) + error_code = marker.group(1) if marker else default_error_code + if "_publish_blocked" in error_code or any( + text in message for text in ("验证码", "安全验证", "登录失效", "风控", "内容违规") + ): + raise PublishNeedsReview(f"平台要求人工处理:{message}", error_code) from exc + raise PublishError(message, error_code) from exc + if result is None: + return {} + if not isinstance(result, dict): + return {"result": result} + return result + + def wait_for_script_state( + self, + page: Any, + script: str, + *, + phase: str, + ready_key: str, + timeout_seconds: int, + timeout_error_code: str, + timeout_message: str, + stable_polls: int = 1, + interval_seconds: float = 1.0, + ) -> dict[str, Any]: + """轮询页面状态,只有连续稳定命中后才返回。 + + 页面脚本可以返回 ``error_code`` 和 ``message`` 表示确定失败;这种情况 + 会立即终止,避免继续填写表单或点击发布。 + """ + + self.phase(phase, {"state": "waiting"}) + deadline = time.monotonic() + max(1, int(timeout_seconds)) + stable_count = 0 + last_result: dict[str, Any] = {} + last_signature: tuple[Any, ...] | None = None + while time.monotonic() < deadline: + self.detect_manual_challenge(page) + result = self.evaluate_script(page, script, phase="") + last_result = result + error_code = str(result.get("error_code") or "").strip() + if error_code: + message = str(result.get("message") or "平台返回了失败状态") + raise PublishError(message, error_code) + + signature = ( + result.get("state"), + result.get("progress"), + result.get("message"), + bool(result.get(ready_key)), + ) + if signature != last_signature: + self.phase(phase, result) + last_signature = signature + + if bool(result.get(ready_key)): + stable_count += 1 + if stable_count >= max(1, int(stable_polls)): + return {**result, "stable_polls": stable_count} + else: + stable_count = 0 + time.sleep(max(0.05, float(interval_seconds))) + + details = str(last_result.get("message") or last_result.get("state") or "") + suffix = f"(最后状态:{details})" if details else "" + raise PublishError(f"{timeout_message}{suffix}", timeout_error_code) + + def hold_for_manual_review( + self, + page: Any, + message: str, + error_code: str, + *, + seconds: int | None = None, + evidence: dict[str, Any] | None = None, + ) -> None: + """在失败后保留可见 Chrome,供用户查看或人工处理。""" + + hold_seconds = max( + 0, + int( + settings.publish_browser_failure_hold_seconds + if seconds is None + else seconds + ), + ) + if hold_seconds <= 0 or bool(getattr(page, "is_closed", lambda: False)()): + return + details = { + **(evidence or {}), + "error_code": error_code, + "message": message, + "hold_seconds": hold_seconds, + } + self.phase("manual_review_waiting", details) + try: + page.evaluate( + """ + ({message, errorCode, holdSeconds}) => { + const existing = document.getElementById('niuma-publish-review-banner'); + if (existing) existing.remove(); + const banner = document.createElement('div'); + banner.id = 'niuma-publish-review-banner'; + banner.setAttribute('role', 'alert'); + Object.assign(banner.style, { + position: 'fixed', top: '16px', left: '50%', transform: 'translateX(-50%)', + zIndex: '2147483647', width: 'min(760px, calc(100vw - 32px))', + padding: '16px 18px', borderRadius: '14px', color: '#172033', + background: 'rgba(255,255,255,.97)', border: '1px solid rgba(255,59,48,.32)', + boxShadow: '0 18px 50px rgba(15,23,42,.24)', font: '14px/1.55 system-ui' + }); + const title = document.createElement('strong'); + title.textContent = '牛马片场已暂停自动发送'; + title.style.display = 'block'; + title.style.color = '#c62828'; + title.style.marginBottom = '6px'; + const body = document.createElement('div'); + body.textContent = `${message}(${errorCode})`; + const footer = document.createElement('div'); + footer.style.marginTop = '6px'; + footer.style.color = '#5f6b7a'; + footer.textContent = `此窗口最多保留 ${Math.ceil(holdSeconds / 60)} 分钟。若你人工完成发布,请回发送中心核对结果,不要直接重试。`; + banner.append(title, body, footer); + document.documentElement.appendChild(banner); + } + """, + {"message": message, "errorCode": error_code, "holdSeconds": hold_seconds}, + ) + except Exception: + pass + + deadline = time.monotonic() + hold_seconds + while time.monotonic() < deadline: + try: + if page.is_closed(): + break + except Exception: + break + time.sleep(1) + + @staticmethod + def body_text(page: Any) -> str: + try: + return page.locator("body").inner_text(timeout=3000) + except Exception: + return "" + + def detect_manual_challenge(self, page: Any) -> None: + text = self.body_text(page) + markers = ("滑块", "验证码", "安全验证", "扫码登录", "短信验证", "操作频繁", "账号存在风险") + matched = next((marker for marker in markers if marker in text), "") + if matched: + self.phase("manual_review", {"reason": matched}) + raise PublishNeedsReview(f"平台要求人工处理:{matched}", "platform_verification_required") + + def wait_for_text(self, page: Any, patterns: Sequence[str], timeout_seconds: int) -> str: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + text = self.body_text(page) + for pattern in patterns: + if pattern in text: + return pattern + self.detect_manual_challenge(page) + time.sleep(1) + return "" + + @staticmethod + def extract_link(page: Any, href_patterns: Sequence[str]) -> str: + for pattern in href_patterns: + try: + links = page.locator(f'a[href*="{pattern}"]').evaluate_all( + "nodes => nodes.map(node => node.href).filter(Boolean)" + ) + except Exception: + continue + if links: + return str(links[0]) + return "" + + @staticmethod + def extract_remote_id(url: str) -> str: + for pattern in (r"/video/(\d+)", r"/(BV[0-9A-Za-z]+)", r"[?&]aid=(\d+)"): + matched = re.search(pattern, url or "") + if matched: + return matched.group(1) + return "" + + def screenshot(self, page: Any, name: str) -> str: + safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", name) + path = self.artifact_dir / f"{int(time.time())}-{safe_name}.png" + try: + page.screenshot(path=str(path), full_page=True) + return str(path) + except Exception: + return "" diff --git a/app/services/publishers/douyin.py b/app/services/publishers/douyin.py new file mode 100644 index 0000000..9ea8b86 --- /dev/null +++ b/app/services/publishers/douyin.py @@ -0,0 +1,243 @@ +"""抖音创作者中心 Playwright Publisher。""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from app.services.publish_time import utc_now_iso +from app.services.publishers.base import ( + BasePlatformPublisher, + PublishError, + PublishNeedsReview, + PublishOutcome, + PublishResult, + PublishValidationError, + job_caption, + job_hashtags, +) +from app.services.publishers.browser_runtime import BrowserRuntime +from app.services.publishers import page_scripts + + +class DouyinPublisher(BasePlatformPublisher): + name = "douyin" + platform = "douyin" + creator_url = "https://creator.douyin.com/creator-micro/content/upload" + + def __init__(self, *, runtime: BrowserRuntime | None = None, account_id: str = "", **_: Any) -> None: + self.runtime = runtime or BrowserRuntime(self.platform, account_id) + + @classmethod + def validate_job_data(cls, job: dict[str, Any]) -> None: + title = str(job.get("title") or "").strip() + caption = job_caption(job) + if not title: + raise PublishValidationError("抖音标题不能为空", "missing_title") + if len(title) > 30: + raise PublishValidationError("抖音标题不能超过 30 个字符", "douyin_title_too_long") + if not caption: + raise PublishValidationError("抖音正文不能为空", "missing_caption") + if not job_hashtags(job): + raise PublishValidationError("抖音话题不能为空", "missing_hashtags") + if not str(job.get("cover_file_path") or "").strip(): + raise PublishValidationError("请选择或生成抖音封面", "missing_cover") + + def validate(self, job: dict[str, Any]) -> None: + super().validate(job) + self.validate_job_data(job) + + def check_login(self, account_id: str) -> dict[str, Any]: + with self.runtime.page(self.creator_url) as page: + time.sleep(2) + text = self.runtime.body_text(page) + login_required = "扫码登录" in text or "手机号登录" in text or "login" in page.url.lower() + normal = not login_required and ( + page.locator('input[type="file"]').count() > 0 or "发布视频" in text or "上传视频" in text + ) + return { + "login_status": "normal" if normal else "login_required", + "message": "登录状态正常" if normal else "抖音账号需要重新登录", + } + + def open_login(self, account_id: str) -> dict[str, Any]: + with self.runtime.page(self.creator_url) as page: + deadline = time.monotonic() + 600 + while time.monotonic() < deadline: + text = self.runtime.body_text(page) + if "发布视频" in text or "上传视频" in text or page.locator('input[type="file"]').count() > 0: + return {"login_status": "normal", "message": "抖音登录成功"} + time.sleep(2) + return {"login_status": "login_required", "message": "等待登录超时,请重新打开登录窗口"} + + def publish(self, job: dict[str, Any]) -> PublishResult: + self.validate(job) + payload = self.build_payload(job) + submitted = False + evidence: dict[str, Any] = {} + with self.runtime.page(self.creator_url) as page: + try: + self.runtime.detect_manual_challenge(page) + login = self._page_logged_in(page) + if not login: + raise PublishNeedsReview("抖音账号登录失效,请重新登录", "account_login_required") + + upload = self.runtime.first_visible(page, ['input[type="file"]'], timeout_ms=5000) + if upload is None: + raise PublishError("未找到抖音视频上传入口", "platform_form_changed") + self.runtime.phase("upload_started", {"video": Path(payload["video_path"]).name}) + upload.set_input_files(payload["video_path"]) + upload_result = self.runtime.wait_for_script_state( + page, + page_scripts.douyin_upload_state(), + phase="upload_waiting", + ready_key="upload_ready", + timeout_seconds=600, + timeout_error_code="video_upload_timeout", + timeout_message="抖音视频上传或解析超时", + stable_polls=2, + ) + evidence["upload"] = upload_result + self.runtime.phase("upload_completed", upload_result) + + content = page_scripts.douyin_description(job, payload["title"]) + evidence["preview_tip"] = self.runtime.evaluate_script( + page, page_scripts.douyin_close_preview_tip(), phase="preview_tip_closed" + ) + evidence["title"] = self.runtime.evaluate_script( + page, page_scripts.fill_title(payload["title"]), phase="title_filled" + ) + evidence["description"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_set_description(content), + phase="description_filled", + ) + evidence["form_before_cover"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_verify_ready(payload["title"], content), + phase="form_verified_before_cover", + ) + evidence["recommended_cover_ready"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_wait_recommended_cover(), + phase="recommended_cover_ready", + default_error_code="douyin_ai_cover_not_ready", + ) + evidence["recommended_cover_click"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_click_recommended_cover(), + phase="recommended_cover_clicked", + ) + evidence["recommended_cover_confirm"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_confirm_cover(), + phase="recommended_cover_confirmed", + ) + evidence["recommended_cover"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_verify_cover(), + phase="recommended_cover_verified", + ) + evidence["form_before_submit"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_verify_ready(payload["title"], content), + phase="form_verified_before_submit", + ) + evidence["visibility"] = self.runtime.evaluate_script( + page, + page_scripts.douyin_set_visibility(payload.get("visibility") or "public"), + phase="visibility_verified", + default_error_code="douyin_visibility_not_applied", + ) + self.runtime.detect_manual_challenge(page) + click_result = self.runtime.evaluate_script( + page, + page_scripts.douyin_click_publish(), + phase="precise_publish_clicked", + default_error_code="douyin_publish_button_not_found", + ) + if not click_result.get("clicked"): + raise PublishError("抖音发布按钮没有返回已点击证据", "douyin_publish_click_not_confirmed") + submitted = True + evidence["click"] = click_result + self.runtime.phase("submit_clicked", click_result) + confirmation = self.runtime.evaluate_script( + page, + page_scripts.douyin_wait_result(payload["title"]), + phase="publish_result_checked", + default_error_code="douyin_publish_not_confirmed", + ) + if not confirmation.get("publish_confirmed"): + raise PublishNeedsReview( + "抖音没有返回可验证的发布成功证据,请在创作者中心核对", + "publish_result_uncertain", + ) + evidence["confirmation"] = confirmation + platform_url = self.runtime.extract_link(page, ("/video/", "/creator-micro/content/manage")) + confirmed_url = str(confirmation.get("url") or page.url or "") + if not platform_url and "/manage" in confirmed_url: + platform_url = confirmed_url + remote_id = self.runtime.extract_remote_id(platform_url) + self.runtime.phase("confirmed_success", {"platform_url": platform_url, "remote_video_id": remote_id}) + return PublishResult( + outcome=PublishOutcome.PUBLISHED, + message="抖音投稿成功", + remote_video_id=remote_id, + platform_url=platform_url, + published_at=utc_now_iso(), + provider_response={ + **evidence, + "final_url": confirmed_url, + }, + ) + except Exception as exc: + screenshot = self.runtime.screenshot(page, "douyin-publish-error") + error_code = ( + exc.error_code if isinstance(exc, PublishError) else "douyin_publish_failed" + ) + message = ( + exc.message if isinstance(exc, PublishError) else str(exc) + ) + diagnostic = {**evidence, "screenshot": screenshot, "submitted": submitted} + self.runtime.hold_for_manual_review( + page, + message, + error_code, + evidence=diagnostic, + ) + if submitted: + raise PublishNeedsReview( + f"抖音投稿结果不确定,请人工确认。{message}", + "publish_result_uncertain", + ) from exc + if isinstance(exc, PublishNeedsReview): + raise + # 失败窗口允许用户人工操作,因此即使错误发生在点击前,也不能再自动重试。 + raise PublishNeedsReview(message, error_code) from exc + + def _page_logged_in(self, page: Any) -> bool: + text = self.runtime.body_text(page) + return not ("扫码登录" in text or "手机号登录" in text or "login" in page.url.lower()) + + def _set_cover(self, page: Any, cover_path: str) -> None: + if not cover_path or not Path(cover_path).is_file(): + return + if not self.runtime.click_first(page, ('text="选择封面"', 'button:has-text("选择封面")'), required=False): + return + cover_input = self.runtime.first_visible( + page, + ('input[type="file"][accept*="image"]', 'input[type="file"]'), + timeout_ms=3000, + ) + if cover_input is not None: + cover_input.set_input_files(cover_path) + self.runtime.click_first(page, ('button:has-text("完成")', 'button:has-text("确定")'), required=False) + + def _set_visibility(self, page: Any, visibility: str) -> None: + labels = {"public": "公开", "friends": "好友可见", "private": "仅自己可见"} + label = labels.get(visibility, "公开") + if label == "公开": + return + self.runtime.click_first(page, ('text="公开"', '[role="combobox"]'), required=False) + self.runtime.click_first(page, (f'text="{label}"',), required=False) diff --git a/app/services/publishers/local_browser.py b/app/services/publishers/local_browser.py new file mode 100644 index 0000000..549507a --- /dev/null +++ b/app/services/publishers/local_browser.py @@ -0,0 +1,68 @@ +"""本地浏览器执行模式:校验、登录态预检、调用 Worker、写回原始结果。""" + +from __future__ import annotations + +from typing import Any + +from app.services.publishers.base import ( + BasePublisher, + PublishOutcome, + PublishResult, + PublishValidationError, + job_cover_path, + job_hashtags, +) +from app.services.publishers.worker_client import PublishWorkerClient + + +class LocalBrowserPublisher(BasePublisher): + name = "local_browser" + + def __init__( + self, + *, + platform: str, + worker_client: PublishWorkerClient | None = None, + repository: Any | None = None, + **_: Any, + ) -> None: + self.platform = str(platform or "").lower() + self.worker_client = worker_client or PublishWorkerClient() + self.repository = repository + + def validate(self, job: dict[str, Any]) -> None: + super().validate(job) + if str(job.get("platform") or "").lower() != self.platform: + raise PublishValidationError("Publisher 与任务平台不匹配", "platform_mismatch") + if not str(job.get("account_id") or "").strip(): + raise PublishValidationError("请选择发布账号", "missing_account") + if not job_hashtags(job): + raise PublishValidationError("话题或标签不能为空", "missing_hashtags") + job_cover_path(job, required=True) + from app.services.publishers.registry import get_platform_publisher_class + + platform_class = get_platform_publisher_class(self.platform) + # 平台字段校验不需要启动浏览器。 + platform_class.validate_job_data(job) + + def publish(self, job: dict[str, Any]) -> PublishResult: + self.validate(job) + account_id = str(job.get("account_id") or "") + login = self.worker_client.check_account(self.platform, account_id) + login_status = str(login.get("login_status") or login.get("status") or "unknown").lower() + if self.repository is not None: + self.repository.update_account_status( + account_id, + "normal" if login_status == "normal" else "invalid", + str(login.get("message") or ""), + logged_in=login_status == "normal", + ) + if login_status != "normal": + return PublishResult( + outcome=PublishOutcome.NEED_REVIEW, + message=str(login.get("message") or "账号登录失效,请重新登录"), + error_code="account_login_required", + needs_manual_review=True, + provider_response={"login_status": login_status}, + ) + return self.worker_client.publish(self.build_payload(job)) diff --git a/app/services/publishers/manual_export.py b/app/services/publishers/manual_export.py new file mode 100644 index 0000000..d1ed2ce --- /dev/null +++ b/app/services/publishers/manual_export.py @@ -0,0 +1,62 @@ +"""明确选择时生成本地发布包,不代表平台投稿成功。""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +from app.core.config import settings +from app.services.publish_time import utc_now_iso +from app.services.publishers.base import BasePublisher, PublishOutcome, PublishResult, job_video_path + + +def _write_text(path: Path, value: str) -> None: + path.write_text(str(value or "").strip() + "\n", encoding="utf-8") + + +def _write_json(path: Path, value: dict[str, Any]) -> None: + path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") + + +class ManualExportPublisher(BasePublisher): + name = "manual_export" + + def __init__(self, export_dir: Path | None = None, **_: Any) -> None: + self.export_dir = Path(export_dir or settings.publish_scheduler_export_dir) + + def build_package_dir(self, job: dict[str, Any]) -> Path: + clip_id = str(job.get("clip_id") or job.get("output_clip_id") or "unknown_clip") + return self.export_dir / str(job.get("task_id") or "unknown_task") / clip_id + + def publish(self, job: dict[str, Any]) -> PublishResult: + self.validate(job) + video_path = job_video_path(job) + package_dir = self.build_package_dir(job) + package_dir.mkdir(parents=True, exist_ok=True) + clip_path = package_dir / f"clip{video_path.suffix.lower()}" + shutil.copy2(video_path, clip_path) + + payload = self.build_payload(job) + payload.update({ + "package_dir": str(package_dir), + "clip_file": str(clip_path), + "exported_at": utc_now_iso(), + "notice": "本地发布包已生成,尚未向平台投稿。", + }) + _write_text(package_dir / "title.txt", payload["title"]) + _write_text(package_dir / "caption.txt", payload["caption"]) + _write_text(package_dir / "hashtags.txt", payload["hashtags"]) + _write_json(package_dir / "publish_plan.json", payload) + _write_json(package_dir / "metadata.json", { + **payload, + "source_video_name": video_path.name, + "source_video_size_bytes": video_path.stat().st_size, + }) + return PublishResult( + outcome=PublishOutcome.EXPORTED, + message="本地发布包已生成,未向平台投稿", + remote_video_id=f"manual_export:{job.get('id') or package_dir.name}", + provider_response=payload, + ) diff --git a/app/services/publishers/opencli_compat.py b/app/services/publishers/opencli_compat.py new file mode 100644 index 0000000..6883f5c --- /dev/null +++ b/app/services/publishers/opencli_compat.py @@ -0,0 +1,46 @@ +"""旧 opencli 的显式兼容 Publisher;状态仍由统一 Scheduler 管理。""" + +from __future__ import annotations + +from typing import Any + +from app.services.publishers.base import BasePublisher, PublishOutcome, PublishResult + + +class OpenCliCompatPublisher(BasePublisher): + name = "opencli_publish" + + def __init__(self, *, runner=None, **_: Any) -> None: + self.runner = runner + + def publish(self, job: dict[str, Any]) -> PublishResult: + self.validate(job) + from app.services import publish_service + + response = publish_service.execute_opencli_send_job(str(job.get("id") or ""), runner=self.runner) + if response.get("status") == "ok": + current = response.get("job") or {} + url = str(current.get("platform_url") or "") + remote_id = str(current.get("remote_video_id") or current.get("platform_item_id") or "") + # 旧 opencli 只有在明确返回平台证据时才算成功;否则避免误判为已发布。 + if url or remote_id or response.get("confirmed") is True: + return PublishResult( + outcome=PublishOutcome.PUBLISHED, + message=str(response.get("message") or "opencli 已确认投稿成功"), + remote_video_id=remote_id, + platform_url=url, + provider_response=response, + ) + return PublishResult( + outcome=PublishOutcome.NEED_REVIEW, + message="opencli 已执行,但没有取得作品链接或稿件 ID,请人工确认", + error_code="opencli_result_uncertain", + needs_manual_review=True, + provider_response=response, + ) + return PublishResult( + outcome=PublishOutcome.FAILED, + message=str(response.get("message") or "opencli 执行失败"), + error_code="opencli_publish_failed", + provider_response=response, + ) diff --git a/app/services/publishers/page_scripts.py b/app/services/publishers/page_scripts.py new file mode 100644 index 0000000..035aab9 --- /dev/null +++ b/app/services/publishers/page_scripts.py @@ -0,0 +1,150 @@ +"""Windows Worker 与旧兼容流程共用的页面脚本入口。 + +旧版发送流程在 ``publish_service`` 中积累了经过真实页面反复修正的 DOM 脚本。 +这里通过延迟导入暴露同一份脚本,避免 Playwright Publisher 再维护一套脆弱选择器, +同时也避免模块加载时形成循环依赖。 +""" + +from __future__ import annotations + +import json +from typing import Any + + +def _legacy_module(): + from app.services import publish_service + + return publish_service + + +def fill_title(title: str) -> str: + legacy = _legacy_module() + return legacy._fill_visible_field_script(legacy._TITLE_FIELD_SELECTOR, title, "title") + + +def douyin_description(job: dict[str, Any], title: str) -> str: + return _legacy_module()._douyin_description_for_job(job, title) + + +def douyin_close_preview_tip() -> str: + return _legacy_module()._douyin_close_preview_tip_script() + + +def douyin_upload_state() -> str: + """读取抖音上传/解析状态;常驻表单文案永远不能代表上传完成。""" + + return ( + "(()=>{" + "const visible=(el)=>{const style=getComputedStyle(el);const rect=el.getBoundingClientRect();return style.display!=='none'&&style.visibility!=='hidden'&&style.opacity!=='0'&&rect.width>0&&rect.height>0;};" + "const textOf=(el)=>String(el?.innerText||el?.textContent||'').replace(/\\s+/g,'').trim();" + "const body=textOf(document.body);" + "const failures=['上传失败','文件格式错误','文件格式不支持','不支持该视频','视频处理失败','解析失败','转码失败','网络异常,请重试'];" + "const failure=failures.find((item)=>body.includes(item));" + "if(failure){return {state:'failed',upload_ready:false,error_code:'douyin_video_upload_failed',message:failure};}" + "const progressNodes=[...document.querySelectorAll('span,div,p')].filter(visible).map(textOf).filter((text)=>/^\\d{1,3}%$/.test(text));" + "const progressValues=progressNodes.map((text)=>Number(text.slice(0,-1))).filter(Number.isFinite);" + "const progress=progressValues.length?Math.min(...progressValues):null;" + "const busyMarkers=['文件解析中','正在上传','上传中','视频处理中','正在处理','转码中','等待上传','请等待上传完成','上传过程中请不要删除','上传过程中请勿删除'];" + "const explanatoryMarkers=['点击发布后','如作品还在上传中','上传发布完成','视频预览功能','实际播放时'];" + "const statusTexts=[...document.querySelectorAll('span,div,p')].filter(visible).map(textOf).filter((text)=>text&&text.length<=40&&!explanatoryMarkers.some((item)=>text.includes(item)));" + "const busy=busyMarkers.find((item)=>statusTexts.some((text)=>text===item||text.startsWith(`${item},`)||text.startsWith(`${item},`)||text.startsWith(`${item}:`)||text.startsWith(`${item}:`)||text.startsWith(`${item}...`)||text.startsWith(`${item}…`)))||((progress!==null&&progress<100)?`${progress}%`:'');" + "const badImage=(src)=>/logo|avatar|favicon|icon|douyin-creator-logo|static\\/image/i.test(src||'');" + "const videos=[...document.querySelectorAll('video')].filter((el)=>visible(el)&&(el.videoWidth>0||el.readyState>=2||Number.isFinite(el.duration)));" + "const canvases=[...document.querySelectorAll('canvas')].filter((el)=>{const rect=el.getBoundingClientRect();return visible(el)&&el.width>=160&&el.height>=90&&rect.width>=120&&rect.height>=80;});" + "const images=[...document.querySelectorAll('img')].filter((el)=>{const rect=el.getBoundingClientRect();const src=el.currentSrc||el.src||'';return visible(el)&&!badImage(src)&&el.complete!==false&&el.naturalWidth>=240&&el.naturalHeight>=135&&rect.width>=120&&rect.height>=80;});" + "const preview_count=videos.length+canvases.length+images.length;" + "const upload_ready=!busy&&preview_count>0&&(progress===null||progress>=100);" + "return {state:upload_ready?'ready':(busy?'processing':'waiting_preview'),upload_ready,progress,preview_count,busy_marker:busy||'',message:upload_ready?'视频上传与解析完成':(busy?`仍在上传或解析:${busy}`:'等待真实视频预览')};" + "})()" + ) + + +def douyin_set_description(description: str) -> str: + return _legacy_module()._douyin_set_description_script(description) + + +def douyin_verify_ready(title: str, description: str) -> str: + return _legacy_module()._douyin_verify_publish_ready_script(title, description) + + +def douyin_set_visibility(visibility: str) -> str: + labels = {"public": "公开", "friends": "好友可见", "private": "仅自己可见"} + label = labels.get(str(visibility or "public"), "公开") + return ( + "(async()=>{" + f"const expected={json.dumps(label, ensure_ascii=False)};" + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));" + "const visible=(el)=>{const style=getComputedStyle(el);const rect=el.getBoundingClientRect();return !el.disabled&&style.display!=='none'&&style.visibility!=='hidden'&&style.opacity!=='0'&&rect.width>0&&rect.height>0;};" + "const textOf=(el)=>String(el?.innerText||el?.textContent||'').replace(/\\s+/g,'').trim();" + "const isSelected=(el)=>{const input=el.matches?.('input')?el:el.querySelector?.('input[type=radio],input[type=checkbox]');return Boolean(input?.checked||el.getAttribute?.('aria-checked')==='true'||/(^|\\s)(active|checked|selected)(\\s|$)/i.test(String(el.className||'')));};" + "const labels=['公开','好友可见','仅自己可见'];" + "const optionNodes=[...document.querySelectorAll('label,button,[role=radio],[role=option],div,span')].filter(visible).filter((el)=>textOf(el)===expected);" + "const scored=optionNodes.map((el)=>{const clickable=el.closest('label,button,[role=radio],[role=option]')||el;let score=0;let node=clickable;for(let i=0;i<7&&node;i+=1){const text=textOf(node);if(text.includes('谁可以看'))score+=100-i*8;if(labels.filter((item)=>text.includes(item)).length>=2)score+=40-i*3;node=node.parentElement;}if(clickable.matches('label,button,[role=radio],[role=option]'))score+=20;return {el:clickable,score};}).sort((a,b)=>b.score-a.score);" + "const target=scored[0]?.el;if(!target){throw new Error('douyin_visibility_option_not_found:'+expected);}" + "target.scrollIntoView({block:'center',inline:'center'});target.click();await sleep(700);" + "const refreshed=[...document.querySelectorAll('label,button,[role=radio],[role=option],div,span')].filter(visible).filter((el)=>textOf(el)===expected).map((el)=>el.closest('label,button,[role=radio],[role=option]')||el);" + "const selected=refreshed.find(isSelected)||refreshed.find((el)=>{let node=el;for(let i=0;i<3&&node;i+=1){if(isSelected(node))return true;node=node.parentElement;}return false;});" + "if(!selected){throw new Error('douyin_visibility_not_applied:'+expected);}" + "return {visibility_verified:true,visibility_text:expected,option_count:refreshed.length};" + "})()" + ) + + +def douyin_wait_recommended_cover(timeout_seconds: int = 150) -> str: + return _legacy_module()._douyin_wait_ai_cover_script(timeout_seconds) + + +def douyin_click_recommended_cover() -> str: + return _legacy_module()._douyin_click_ai_cover_script() + + +def douyin_confirm_cover(timeout_seconds: int = 20) -> str: + return _legacy_module()._douyin_confirm_cover_script(timeout_seconds) + + +def douyin_verify_cover(timeout_seconds: int = 45) -> str: + return _legacy_module()._douyin_verify_cover_applied_script(timeout_seconds) + + +def douyin_click_publish() -> str: + return _legacy_module()._douyin_click_publish_script() + + +def douyin_wait_result(title: str, timeout_seconds: int = 120) -> str: + return _legacy_module()._douyin_wait_publish_result_script(title, timeout_seconds) + + +def bilibili_dismiss_local_draft() -> str: + return _legacy_module()._bilibili_dismiss_local_draft_script() + + +def bilibili_wait_uploaded(timeout_seconds: int = 180) -> str: + return _legacy_module()._bilibili_wait_video_uploaded_script(timeout_seconds) + + +def bilibili_select_recommended_cover(timeout_seconds: int = 120) -> str: + return _legacy_module()._bilibili_select_recommended_cover_script(timeout_seconds) + + +def bilibili_select_declaration() -> str: + return _legacy_module()._bilibili_select_declaration_script() + + +def bilibili_select_category(category: str) -> str: + return _legacy_module()._bilibili_select_category_if_empty_script(category) + + +def bilibili_set_description(description: str) -> str: + return _legacy_module()._bilibili_set_description_script(description) + + +def bilibili_verify_ready(title: str, description: str) -> str: + return _legacy_module()._bilibili_verify_publish_ready_script(title, description) + + +def bilibili_click_publish() -> str: + return _legacy_module()._bilibili_click_publish_script() + + +def bilibili_wait_result(title: str, timeout_seconds: int = 180) -> str: + return _legacy_module()._bilibili_wait_publish_result_script(title, timeout_seconds) diff --git a/app/services/publishers/registry.py b/app/services/publishers/registry.py new file mode 100644 index 0000000..eac153d --- /dev/null +++ b/app/services/publishers/registry.py @@ -0,0 +1,93 @@ +"""平台和执行模式注册表。未知项永不静默降级。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from app.core.config import settings +from app.services.publishers.base import BasePlatformPublisher, BasePublisher, PublishValidationError + + +PublisherFactory = Callable[..., BasePublisher] +PlatformFactory = Callable[..., BasePlatformPublisher] + +_MODE_REGISTRY: dict[str, PublisherFactory] = {} +_PLATFORM_REGISTRY: dict[str, PlatformFactory] = {} +_BOOTSTRAPPED = False + + +def register_publisher(name: str, factory: PublisherFactory) -> None: + key = str(name or "").strip().lower() + if not key: + raise ValueError("Publisher 注册名称不能为空") + _MODE_REGISTRY[key] = factory + + +def register_platform_publisher(platform: str, factory: PlatformFactory) -> None: + key = str(platform or "").strip().lower() + if key not in {"douyin", "bilibili"}: + raise ValueError(f"不支持的平台:{platform}") + _PLATFORM_REGISTRY[key] = factory + + +def _bootstrap() -> None: + global _BOOTSTRAPPED + if _BOOTSTRAPPED: + return + from app.services.publishers.bilibili import BilibiliPublisher + from app.services.publishers.api_compat import ApiCompatPublisher + from app.services.publishers.douyin import DouyinPublisher + from app.services.publishers.local_browser import LocalBrowserPublisher + from app.services.publishers.manual_export import ManualExportPublisher + from app.services.publishers.opencli_compat import OpenCliCompatPublisher + + register_platform_publisher("douyin", DouyinPublisher) + register_platform_publisher("bilibili", BilibiliPublisher) + register_publisher("local_browser", LocalBrowserPublisher) + register_publisher("manual_export", ManualExportPublisher) + register_publisher("opencli_publish", OpenCliCompatPublisher) + register_publisher("api_publish", ApiCompatPublisher) + _BOOTSTRAPPED = True + + +def get_platform_publisher_class(platform: str) -> PlatformFactory: + _bootstrap() + key = str(platform or "").strip().lower() + factory = _PLATFORM_REGISTRY.get(key) + if not factory: + raise PublishValidationError(f"未注册的平台:{key or '(empty)'}", "unregistered_platform") + return factory + + +def get_platform_publisher(platform: str, **dependencies: Any) -> BasePlatformPublisher: + factory = get_platform_publisher_class(platform) + return factory(**dependencies) + + +def get_publisher(platform: str, publish_mode: str, **dependencies: Any) -> BasePublisher: + _bootstrap() + platform_key = str(platform or "").strip().lower() + mode_key = str(publish_mode or "").strip().lower() + if platform_key not in _PLATFORM_REGISTRY: + raise PublishValidationError(f"未注册的平台:{platform_key or '(empty)'}", "unregistered_platform") + factory = _MODE_REGISTRY.get(mode_key) + if not factory: + raise PublishValidationError(f"未注册的发布方式:{mode_key or '(empty)'}", "unsupported_publish_mode") + if mode_key == "opencli_publish" and not settings.publish_enable_opencli_fallback: + raise PublishValidationError( + "该任务使用旧 opencli 模式,但兼容开关未开启,请改为本地浏览器后重新排期", + "opencli_fallback_disabled", + needs_manual_review=True, + ) + return factory(platform=platform_key, **dependencies) + + +def registered_platforms() -> tuple[str, ...]: + _bootstrap() + return tuple(sorted(_PLATFORM_REGISTRY)) + + +def registered_modes() -> tuple[str, ...]: + _bootstrap() + return tuple(sorted(_MODE_REGISTRY)) diff --git a/app/services/publishers/worker_client.py b/app/services/publishers/worker_client.py new file mode 100644 index 0000000..74c1ab0 --- /dev/null +++ b/app/services/publishers/worker_client.py @@ -0,0 +1,88 @@ +"""FastAPI 调度进程访问 Windows 发布 Worker 的小型 HTTP 客户端。""" + +from __future__ import annotations + +import json +import socket +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from app.core.config import settings +from app.services.publishers.base import PublishError, PublishResult, PublishWorkerUnavailable + + +class PublishWorkerClient: + def __init__(self, base_url: str | None = None, token: str | None = None, timeout: int | None = None) -> None: + self.base_url = str(base_url or settings.publish_worker_url).rstrip("/") + self.token = str(token if token is not None else settings.publish_worker_token) + self.timeout = max(2, int(timeout or settings.publish_worker_timeout_seconds)) + + def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.base_url: + raise PublishWorkerUnavailable("未配置 PUBLISH_WORKER_URL") + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None + headers = {"Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=self.timeout) as response: # noqa: S310 - URL is local configuration + raw = response.read().decode("utf-8", errors="replace") + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + detail = json.loads(raw).get("detail") or raw + except json.JSONDecodeError: + detail = raw or str(exc) + if exc.code in {401, 403}: + raise PublishError(str(detail), "publish_worker_unauthorized") from exc + if exc.code in {409, 422}: + raise PublishError(str(detail), "publish_worker_rejected") from exc + raise PublishWorkerUnavailable( + f"Windows 发布 Worker 返回 HTTP {exc.code}:{detail}", + request_may_have_been_received=True, + ) from exc + except (TimeoutError, socket.timeout) as exc: + raise PublishWorkerUnavailable( + f"等待 Windows 发布 Worker 返回结果超时:{exc}", + request_may_have_been_received=True, + ) from exc + except (URLError, OSError) as exc: + reason = getattr(exc, "reason", exc) + timed_out = isinstance(reason, (TimeoutError, socket.timeout)) or "timed out" in str(reason).lower() + raise PublishWorkerUnavailable( + "发送服务正在随 Docker 中的牛马片场项目自动启动。" + "如果刚刚运行项目,请稍候并重新检测;持续未连接时,请在 Docker Desktop 中停止后重新运行本项目。", + request_may_have_been_received=timed_out, + ) from exc + if not raw: + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise PublishWorkerUnavailable("Windows 发布 Worker 返回了无效数据") from exc + if not isinstance(parsed, dict): + raise PublishWorkerUnavailable("Windows 发布 Worker 返回格式不正确") + return parsed + + def health(self) -> dict[str, Any]: + return self._request("GET", "/v1/health") + + def check_account(self, platform: str, account_id: str) -> dict[str, Any]: + return self._request("POST", "/v1/accounts/check", {"platform": platform, "account_id": account_id}) + + def start_login(self, platform: str, account_id: str) -> dict[str, Any]: + return self._request("POST", "/v1/accounts/login", {"platform": platform, "account_id": account_id}) + + def open_creator_center(self, platform: str, account_id: str) -> dict[str, Any]: + return self._request("POST", "/v1/accounts/open-center", {"platform": platform, "account_id": account_id}) + + def publish(self, payload: dict[str, Any]) -> PublishResult: + response = self._request("POST", "/v1/publish", payload) + return PublishResult.from_dict(response) + + def execution(self, execution_id: str) -> dict[str, Any]: + return self._request("GET", f"/v1/executions/{execution_id}") diff --git a/app/services/storage_service.py b/app/services/storage_service.py index aff4d1b..56065aa 100644 --- a/app/services/storage_service.py +++ b/app/services/storage_service.py @@ -1,7 +1,10 @@ +from dataclasses import dataclass from pathlib import Path, PureWindowsPath +import os import re import sqlite3 import shutil +import tempfile from typing import BinaryIO from uuid import uuid4 @@ -26,6 +29,62 @@ _PATH_TRAVERSAL_MARKERS = ("..", "~") +class StorageSafetyError(RuntimeError): + """存储路径不安全或不满足清理条件。""" + + +@dataclass(frozen=True) +class ManagedMediaTarget: + label: str + path: Path + + +@dataclass(frozen=True) +class TaskMediaCleanupPlan: + task_id: str + targets: tuple[ManagedMediaTarget, ...] + external_source_path: Path | None + + @property + def existing_targets(self) -> tuple[ManagedMediaTarget, ...]: + return tuple(target for target in self.targets if target.path.exists()) + + +@dataclass(frozen=True) +class TaskMediaCleanupResult: + deleted_paths: tuple[str, ...] + freed_bytes: int + external_source_preserved: bool + + +def _ensure_writable_directory(path: Path, label: str) -> Path: + try: + path.mkdir(parents=True, exist_ok=True) + probe_path = path / f".niuma-write-test-{uuid4().hex}" + probe_path.write_bytes(b"ok") + probe_path.unlink() + except OSError as exc: + raise RuntimeError(f"{label}不可用或不可写:{path};原因:{exc}") from exc + return path.resolve() + + +def configure_runtime_media_storage() -> dict[str, str]: + """准备大文件目录,并把当前应用进程的临时目录固定到存储盘。""" + tasks_dir = _ensure_writable_directory(settings.tasks_dir, "任务存储目录") + upload_temp_dir = _ensure_writable_directory(settings.upload_temp_dir, "上传临时目录") + export_dir = _ensure_writable_directory(settings.publish_scheduler_export_dir, "发布包目录") + + temp_value = str(upload_temp_dir) + os.environ["TEMP"] = temp_value + os.environ["TMP"] = temp_value + tempfile.tempdir = temp_value + return { + "tasks_dir": str(tasks_dir), + "upload_temp_dir": temp_value, + "publish_export_dir": str(export_dir), + } + + def _collect_allowed_roots() -> list[Path]: """收集所有允许访问的文件系统根目录。""" roots: list[Path] = [] @@ -107,6 +166,11 @@ def ensure_storage_root() -> Path: return settings.storage_root +def ensure_tasks_root() -> Path: + settings.tasks_dir.mkdir(parents=True, exist_ok=True) + return settings.tasks_dir + + def _storage_relative_parts(task_dir_name: str) -> tuple[str, ...]: return tuple(part for part in PureWindowsPath(task_dir_name).parts if part not in {"", "."}) @@ -159,14 +223,14 @@ def allocate_task_dir_name( base_name = sanitize_task_dir_name(task_name, fallback=exclude_task_id or "untitled") parent_parts = _storage_relative_parts(parent_dir_name or "") existing_names = _get_existing_task_dir_names(exclude_task_id=exclude_task_id) - root = ensure_storage_root().joinpath(*parent_parts) + root = ensure_tasks_root().joinpath(*parent_parts) root.mkdir(parents=True, exist_ok=True) for index in range(1, 1000): candidate_name = base_name if index == 1 else f"{base_name} ({index})" candidate_parts = (*parent_parts, candidate_name) relative_name = str(PureWindowsPath(*candidate_parts)) - candidate_path = ensure_storage_root().joinpath(*candidate_parts) + candidate_path = ensure_tasks_root().joinpath(*candidate_parts) if relative_name.lower() not in existing_names and not candidate_path.exists(): return relative_name @@ -308,10 +372,9 @@ def get_source_video_path(task: dict) -> Path | None: def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task_dir_name: str | None = None) -> Path: - create_task_directory(task_id, task_dir_name) - # 扩展名校验 _validate_upload_extension(filename) + create_task_directory(task_id, task_dir_name) safe_name = Path(filename or "source_video").name if not Path(safe_name).suffix: @@ -321,25 +384,176 @@ def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task # 流式写入 + 大小限制检查 max_size = settings.max_upload_size_bytes written = 0 - with output_path.open("wb") as target: - while True: - chunk = file_object.read(1024 * 1024) # 1MB chunks - if not chunk: - break - written += len(chunk) - if written > max_size: - # 删除已写入的部分 - try: - output_path.unlink() - except OSError: - pass - max_gb = max_size / (1024 * 1024 * 1024) - raise ValueError(f"上传文件超过大小限制({max_gb:.1f} GB)") - target.write(chunk) + try: + with output_path.open("wb") as target: + while True: + chunk = file_object.read(1024 * 1024) # 1MB chunks + if not chunk: + break + written += len(chunk) + if written > max_size: + max_gb = max_size / (1024 * 1024 * 1024) + raise ValueError(f"上传文件超过大小限制({max_gb:.1f} GB)") + target.write(chunk) + except Exception: + try: + output_path.unlink(missing_ok=True) + except OSError: + pass + raise return output_path +def remove_failed_task_directory(task_id: str, task_dir_name: str) -> None: + """仅清理本次尚未写入数据库的新任务目录。""" + if _fetch_task_dir_name(task_id): + return + task_dir = get_task_directory(task_id, task_dir_name) + tasks_root = settings.tasks_dir.resolve() + resolved = task_dir.resolve(strict=False) + try: + within_root = resolved.is_relative_to(tasks_root) + except AttributeError: # pragma: no cover - Python 3.8 兼容 + within_root = str(resolved).lower().startswith(str(tasks_root).lower() + os.sep) + if resolved == tasks_root or not within_root or task_dir.is_symlink(): + raise StorageSafetyError(f"拒绝清理不安全的任务目录:{task_dir}") + if task_dir.exists(): + shutil.rmtree(task_dir) + + +def _safe_relative_parts(value: str, label: str) -> tuple[str, ...]: + windows_path = PureWindowsPath(str(value or "").strip()) + parts = tuple(part for part in windows_path.parts if part not in {"", "."}) + if ( + not parts + or windows_path.is_absolute() + or windows_path.drive + or any(part in _PATH_TRAVERSAL_MARKERS for part in parts) + ): + raise StorageSafetyError(f"{label}包含不安全路径:{value}") + return parts + + +def _safe_managed_child(root: Path, parts: tuple[str, ...], label: str) -> Path: + resolved_root = root.resolve(strict=False) + candidate = root.joinpath(*parts) + resolved_candidate = candidate.resolve(strict=False) + try: + within_root = resolved_candidate.is_relative_to(resolved_root) + except AttributeError: # pragma: no cover - Python 3.8 兼容 + within_root = str(resolved_candidate).lower().startswith(str(resolved_root).lower() + os.sep) + if resolved_candidate == resolved_root or not within_root or candidate.is_symlink(): + raise StorageSafetyError(f"拒绝删除不安全的{label}:{candidate}") + return candidate + + +def _deduplicate_targets(targets: list[ManagedMediaTarget]) -> tuple[ManagedMediaTarget, ...]: + unique: list[ManagedMediaTarget] = [] + seen: set[str] = set() + for target in targets: + key = str(target.path.resolve(strict=False)).lower() + if key in seen: + continue + seen.add(key) + unique.append(target) + return tuple(unique) + + +def _path_is_within(path: Path, parent: Path) -> bool: + try: + return path.resolve(strict=False).is_relative_to(parent.resolve(strict=False)) + except (AttributeError, OSError, ValueError): + path_value = str(path.resolve(strict=False)).lower() + parent_value = str(parent.resolve(strict=False)).lower() + return path_value.startswith(parent_value + os.sep) + + +def build_task_media_cleanup_plan(task: dict, *, include_legacy: bool = True) -> TaskMediaCleanupPlan: + task_id = str(task.get("id") or "").strip() + task_id_parts = _safe_relative_parts(task_id, "任务 ID") + if len(task_id_parts) != 1: + raise StorageSafetyError(f"任务 ID 必须是单层目录名:{task_id}") + + task_dir_name = str(task.get("task_dir_name") or task_id) + task_parts = _safe_relative_parts(task_dir_name, "任务目录名") + task_dir = _safe_managed_child(settings.tasks_dir, task_parts, "任务目录") + targets = [ManagedMediaTarget("E 盘任务目录", task_dir)] + + export_dir = _safe_managed_child( + settings.publish_scheduler_export_dir, + task_id_parts, + "发布包目录", + ) + targets.append(ManagedMediaTarget("E 盘发布包目录", export_dir)) + + if include_legacy: + legacy_root = settings.project_root / "tasks" + legacy_values = [task_id] + if len(task_parts) == 1 and task_dir_name.lower() != task_id.lower(): + legacy_values.append(task_dir_name) + for legacy_value in legacy_values: + legacy_parts = _safe_relative_parts(legacy_value, "旧版任务目录名") + legacy_path = _safe_managed_child(legacy_root, legacy_parts, "旧版 C 盘任务目录") + targets.append(ManagedMediaTarget("旧版 C 盘任务目录", legacy_path)) + + managed_targets = _deduplicate_targets(targets) + source_path = get_source_video_path(task) + external_source_path = None + if source_path and source_path.exists(): + if not any(_path_is_within(source_path, target.path) for target in managed_targets): + external_source_path = source_path + + return TaskMediaCleanupPlan( + task_id=task_id, + targets=managed_targets, + external_source_path=external_source_path, + ) + + +def _directory_size_bytes(path: Path) -> int: + total = 0 + for child in path.rglob("*"): + try: + if child.is_file() and not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + return total + + +def task_media_cleanup_plan_size(plan: TaskMediaCleanupPlan) -> int: + return sum( + _directory_size_bytes(target.path) + for target in plan.existing_targets + if target.path.is_dir() and not target.path.is_symlink() + ) + + +def apply_task_media_cleanup_plan(plan: TaskMediaCleanupPlan) -> TaskMediaCleanupResult: + deleted_paths: list[str] = [] + freed_bytes = 0 + for target in plan.targets: + path = target.path + if not path.exists(): + continue + if path.is_symlink() or not path.is_dir(): + raise StorageSafetyError(f"拒绝删除异常的{target.label}:{path}") + size = _directory_size_bytes(path) + try: + shutil.rmtree(path) + except OSError as exc: + raise RuntimeError(f"删除{target.label}失败:{path};原因:{exc}") from exc + freed_bytes += size + deleted_paths.append(str(path)) + + return TaskMediaCleanupResult( + deleted_paths=tuple(deleted_paths), + freed_bytes=freed_bytes, + external_source_preserved=plan.external_source_path is not None, + ) + + def move_task_directory_to_trash(task_id: str, task_name: str, task_dir_name: str | None = None) -> tuple[str, Path]: current_dir_name = resolve_task_dir_name(task_id, task_dir_name) source_dir = get_task_directory(task_id, current_dir_name) diff --git a/app/services/task_lifecycle_service.py b/app/services/task_lifecycle_service.py index 6f1d447..34a2323 100644 --- a/app/services/task_lifecycle_service.py +++ b/app/services/task_lifecycle_service.py @@ -8,10 +8,37 @@ from app.db.database import get_connection from app.models.task import TaskCreate, TaskStatus -from app.services.storage_service import allocate_task_dir_name, create_task_directory, validate_source_video_path +from app.services.storage_service import ( + apply_task_media_cleanup_plan, + allocate_task_dir_name, + build_task_media_cleanup_plan, + create_task_directory, + validate_source_video_path, +) from app.services.task_log_service import append_task_log +class TaskDeletionConflictError(RuntimeError): + """任务仍在执行,暂时不能删除其媒体文件。""" + + +ACTIVE_TASK_STATUSES = { + TaskStatus.CREATED.value, + TaskStatus.PREPARING_SOURCE.value, + TaskStatus.TRANSCRIBING.value, + TaskStatus.AI_ANALYZING.value, + TaskStatus.CLIP_SELECTING.value, + TaskStatus.VIDEO_CUTTING.value, + TaskStatus.METADATA_GENERATING.value, + TaskStatus.SCHEDULE_CREATING.value, + TaskStatus.PUBLISH_JOB_CREATING.value, + TaskStatus.audio_extracting.value, + TaskStatus.transcribing.value, + TaskStatus.ai_analyzing.value, + TaskStatus.cutting.value, +} + + def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir_name: str | None = None) -> dict: from app.services.task_service import _now_iso, get_status_label, STATUS_PROGRESS # noqa: F811 @@ -60,6 +87,8 @@ def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir "nas_file_path": payload.nas_file_path, "max_clip_duration": payload.max_clip_duration, "candidate_clip_count": payload.candidate_clip_count, + "selection_profile": payload.selection_profile, + "final_clip_target": payload.final_clip_target, "ai_preference": payload.ai_preference, "ai_prompt_preset_id": "preset_001", "auto_mode": 1 if payload.auto_mode else 0, @@ -190,34 +219,145 @@ def update_task_candidate_clip_count(task_id: str, candidate_clip_count: int) -> } -def soft_delete_task(task_id: str) -> dict: +def update_task_selection_settings( + task_id: str, + selection_profile: str, + final_clip_target: int, +) -> dict: from app.services.task_service import _now_iso, get_task # noqa: F811 task = get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") - if task.get("is_deleted"): - return { - "message": "任务已隐藏,无需重复操作。", - "task_id": task_id, - "task_dir": task["task_dir"], - } + if selection_profile not in {"general", "variety_comedy"}: + raise ValueError("选片模式只能是通用模式或综艺笑点优先") + if final_clip_target < 1 or final_clip_target > 12: + raise ValueError("最终启用目标必须在 1 到 12 条之间") now = _now_iso() with get_connection() as connection: connection.execute( """ UPDATE tasks - SET is_deleted = 1, deleted_at = ?, updated_at = ? + SET selection_profile = ?, final_clip_target = ?, updated_at = ? WHERE id = ? """, - (now, now, task_id), + (selection_profile, final_clip_target, now, task_id), ) connection.commit() - append_task_log(task_id, "任务已从列表隐藏,文件未删除") + profile_label = "综艺笑点优先" if selection_profile == "variety_comedy" else "通用模式" + append_task_log(task_id, f"已更新选片模式:{profile_label},最终启用目标:{final_clip_target} 条") return { - "message": "任务已隐藏,原视频、切片和任务目录都已保留。", + "status": "ok", + "message": f"已保存{profile_label},最终启用目标为 {final_clip_target} 条。", + "task": get_task(task_id, include_video_probe=False), + } + + +def delete_task_permanently(task_id: str) -> dict: + from app.services.task_service import _now_iso, get_task # noqa: F811 + + task = get_task(task_id, include_video_probe=False) + if not task: + raise ValueError("任务不存在") + cleanup_plan = build_task_media_cleanup_plan(task) + existing_target_count = len(cleanup_plan.existing_targets) + now = _now_iso() + with get_connection() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + current = connection.execute( + "SELECT status, COALESCE(is_deleted, 0) AS is_deleted FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not current: + raise ValueError("任务不存在") + + if not current["is_deleted"] and str(current["status"] or "") in ACTIVE_TASK_STATUSES: + raise TaskDeletionConflictError("任务正在处理,请等待处理结束后再永久删除。") + + conflicting_task = connection.execute( + """ + SELECT id + FROM tasks + WHERE id != ? AND COALESCE(is_deleted, 0) = 0 + AND LOWER(COALESCE(task_dir_name, id)) = LOWER(?) + LIMIT 1 + """, + (task_id, str(task.get("task_dir_name") or task_id)), + ).fetchone() + if conflicting_task: + raise TaskDeletionConflictError( + "该目录仍被另一条有效任务使用,已拒绝删除以避免误删。" + ) + + running_job = connection.execute( + "SELECT id FROM workflow_jobs WHERE task_id = ? AND status = 'running' LIMIT 1", + (task_id,), + ).fetchone() + if running_job: + raise TaskDeletionConflictError("任务仍有后台切片工作正在运行,请等待结束后再删除。") + + publishing_job = connection.execute( + "SELECT id FROM publish_jobs WHERE task_id = ? AND status = 'PUBLISHING' LIMIT 1", + (task_id,), + ).fetchone() + if publishing_job: + raise TaskDeletionConflictError("任务正在向平台发送视频,请等待发送结束后再删除。") + + cleanup_result = apply_task_media_cleanup_plan(cleanup_plan) + connection.execute( + """ + UPDATE workflow_jobs + SET status = 'cancelled', progress = 100, + message = '任务已永久删除,排队任务已取消', + error_message = '任务已永久删除', finished_at = ?, updated_at = ? + WHERE task_id = ? AND status = 'queued' + """, + (now, now, task_id), + ) + connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', scheduled_at = '', next_attempt_at = NULL, + error_code = 'task_deleted', error_message = '任务已永久删除', + last_error = '任务已永久删除', history_hidden = 1, + finished_at = ?, updated_at = ? + WHERE task_id = ? + AND status NOT IN ('PUBLISHED', 'EXPORTED', 'NEED_REVIEW', 'CANCELLED') + """, + (now, now, task_id), + ) + connection.execute( + """ + UPDATE tasks + SET is_deleted = 1, deleted_at = COALESCE(deleted_at, ?), updated_at = ? + WHERE id = ? + """, + (now, now, task_id), + ) + connection.commit() + except Exception: + connection.rollback() + raise + + status = "already_deleted" if task.get("is_deleted") and existing_target_count == 0 else "deleted" + freed_mb = cleanup_result.freed_bytes / (1024 * 1024) + if status == "already_deleted": + message = "任务已经永久删除,当前没有残留的任务视频文件。" + else: + message = f"任务已永久删除,共释放约 {freed_mb:.1f} MB;数据库历史记录已隐藏保留。" + return { + "status": status, "task_id": task_id, - "task_dir": task["task_dir"], + "freed_bytes": cleanup_result.freed_bytes, + "external_source_preserved": cleanup_result.external_source_preserved, + "deleted_paths": list(cleanup_result.deleted_paths), + "message": message, } + + +def soft_delete_task(task_id: str) -> dict: + """兼容旧调用名称;实际执行永久媒体删除并保留隐藏数据库记录。""" + return delete_task_permanently(task_id) diff --git a/app/services/task_query_service.py b/app/services/task_query_service.py index d1f93ff..9cb420d 100644 --- a/app/services/task_query_service.py +++ b/app/services/task_query_service.py @@ -407,6 +407,12 @@ def get_system_status_context() -> dict: return { "storage_root": str(settings.storage_root), "storage_exists": settings.storage_root.exists(), + "tasks_dir": str(settings.tasks_dir), + "tasks_dir_exists": settings.tasks_dir.exists(), + "upload_temp_dir": str(settings.upload_temp_dir), + "upload_temp_dir_exists": settings.upload_temp_dir.exists(), + "publish_export_dir": str(settings.publish_scheduler_export_dir), + "publish_export_dir_exists": settings.publish_scheduler_export_dir.exists(), "database_path": str(settings.database_path), "database_exists": settings.database_path.exists(), "ffmpeg_path": ffmpeg_path or "未找到", diff --git a/app/services/task_service.py b/app/services/task_service.py index fa2ff75..217a0be 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -1,5 +1,6 @@ # ruff: noqa: F401 from datetime import datetime, timedelta, timezone +import json from pathlib import Path import shutil import subprocess @@ -37,6 +38,7 @@ process_task_ai_analysis, restore_ai_analysis_run, ) +from app.services.clip_feedback_service import save_clip_feedback from app.services.storage_service import ( get_artifact_paths, get_source_video_path, @@ -47,6 +49,7 @@ soft_delete_task, update_task_ai_preference, update_task_candidate_clip_count, + update_task_selection_settings, update_task_status, ) from app.services.task_log_service import append_task_log as _append_task_log, read_task_log_tail as _read_task_log_tail @@ -448,6 +451,11 @@ def _row_to_task(row: Row, include_video_probe: bool = False) -> dict: "status_label": get_status_label(status), "progress": progress, "candidate_count": task.get("candidate_clip_count") or 0, + "selection_profile": task.get("selection_profile") or "general", + "selection_profile_label": ( + "综艺笑点优先" if task.get("selection_profile") == "variety_comedy" else "通用模式" + ), + "final_clip_target": int(task.get("final_clip_target") or 5), "duration": video_meta["duration"], "video_size": video_meta["video_size"], "owner": "本地用户", @@ -485,7 +493,8 @@ def list_tasks(include_deleted: bool = False) -> list[dict]: f""" SELECT id, task_name, task_dir_name, source_type, platform, original_video_path, nas_file_path, - max_clip_duration, candidate_clip_count, ai_preference, ai_prompt_preset_id, auto_mode, + max_clip_duration, candidate_clip_count, selection_profile, final_clip_target, + ai_preference, ai_prompt_preset_id, auto_mode, auto_config_json, status, progress, error_message, last_error, is_deleted, deleted_at, created_at, updated_at FROM tasks @@ -502,7 +511,8 @@ def get_task(task_id: str, include_video_probe: bool = True) -> dict | None: """ SELECT id, task_name, task_dir_name, source_type, platform, original_video_path, nas_file_path, - max_clip_duration, candidate_clip_count, ai_preference, ai_prompt_preset_id, auto_mode, + max_clip_duration, candidate_clip_count, selection_profile, final_clip_target, + ai_preference, ai_prompt_preset_id, auto_mode, auto_config_json, status, progress, error_message, last_error, is_deleted, deleted_at, created_at, updated_at FROM tasks @@ -518,8 +528,10 @@ def list_clip_candidates(task_id: str) -> list[dict]: with get_connection() as connection: rows = connection.execute( """ - SELECT id, task_id, clip_key, title, start_time, end_time, duration_seconds, summary, - reason, highlight_reason, spread_value, suggested_editing, confidence_score, + SELECT id, task_id, clip_key, title, start_time, end_time, duration_seconds, cover_time_seconds, + summary, reason, highlight_reason, spread_value, suggested_editing, confidence_score, + quality_tier, quality_score, text_quality_score, humor_score, completeness_score, + audio_reaction_score, topic_key, key_moment_time, quality_evidence_json, rejection_reason, selected_by_default, enabled, reviewed, is_deleted, deleted_at, created_at, updated_at FROM clip_candidates WHERE task_id = ? AND is_deleted = 0 @@ -532,6 +544,12 @@ def list_clip_candidates(task_id: str) -> list[dict]: clip = dict(row) highlight_reason = clip.get("highlight_reason") or clip.get("reason") or "" confidence_score = clip.get("confidence_score") or 0 + try: + quality_evidence = json.loads(clip.get("quality_evidence_json") or "{}") + except (TypeError, json.JSONDecodeError): + quality_evidence = {} + if not isinstance(quality_evidence, dict): + quality_evidence = {} clips.append( { **clip, @@ -542,6 +560,16 @@ def list_clip_candidates(task_id: str) -> list[dict]: "suggested_editing": clip.get("suggested_editing") or "", "confidence_score": float(confidence_score), "confidence_percent": int(round(float(confidence_score) * 100)), + "quality_tier": clip.get("quality_tier") or "", + "quality_score": float(clip.get("quality_score") or 0), + "text_quality_score": float(clip.get("text_quality_score") or 0), + "humor_score": float(clip.get("humor_score") or 0), + "completeness_score": float(clip.get("completeness_score") or 0), + "audio_reaction_score": float(clip.get("audio_reaction_score") or 0), + "topic_key": clip.get("topic_key") or "", + "key_moment_time": clip.get("key_moment_time") or "", + "quality_evidence": quality_evidence, + "rejection_reason": clip.get("rejection_reason") or "", "ai_source_label": ai_source_label, "selected_by_default": bool(clip.get("selected_by_default")), "enabled": bool(clip.get("enabled")), @@ -757,8 +785,10 @@ def list_enabled_clip_candidates(task_id: str) -> list[dict]: with get_connection() as connection: rows = connection.execute( """ - SELECT id, task_id, clip_key, title, start_time, end_time, duration_seconds, summary, - reason, highlight_reason, spread_value, suggested_editing, confidence_score, + SELECT id, task_id, clip_key, title, start_time, end_time, duration_seconds, cover_time_seconds, + summary, reason, highlight_reason, spread_value, suggested_editing, confidence_score, + quality_tier, quality_score, text_quality_score, humor_score, completeness_score, + audio_reaction_score, topic_key, key_moment_time, quality_evidence_json, rejection_reason, selected_by_default, enabled, reviewed, is_deleted, deleted_at, created_at, updated_at FROM clip_candidates WHERE task_id = ? AND enabled = 1 AND is_deleted = 0 @@ -811,6 +841,7 @@ def list_output_clips(task_id: str) -> list[dict]: clip_candidates.start_time AS clip_start_time, clip_candidates.end_time AS clip_end_time, clip_candidates.duration_seconds AS clip_duration_seconds, + clip_candidates.cover_time_seconds AS cover_time_seconds, clip_candidates.summary AS clip_summary, clip_candidates.enabled AS clip_enabled, subtitle_jobs.id AS subtitle_job_id, diff --git a/app/services/video_cut_workflow_service.py b/app/services/video_cut_workflow_service.py index a3d84d5..881cef5 100644 --- a/app/services/video_cut_workflow_service.py +++ b/app/services/video_cut_workflow_service.py @@ -155,7 +155,7 @@ def _resolve_final_cut_status(results: list[CutResult]) -> tuple[TaskStatus, str # ---------- 切片主流程 ---------- -def process_task_video_cuts(task_id: str) -> dict: +def process_task_video_cuts(task_id: str, *, sync_publish_jobs: bool = True) -> dict: from app.services.task_service import ( get_status_label, get_task, @@ -215,6 +215,7 @@ def process_task_video_cuts(task_id: str) -> dict: final_status, final_error = _resolve_final_cut_status(results) + publish_sync = None if final_status == TaskStatus.failed: # 全部失败:不激活新 run,旧 active 保持不变 _fail_cut_run(cut_run_id, final_error or "全部切片失败") @@ -225,6 +226,22 @@ def process_task_video_cuts(task_id: str) -> dict: _activate_cut_run(task_id, cut_run_id) update_task_status(task_id, final_status, final_error) append_task_log(task_id, f"自动切割结束:{get_status_label(final_status.value)}") + if sync_publish_jobs: + try: + from app.services.publish_service import sync_task_publish_jobs as sync_publish + + publish_sync = sync_publish( + task_id, + prefer_subtitled=False, + restore_removed=False, + ) + except Exception as exc: + publish_sync = { + "status": "partial", + "message": f"切片已生成,但发送中心自动同步失败:{exc}", + "errors": [str(exc)], + } + append_task_log(task_id, f"切片完成后的发送中心同步失败:{exc}") return { "status": final_status.value, @@ -234,5 +251,6 @@ def process_task_video_cuts(task_id: str) -> dict: "cut_run_id": cut_run_id, "cut_run_number": cut_run["run_number"], "results": [result.__dict__ for result in results], + "publish_sync": publish_sync, "task": get_task(task_id), } diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 8c2c0c4..bd6e85c 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1503,6 +1503,98 @@ fieldset input.visually-hidden-file { pointer-events: none; } +.quality-tier { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + font-size: 13px; + font-weight: 800; +} + +.quality-tier-a { + color: #11643f; + background: rgba(52, 199, 89, 0.13); +} + +.quality-tier-b { + color: #875700; + background: rgba(255, 159, 10, 0.14); +} + +.clip-quality-card { + display: grid; + gap: 10px; + margin: 14px 0; + padding: 14px; + border: 1px solid rgba(0, 122, 255, 0.13); + border-radius: 16px; + background: rgba(0, 122, 255, 0.045); +} + +.clip-quality-card p { + margin: 0; + color: var(--muted-text); + line-height: 1.65; +} + +.clip-quality-scores { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.clip-quality-scores span { + display: grid; + gap: 2px; + padding: 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.75); + color: var(--muted-text); + font-size: 12px; +} + +.clip-quality-scores strong { + color: var(--text-color); + font-size: 20px; +} + +.quality-warning { + color: #9a5a00 !important; +} + +.clip-feedback { + display: grid; + gap: 8px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-color); +} + +.clip-feedback > span { + color: var(--muted-text); + font-size: 13px; +} + +.clip-feedback > div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.clip-feedback button.is-active { + border-color: rgba(0, 122, 255, 0.55); + color: var(--primary-color); + background: rgba(0, 122, 255, 0.1); +} + +.clip-feedback .feedback-positive.is-active { + border-color: rgba(52, 199, 89, 0.55); + color: #11643f; + background: rgba(52, 199, 89, 0.12); +} + .compact-button { min-height: 34px; padding: 0 12px; @@ -4873,3 +4965,614 @@ td a, min-width: 38px; } } +/* 发送中心:紧凑列表、批量栏与排期抽屉 */ +.scheduler-health-card { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + margin-bottom: 18px; + padding: 14px 18px; + border: 1px solid rgba(124, 151, 190, 0.2); + border-radius: 16px; + background: rgba(255, 255, 255, 0.74); +} + +.scheduler-health-card div { display: flex; align-items: center; gap: 8px; } +.scheduler-health-card small { color: var(--muted); flex: 1 1 280px; } +.worker-refresh-button { margin-left: auto; } +.worker-connection-help { + flex: 1 0 100%; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 12px; + border-radius: 12px; + color: #8a5a00; + background: rgba(255, 244, 214, 0.82); +} +.worker-connection-help[hidden] { display: none; } +.worker-connection-help code { padding: 3px 7px; border-radius: 7px; color: #684200; background: rgba(255, 255, 255, 0.78); } +.health-dot { width: 9px; height: 9px; border-radius: 50%; background: #f59e0b; } +.health-dot.is-ok { background: #22c55e; box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.12); } + +.publish-center-tabs { + display: flex; + gap: 8px; + margin-bottom: 14px; + padding: 5px; + width: fit-content; + border-radius: 14px; + background: rgba(227, 235, 247, 0.72); +} + +.publish-center-tabs button { + border: 0; + border-radius: 10px; + padding: 10px 16px; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.publish-center-tabs button.active { color: var(--blue); background: #fff; box-shadow: 0 6px 18px rgba(60, 90, 130, 0.12); } +.publish-center-tabs span { margin-left: 6px; opacity: 0.7; } +.publish-center-panel[hidden] { display: none; } +.publish-compact-list { display: grid; gap: 10px; } + +.publish-compact-row { + display: grid; + grid-template-columns: 28px 104px minmax(220px, 1fr) 170px auto; + align-items: center; + gap: 14px; + padding: 12px; + border: 1px solid rgba(126, 151, 187, 0.18); + border-radius: 16px; + background: rgba(255, 255, 255, 0.82); +} + +.publish-row-thumb { width: 104px; height: 64px; border-radius: 10px; object-fit: cover; background: #111827; } +.publish-row-main { display: grid; gap: 4px; min-width: 0; } +.publish-row-main > strong, .publish-row-main > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-row-main > span, .publish-row-time small { color: var(--muted); font-size: 0.82rem; } +.publish-row-time { display: grid; gap: 4px; } +.publish-row-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } + +.publish-inline-editor { + grid-column: 2 / -1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 16px; + border-radius: 14px; + background: rgba(237, 244, 255, 0.7); +} + +.publish-inline-editor[hidden] { display: none; } +.publish-inline-editor label { display: grid; gap: 6px; } +.publish-inline-editor .span-2 { grid-column: 1 / -1; } +.publish-inline-editor textarea { min-height: 92px; } +.publish-advanced-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 12px; } + +.publish-selection-bar { + position: fixed; + z-index: 80; + left: calc(50% + 110px); + bottom: 24px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border: 1px solid rgba(90, 130, 185, 0.25); + border-radius: 18px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 18px 50px rgba(30, 55, 90, 0.2); + backdrop-filter: blur(18px); +} + +.publish-selection-bar[hidden], .schedule-drawer[hidden], .schedule-drawer-backdrop[hidden] { display: none; } +.schedule-drawer-backdrop { position: fixed; z-index: 89; inset: 0; background: rgba(15, 23, 42, 0.22); } +.schedule-drawer { position: fixed; z-index: 90; top: 0; right: 0; width: min(440px, 94vw); height: 100vh; padding: 24px; overflow-y: auto; background: #f9fbff; box-shadow: -20px 0 50px rgba(30, 55, 90, 0.2); } +.schedule-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; } +.schedule-drawer form { display: grid; gap: 16px; } +.schedule-drawer form > label, .schedule-window-grid label { display: grid; gap: 7px; } +.schedule-start-field, .schedule-start-field > label { display: grid; gap: 7px; } +.schedule-after-latest-button { justify-self: start; margin-top: 2px; } +.schedule-latest-note { margin: 2px 0 0; line-height: 1.55; } +.schedule-latest-note.tone-red { color: var(--red); } +.schedule-latest-note.tone-blue { color: var(--blue); } +.schedule-window-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.schedule-window-help { margin: 8px 0 0; line-height: 1.55; } +.schedule-feedback { margin: 0; } +.timezone-note { padding: 10px 12px; border-radius: 10px; background: #eaf2ff; } +.schedule-preview-list { display: grid; gap: 8px; max-height: 260px; overflow-y: auto; } +.schedule-preview-list > div { display: flex; justify-content: space-between; gap: 12px; padding: 10px; border-radius: 10px; background: #fff; } +.schedule-preview-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.schedule-preview-list time { flex: none; color: var(--blue); } +.publish-history-table { display: grid; gap: 8px; } +.publish-history-row { display: grid; grid-template-columns: 150px 80px minmax(200px, 1fr) 150px 110px auto; gap: 12px; align-items: center; padding: 12px; border-bottom: 1px solid rgba(126, 151, 187, 0.18); } +.publish-history-row small { grid-column: 3 / -1; } + +@media (max-width: 960px) { + .publish-compact-row { grid-template-columns: 28px 86px 1fr; } + .publish-row-thumb { width: 86px; } + .publish-row-time, .publish-row-actions { grid-column: 3; } + .publish-inline-editor { grid-column: 1 / -1; } + .publish-selection-bar { left: 50%; width: calc(100% - 28px); overflow-x: auto; } + .publish-history-row { grid-template-columns: 1fr 1fr; } +} + +/* v1.5 发送中心:内容准备、排期计划、执行记录 */ +.publish-content-list, +.publish-plan-list, +.publish-execution-list { + display: grid; + gap: 12px; +} + +.publish-content-list { gap: 16px; } + +.publish-content-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.publish-content-header [data-backfill-covers][data-loading="true"] { + cursor: wait; + opacity: 0.72; +} + +.publish-task-group { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid rgba(126, 151, 187, 0.22); + border-radius: 20px; + background: rgba(247, 250, 255, 0.72); +} + +.publish-task-group[hidden] { display: none; } + +.publish-task-group-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 4px 4px 2px; +} + +.publish-task-group-identity { min-width: 0; } +.publish-task-group-identity h3 { margin: 2px 0 6px; } +.publish-task-group-identity p:not(.eyebrow) { margin: 0 0 5px; color: var(--muted); } +.publish-task-group-identity p strong { + display: inline-block; + max-width: min(680px, 68vw); + overflow: hidden; + color: var(--text); + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; +} +.publish-task-group-identity small { color: var(--muted); } +.publish-task-group-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; } +.publish-task-group-body { display: grid; gap: 12px; } +.publish-task-group-body[hidden] { display: none; } + +.publish-content-card { + display: grid; + grid-template-columns: 28px 150px minmax(0, 1fr); + gap: 16px; + align-items: start; + padding: 16px; + border: 1px solid rgba(126, 151, 187, 0.18); + border-radius: 18px; + background: rgba(255, 255, 255, 0.84); +} + +.publish-content-card[hidden], +.publish-plan-row[hidden], +.publish-execution-row[hidden] { display: none; } + +.publish-content-media { display: grid; gap: 8px; } +.publish-content-media video, +.publish-content-media img { + width: 150px; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 12px; + background: #111827; +} +.publish-content-media img { max-height: 84px; } +.publish-content-form { min-width: 0; } +.publish-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } +.publish-card-heading > div { display: grid; gap: 3px; min-width: 0; } +.publish-card-heading small { color: var(--muted); } +.publish-card-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-card-heading .publish-card-heading-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 10px; +} +.text-button.danger { color: var(--red); } + +.publish-content-grid, +.publish-bilibili-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.publish-content-grid label, +.publish-bilibili-fields label, +.publish-account-create label, +.compact-filter { display: grid; gap: 6px; } +.publish-content-grid .span-2, +.publish-bilibili-fields.span-2, +.publish-bilibili-fields .span-2 { grid-column: 1 / -1; } +.publish-content-grid textarea { min-height: 88px; resize: vertical; } +.publish-content-form .button-row { margin-top: 14px; } + +.publish-plan-header, +.publish-plan-row { + display: grid; + grid-template-columns: 28px 150px minmax(220px, 1fr) 80px 130px 100px minmax(220px, auto); + gap: 12px; + align-items: center; +} +.publish-plan-header, +.publish-execution-header { + padding: 0 12px 8px; + color: var(--muted); + font-size: 0.78rem; +} +.publish-plan-row { + padding: 12px; + border: 1px solid rgba(126, 151, 187, 0.18); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); +} +.publish-plan-video { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 10px; min-width: 0; } +.publish-plan-video video { width: 88px; height: 52px; border-radius: 9px; object-fit: cover; background: #111827; } +.publish-plan-video strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-send-readiness { + flex: 1 0 100%; + color: #9a6400; + text-align: right; + line-height: 1.35; +} +.publish-send-readiness.is-ready { color: #18794e; } +.publish-send-readiness.is-blocked { color: #9a6400; } +.publish-execution-row [data-repair-account-select] { min-width: 150px; } + +.publish-platform-switch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} +.publish-platform-context { + display: grid; + grid-template-columns: minmax(220px, 0.7fr) minmax(440px, 1.3fr); + align-items: center; + gap: 22px; + margin: 18px 0; + padding: 18px; + border: 1px solid rgba(126, 151, 187, 0.2); + border-radius: 20px; + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 14px 34px rgba(34, 77, 128, 0.07); +} +.publish-platform-context h2 { margin: 3px 0 6px; } +.publish-platform-context p:last-child { margin: 0; color: var(--muted); } +.publish-platform-context .publish-platform-switch { margin-bottom: 0; } +.publish-locked-value { + display: flex; + align-items: center; + min-height: 44px; + padding: 0 13px; + border: 1px solid rgba(126, 151, 187, 0.2); + border-radius: 12px; + color: #21436e; + background: #f3f7fc; +} +.publish-platform-card { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 15px; + border: 1px solid rgba(126, 151, 187, 0.2); + border-radius: 17px; + text-align: left; + color: var(--text); + background: rgba(255, 255, 255, 0.76); + cursor: pointer; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} +.publish-platform-card:hover { transform: translateY(-1px); border-color: rgba(38, 118, 255, 0.34); } +.publish-platform-card.is-active { border-color: rgba(38, 118, 255, 0.54); background: linear-gradient(145deg, #ffffff, #edf5ff); box-shadow: 0 12px 28px rgba(38, 118, 255, 0.13); } +.publish-platform-card > span:nth-child(2) { display: grid; gap: 4px; min-width: 0; } +.publish-platform-card small { color: var(--muted); } +.platform-card-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; color: #fff; font-weight: 800; } +.platform-card-icon.douyin { background: linear-gradient(145deg, #222936, #0f172a); box-shadow: inset 3px 0 #24f1ff, inset -3px 0 #ff2d55; } +.platform-card-icon.bilibili { background: linear-gradient(145deg, #24a9e8, #1689c6); } +.platform-card-action { color: var(--blue); font-size: 0.84rem; } + +.publish-calendar-card { margin-bottom: 20px; padding: 16px; border: 1px solid rgba(126, 151, 187, 0.18); border-radius: 18px; background: rgba(250, 252, 255, 0.86); } +.publish-calendar-toolbar, .publish-plan-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.publish-calendar-toolbar { margin-bottom: 14px; } +.publish-calendar-toolbar h3, .publish-plan-section-heading h3 { margin: 2px 0 0; } +.calendar-navigation { flex-wrap: nowrap; } +.calendar-navigation .secondary-button { white-space: nowrap; } +.publish-calendar-scroll { overflow-x: auto; padding-bottom: 2px; } +.publish-calendar-weekdays, .publish-calendar-grid { min-width: 700px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } +.publish-calendar-weekdays span { padding: 7px 8px; color: var(--muted); text-align: center; font-size: 0.78rem; } +.publish-calendar-grid { overflow: hidden; border-top: 1px solid rgba(126, 151, 187, 0.18); border-left: 1px solid rgba(126, 151, 187, 0.18); border-radius: 12px; } +.publish-calendar-day { min-height: 96px; padding: 8px; border-right: 1px solid rgba(126, 151, 187, 0.18); border-bottom: 1px solid rgba(126, 151, 187, 0.18); background: rgba(255, 255, 255, 0.82); } +.publish-calendar-day.is-outside { color: #a9b4c5; background: rgba(244, 247, 251, 0.72); } +.publish-calendar-day.is-today { box-shadow: inset 0 0 0 2px rgba(38, 118, 255, 0.45); } +.calendar-day-number { display: inline-grid; place-items: center; width: 26px; height: 26px; margin-bottom: 3px; border-radius: 8px; font-size: 0.82rem; } +.publish-calendar-day.is-today .calendar-day-number { color: #fff; background: var(--blue); } +.calendar-job-chip { display: block; width: 100%; margin-top: 4px; padding: 5px 6px; overflow: hidden; border: 0; border-radius: 7px; color: #1459b8; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 0.7rem; background: #e8f2ff; cursor: pointer; } +.calendar-job-chip:hover { background: #d9eaff; } +.calendar-job-more { display: block; margin-top: 5px; color: var(--muted); font-size: 0.68rem; } +.publish-plan-section-heading { margin: 0 2px 14px; } +.publish-plan-section-heading > div { min-width: 160px; } +.publish-plan-section-heading > small { color: var(--muted); text-align: right; } +.publish-plan-empty { padding: 30px 18px; border: 1px dashed rgba(126, 151, 187, 0.28); border-radius: 14px; color: var(--muted); text-align: center; background: rgba(247, 250, 255, 0.7); } +.publish-plan-empty[hidden] { display: none; } +.publish-plan-row.is-calendar-focus { border-color: rgba(38, 118, 255, 0.62); box-shadow: 0 0 0 4px rgba(38, 118, 255, 0.1); } + +.publish-execution-header, +.publish-execution-row { + display: grid; + grid-template-columns: + 34px + minmax(140px, 1fr) + minmax(78px, 0.62fr) + minmax(104px, 0.78fr) + minmax(118px, 0.9fr) + minmax(86px, 0.62fr) + minmax(180px, 1.3fr); + gap: 10px; + align-items: center; +} +.publish-execution-row { + padding: 13px 12px; + border-bottom: 1px solid rgba(126, 151, 187, 0.18); +} +.publish-execution-row time { font-size: 0.8rem; color: var(--muted); } +.publish-error-detail { grid-column: 1 / -1; padding: 10px 12px; border-radius: 10px; background: rgba(255, 238, 238, 0.72); } +.publish-error-detail p { margin: 8px 0; color: #9f2e2e; } +.publish-error-detail code { word-break: break-word; } + +.publish-history-heading { + align-items: flex-start; + gap: 18px; +} +.publish-history-view-switch { + display: flex; + gap: 8px; + padding: 4px; + border-radius: 12px; + background: #eef3fa; +} +.publish-history-view-switch .secondary-button { + border-color: transparent; + background: transparent; + box-shadow: none; +} +.publish-history-view-switch .secondary-button.is-active { + border-color: rgba(38, 118, 255, 0.2); + color: var(--blue); + background: #fff; + box-shadow: 0 6px 16px rgba(34, 77, 128, 0.1); +} +.publish-history-calendar-card[hidden], +.publish-history-batch-bar[hidden], +.publish-history-pagination[hidden], +[data-history-batch-hide][hidden], +[data-history-batch-restore][hidden], +[data-history-clear-date][hidden] { + display: none !important; +} +.publish-history-legend { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin: -4px 0 12px; +} +.publish-history-legend span, +.history-calendar-statuses small { + padding: 3px 6px; + border-radius: 999px; + color: #536176; + background: #edf1f6; + font-size: 0.68rem; + font-weight: 750; +} +.publish-history-legend .tone-blue, +.history-calendar-statuses .tone-blue { color: #1459b8; background: #e8f2ff; } +.publish-history-legend .tone-purple, +.history-calendar-statuses .tone-purple { color: #6647b8; background: #f0eaff; } +.publish-history-legend .tone-green, +.history-calendar-statuses .tone-green { color: #18794e; background: #e8f7ef; } +.publish-history-legend .tone-red, +.history-calendar-statuses .tone-red { color: #b4232f; background: #ffeaed; } +.publish-history-legend .tone-amber, +.history-calendar-statuses .tone-amber { color: #8d6200; background: #fff4d8; } +.publish-history-calendar-day { + display: block; + color: var(--text); + text-align: left; + cursor: pointer; +} +.publish-history-calendar-day:hover { background: #f3f8ff; } +.publish-history-calendar-day.is-selected { + box-shadow: inset 0 0 0 2px rgba(38, 118, 255, 0.72); + background: #edf5ff; +} +.history-calendar-total { + display: block; + margin: 1px 0 5px; + color: var(--muted); + font-size: 0.7rem; +} +.history-calendar-statuses { + display: flex; + flex-wrap: wrap; + gap: 3px; +} +.publish-history-toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + margin: 4px 2px 14px; +} +.publish-history-toolbar h3 { margin: 2px 0 4px; } +.publish-history-toolbar small { color: var(--muted); } +.publish-history-toolbar-actions { + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: 12px; +} +.publish-history-batch-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid rgba(38, 118, 255, 0.2); + border-radius: 14px; + background: #f1f7ff; +} +.publish-history-batch-bar > span { + flex: 1; + color: var(--muted); + font-size: 0.82rem; +} +.secondary-button.danger { color: var(--red); } +.publish-history-select { + display: grid; + place-items: center; + color: var(--muted); +} +.publish-history-identity { + display: grid; + gap: 4px; + min-width: 0; +} +.publish-history-identity > strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.publish-history-identity > small { color: var(--muted); } +.publish-history-identity .publish-send-readiness { + text-align: left; + word-break: break-word; +} +.publish-history-identity .publish-error-detail { + margin-top: 4px; + padding: 8px; +} +.publish-history-time-stack { + display: grid; + gap: 3px; +} +.publish-history-actions { + align-items: center; + justify-content: flex-start; + min-width: 0; +} +.publish-history-actions .compact-filter { min-width: 94px; } +.publish-history-actions [data-repair-account-select] { + width: 100%; + min-width: 0; + max-width: 100%; +} +.publish-history-mode { + flex: 1 0 100%; + color: var(--muted); +} +.publish-history-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-top: 18px; +} + +.publish-account-list { display: grid; gap: 10px; } +.publish-account-list article { display: grid; gap: 10px; padding: 14px; border: 1px solid rgba(126, 151, 187, 0.18); border-radius: 14px; background: #fff; } +.publish-account-list article > div:first-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; } +.publish-account-list article > div:first-child [data-account-message] { grid-column: 1 / -1; color: var(--muted); } +.publish-account-create { margin-top: 24px; padding-top: 20px; border-top: 1px solid rgba(126, 151, 187, 0.22); } +.publish-selection-bar select { min-width: 110px; } + +@media (max-width: 1180px) { + .publish-platform-context { grid-template-columns: 1fr; } + .publish-plan-header { display: none; } + .publish-plan-row { grid-template-columns: 28px 132px minmax(190px, 1fr); align-items: start; } + .publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 3; } + .publish-plan-row > :nth-child(2) { grid-column: 2; grid-row: 1; } + .publish-plan-row > :nth-child(3) { grid-column: 3; grid-row: 1; } + .publish-plan-row > :nth-child(4) { grid-column: 2; grid-row: 2; color: var(--muted); } + .publish-plan-row > :nth-child(5) { grid-column: 3; grid-row: 2; color: var(--muted); } + .publish-plan-row > :nth-child(6) { grid-column: 2; grid-row: 3; } + .publish-plan-row .publish-row-actions { grid-column: 3; grid-row: 3; justify-content: flex-start; } + .publish-plan-row .publish-send-readiness { text-align: left; } + .publish-execution-header { display: none; } + .publish-execution-row { grid-template-columns: 34px minmax(180px, 1fr) 110px 130px; align-items: start; } + .publish-execution-row .publish-history-select { grid-column: 1; grid-row: 1 / 5; } + .publish-execution-row .publish-history-identity { grid-column: 2 / -1; } + .publish-execution-row .publish-row-actions { grid-column: 2 / -1; justify-content: flex-start; } +} + +@media (max-width: 760px) { + .publish-content-header-actions { + width: 100%; + justify-content: stretch; + } + .publish-content-header-actions .secondary-button { width: 100%; } + .publish-platform-switch { grid-template-columns: 1fr; } + .publish-calendar-toolbar, .publish-plan-section-heading { align-items: flex-start; flex-direction: column; } + .publish-plan-section-heading > small { text-align: left; } + .calendar-navigation { width: 100%; } + .publish-content-card { grid-template-columns: 28px 1fr; } + .publish-content-media { grid-column: 2; grid-template-columns: 1fr 1fr; } + .publish-content-media video, .publish-content-media img { width: 100%; } + .publish-content-form { grid-column: 1 / -1; } + .publish-content-grid, .publish-bilibili-fields { grid-template-columns: 1fr; } + .publish-content-grid .span-2, .publish-bilibili-fields.span-2, .publish-bilibili-fields .span-2 { grid-column: 1; } + .publish-card-heading { display: grid; } + .publish-task-group-header { align-items: flex-start; flex-direction: column; } + .publish-task-group-actions { width: 100%; justify-content: space-between; } + .publish-task-group-identity p strong { max-width: 70vw; } + .publish-card-heading .publish-card-heading-actions { justify-content: flex-start; } + .publish-plan-row, .publish-execution-row { grid-template-columns: 28px minmax(0, 1fr); } + .publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 7; } + .publish-plan-row > :nth-child(n+2) { grid-column: 2; grid-row: auto; } + .publish-plan-video, .publish-plan-row .publish-row-actions, + .publish-execution-row .publish-history-identity, .publish-execution-row .publish-row-actions { grid-column: 2; } + .publish-plan-row .publish-plan-video, .publish-plan-row .publish-row-actions { grid-column: 2; } + .publish-send-readiness { text-align: left; } + .publish-history-heading, + .publish-history-toolbar, + .publish-history-toolbar-actions, + .publish-history-batch-bar { + align-items: stretch; + flex-direction: column; + } + .publish-history-view-switch { width: 100%; } + .publish-history-view-switch .secondary-button { flex: 1; } + .publish-history-toolbar-actions .compact-filter { width: 100%; } +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 543b51b..90c8726 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,3 +1,36 @@ +async function apiFetch(url, options = {}) { + const requestOptions = { ...options }; + const headers = new Headers(options.headers || {}); + const token = document.querySelector('meta[name="local-admin-token"]')?.content || ""; + if (options.body && !(options.body instanceof FormData) && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + requestOptions.headers = headers; + const response = await fetch(url, requestOptions); + let data = {}; + try { + data = await response.json(); + } catch (_error) { + data = {}; + } + if (!response.ok) { + const detail = data.detail; + const message = typeof detail === "object" && detail + ? (detail.message || data.message || `请求失败(HTTP ${response.status})`) + : (detail || data.message || `请求失败(HTTP ${response.status})`); + const error = new Error(message); + error.status = response.status; + error.details = typeof detail === "object" && detail ? detail : null; + throw error; + } + return data; +} + +window.apiFetch = apiFetch; + const newTaskForm = document.querySelector("#new-task-form"); const newTaskAutoMode = newTaskForm?.querySelector("input[name='auto_mode']"); const newTaskSubmitButton = document.querySelector("#new-task-submit-button"); @@ -31,8 +64,10 @@ if (newTaskForm) { const uploadData = new FormData(); uploadData.append("task_name", payload.task_name || ""); uploadData.append("platform", payload.platform || "general"); - uploadData.append("max_clip_duration", payload.max_clip_duration || "5"); - uploadData.append("candidate_clip_count", payload.candidate_clip_count || "5"); + uploadData.append("max_clip_duration", payload.max_clip_duration || "10"); + uploadData.append("candidate_clip_count", payload.candidate_clip_count || "12"); + uploadData.append("selection_profile", payload.selection_profile || "general"); + uploadData.append("final_clip_target", payload.final_clip_target || "5"); uploadData.append("ai_preference", ""); uploadData.append("auto_mode", payload.auto_mode === "true" ? "true" : "false"); uploadData.append("auto_metadata_use_ai", "false"); @@ -292,6 +327,8 @@ const aiProcessResult = document.querySelector("#ai-process-result"); const aiAnalysisSummary = document.querySelector("#ai-analysis-summary"); const aiCandidateCountPill = document.querySelector("#ai-candidate-count-pill"); const aiCandidateCountInput = document.querySelector("#ai-candidate-count-input"); +const aiSelectionProfile = document.querySelector("#ai-selection-profile"); +const aiFinalClipTarget = document.querySelector("#ai-final-clip-target"); const showAiHistoryButton = document.querySelector("#show-ai-history-button"); const refreshAiHistoryButton = document.querySelector("#refresh-ai-history-button"); const aiAnalysisHistory = document.querySelector("#ai-analysis-history"); @@ -461,7 +498,7 @@ async function refreshAiAnalysisHistory() { async function saveTaskCandidateClipCount() { if (!aiAnalysisForm || !aiCandidateCountInput) return null; const taskId = aiAnalysisForm.dataset.taskId; - const count = Number(aiCandidateCountInput.value || 5); + const count = Number(aiCandidateCountInput.value || 12); if (!Number.isInteger(count) || count < 1 || count > 50) { throw new Error("候选片段数量必须是 1 到 50 之间的整数。"); } @@ -543,6 +580,7 @@ if (saveAiPromptsButton && aiAnalysisForm) { try { const data = await saveTaskAiPromptSettings(); await saveTaskCandidateClipCount(); + await saveTaskSelectionSettings(); if (aiProcessResult) aiProcessResult.textContent = data.message || "AI Prompt 方案已保存。"; } catch (error) { if (aiProcessResult) aiProcessResult.textContent = `保存失败:${error.message}`; @@ -586,6 +624,7 @@ document.querySelectorAll(".js-ai-process-action").forEach((button) => { try { await saveTaskAiPromptSettings(); await saveTaskCandidateClipCount(); + await saveTaskSelectionSettings(); pollAiAnalysisStatus(true).catch(() => {}); const response = await fetch(`/api/tasks/${taskId}/process/ai?provider=${provider}`, { method: "POST", @@ -786,6 +825,40 @@ async function deleteClipCard(card, button) { } } +async function saveClipFeedback(card, button) { + if (!clipReviewForm || !card || !button) return; + const taskId = clipReviewForm.dataset.taskId; + const clipId = card.dataset.clipId; + const decision = button.dataset.feedbackDecision; + const reasonCode = button.dataset.feedbackReason; + const feedbackButtons = Array.from(card.querySelectorAll("[data-feedback-decision]")); + feedbackButtons.forEach((item) => { item.disabled = true; }); + showClipReviewMessage("正在记录你的审片判断...", "info"); + + try { + const response = await fetch(`/api/tasks/${taskId}/clips/${clipId}/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ decision, reason_code: reasonCode }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "保存反馈失败"); + } + feedbackButtons.forEach((item) => { + item.classList.toggle("is-active", item === button); + item.setAttribute("aria-pressed", item === button ? "true" : "false"); + }); + const enabledInput = card.querySelector("[name='enabled']"); + if (enabledInput) enabledInput.checked = Boolean(data.enabled); + showClipReviewMessage(data.message || "反馈已保存。", "success"); + } catch (error) { + showClipReviewMessage(`保存反馈失败:${error.message}`, "error"); + } finally { + feedbackButtons.forEach((item) => { item.disabled = false; }); + } +} + function timeTextToSeconds(value) { const parts = String(value || "") .trim() @@ -1218,6 +1291,12 @@ document.querySelectorAll("[data-delete-trigger]").forEach((button) => { }); }); +document.querySelectorAll("[data-feedback-decision]").forEach((button) => { + button.addEventListener("click", () => { + saveClipFeedback(button.closest("[data-clip-card]"), button); + }); +}); + if (closeTranscriptDrawerButton) { closeTranscriptDrawerButton.addEventListener("click", closeTranscriptDrawer); } @@ -1317,7 +1396,11 @@ if (generateClipsButton) { if (!response.ok) { throw new Error(data.detail || "生成切片请求失败"); } - showClipReviewMessage(`${data.message || "切片生成完成。"} 可进入“字幕推送”继续加字幕、打码和发布配置。`, "success"); + const syncMessage = data.publish_sync?.message ? ` ${data.publish_sync.message}` : ""; + showClipReviewMessage( + `${data.message || "切片生成完成。"}${syncMessage} 可进入“字幕推送”继续处理,或查看本任务发送内容。`, + data.publish_sync?.status === "partial" ? "error" : "success", + ); } catch (error) { showClipReviewMessage(`生成切片失败:${error.message}`, "error"); } finally { @@ -1327,26 +1410,78 @@ if (generateClipsButton) { }); } +async function saveTaskSelectionSettings() { + if (!aiAnalysisForm || !aiSelectionProfile || !aiFinalClipTarget) return null; + const finalTarget = Number(aiFinalClipTarget.value || 5); + if (!Number.isInteger(finalTarget) || finalTarget < 1 || finalTarget > 12) { + throw new Error("最终启用目标必须是 1 到 12 之间的整数。"); + } + const response = await fetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/selection-settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + selection_profile: aiSelectionProfile.value || "general", + final_clip_target: finalTarget, + }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "选片设置保存失败"); + return data; +} + +document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { + button.addEventListener("click", async () => { + const taskId = button.dataset.taskId; + if (!taskId) return; + const originalText = button.textContent; + const preferSubtitled = button.dataset.preferSubtitled === "true"; + button.disabled = true; + button.textContent = "正在同步..."; + try { + const data = await window.apiFetch( + `/api/publish/tasks/${encodeURIComponent(taskId)}/sync?prefer_subtitled=${preferSubtitled ? "true" : "false"}`, + { method: "POST" }, + ); + const summary = document.querySelector("[data-publish-link-summary]"); + if (summary) { + summary.innerHTML = `发送中心关联:${data.link_state?.label || "同步完成"}${data.message || ""}`; + } + showClipReviewMessage(data.message || "发送中心同步完成。", data.status === "partial" ? "error" : "success"); + if (!document.querySelector("#process-result")) { + window.alert(data.message || "发送中心同步完成。"); + } + } catch (error) { + const message = `同步发送中心失败:${error.message}`; + showClipReviewMessage(message, "error"); + if (!document.querySelector("#process-result")) window.alert(message); + } finally { + button.disabled = false; + button.textContent = originalText; + } + }); +}); + document.querySelectorAll(".js-hide-task").forEach((button) => { button.addEventListener("click", async () => { const taskTitle = button.dataset.taskTitle || "这条任务"; - const confirmed = window.confirm(`确认把“${taskTitle}”移入 E 盘回收站吗?\n\n这会从列表隐藏任务,并把对应项目文件夹移动到 E:\\直播间切片工作流存储\\_回收站,不会删除原视频、切片文件和任务目录。`); + const confirmed = window.confirm(`确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\nNAS 或任务目录外的原始视频不会被删除。`); if (!confirmed) return; const originalText = button.textContent; button.disabled = true; - button.textContent = "移动中..."; + button.textContent = "删除中..."; try { const response = await fetch(`/api/tasks/${button.dataset.taskId}`, { method: "DELETE" }); const data = await response.json(); if (!response.ok) { - throw new Error(data.detail || "移入回收站失败"); + throw new Error(data.detail || "永久删除失败"); } - window.alert(data.message || "任务已移入回收站。"); + const externalNotice = data.external_source_preserved ? "\n\n任务目录外的原始视频已保留。" : ""; + window.alert(`${data.message || "任务已永久删除。"}${externalNotice}`); window.location.reload(); } catch (error) { - window.alert(`移入回收站失败:${error.message}`); + window.alert(`永久删除失败:${error.message}`); } finally { button.disabled = false; button.textContent = originalText; @@ -1896,838 +2031,4 @@ if (aiConfigForm) { }); } -document.querySelectorAll("[data-publish-tab]").forEach((tab) => { - tab.addEventListener("click", () => { - const platform = tab.dataset.publishTab; - document.querySelectorAll("[data-publish-tab]").forEach((item) => { - item.classList.toggle("active", item === tab); - }); - document.querySelectorAll("[data-publish-panel]").forEach((panel) => { - panel.classList.toggle("active", panel.dataset.publishPanel === platform); - }); - }); -}); - -function setPublishMessage(node, message, tone = "info") { - if (!node) return; - node.textContent = message; - node.dataset.tone = tone; -} - -function publishFormPayload(form, submitter = null) { - const formData = new FormData(form); - const payload = Object.fromEntries(formData.entries()); - if (submitter?.dataset.publishMode) { - payload.publish_mode = submitter.dataset.publishMode; - } - if (submitter?.dataset.batchMode) { - payload.publish_mode = submitter.dataset.batchMode; - } - payload.allow_download = Boolean(form.elements.allow_download?.checked); - payload.cover_time_seconds = Number(payload.cover_time_seconds || 0); - return payload; -} - -function setCoverPreview(form, coverUrl) { - const preview = form.querySelector("[data-cover-preview]"); - const image = preview?.querySelector("img"); - const emptyText = preview?.querySelector("span"); - if (!preview || !image || !emptyText) return; - if (coverUrl) { - image.src = `${coverUrl}?t=${Date.now()}`; - image.hidden = false; - emptyText.hidden = true; - preview.classList.add("has-cover"); - return; - } - image.removeAttribute("src"); - image.hidden = true; - emptyText.hidden = false; - preview.classList.remove("has-cover"); -} - -document.querySelectorAll("[data-publish-config-form]").forEach((form) => { - form.addEventListener("submit", async (event) => { - event.preventDefault(); - const resultNode = form.querySelector("[data-publish-config-result]"); - const submitButton = form.querySelector("button[type='submit']"); - const payload = publishFormPayload(form); - submitButton.disabled = true; - setPublishMessage(resultNode, "正在保存平台配置..."); - - try { - const response = await fetch(`/api/publish/platforms/${form.dataset.platform}/config`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "保存失败"); - } - setPublishMessage(resultNode, data.message || "配置已保存。", "success"); - } catch (error) { - setPublishMessage(resultNode, `保存失败:${error.message}`, "error"); - } finally { - submitButton.disabled = false; - } - }); -}); - -document.querySelectorAll("[data-test-publish-config]").forEach((button) => { - button.addEventListener("click", async () => { - const form = button.closest("[data-publish-config-form]"); - const resultNode = form?.querySelector("[data-publish-config-result]"); - button.disabled = true; - setPublishMessage(resultNode, "正在检查配置..."); - - try { - const response = await fetch(`/api/publish/platforms/${button.dataset.platform}/test`, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "检查失败"); - } - setPublishMessage(resultNode, data.message || "配置检查完成。", data.status === "ok" ? "success" : "error"); - } catch (error) { - setPublishMessage(resultNode, `检查失败:${error.message}`, "error"); - } finally { - button.disabled = false; - } - }); -}); - -document.querySelectorAll("[data-douyin-oauth]").forEach((button) => { - button.addEventListener("click", async () => { - const form = button.closest("[data-publish-config-form]"); - const resultNode = form?.querySelector("[data-publish-config-result]"); - button.disabled = true; - setPublishMessage(resultNode, "正在生成抖音授权链接..."); - - try { - const response = await fetch("/api/publish/douyin/oauth-url"); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "生成授权链接失败"); - } - window.open(data.url, "_blank", "noopener,noreferrer"); - setPublishMessage(resultNode, "已打开抖音授权页。授权后会回到本地发布中心。", "success"); - } catch (error) { - setPublishMessage(resultNode, `授权失败:${error.message}`, "error"); - } finally { - button.disabled = false; - } - }); -}); - -document.querySelectorAll("[data-publish-account-form]").forEach((form) => { - form.addEventListener("submit", async (event) => { - event.preventDefault(); - const resultNode = form.querySelector("[data-publish-account-result]"); - const submitButton = form.querySelector("button[type='submit']"); - const payload = publishFormPayload(form); - submitButton.disabled = true; - setPublishMessage(resultNode, "正在保存账号..."); - - try { - const response = await fetch("/api/publish/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "账号保存失败"); - } - setPublishMessage(resultNode, data.message || "账号已保存,正在刷新...", "success"); - window.setTimeout(() => window.location.reload(), 600); - } catch (error) { - setPublishMessage(resultNode, `账号保存失败:${error.message}`, "error"); - } finally { - submitButton.disabled = false; - } - }); -}); - -document.querySelectorAll("[data-publish-job-form]").forEach((form) => { - form.querySelectorAll("input[name='video_source']").forEach((input) => { - input.addEventListener("change", () => { - form.elements.cover_file_path.value = ""; - form.elements.cover_mode.value = "auto"; - setCoverPreview(form, ""); - setPublishMessage(form.querySelector("[data-cover-result]"), "视频版本已切换,请重新生成封面。"); - }); - }); -}); - -document.querySelectorAll("[data-generate-cover]").forEach((button) => { - button.addEventListener("click", async () => { - const form = button.closest("[data-publish-job-form]"); - if (!form) return; - const resultNode = form.querySelector("[data-cover-result]"); - const payload = publishFormPayload(form); - payload.cover_mode = "time"; - if (!String(payload.title || "").trim()) { - setPublishMessage(resultNode, "请先填写标题,封面会使用这个标题作为大字。", "error"); - return; - } - - const originalText = button.textContent; - button.disabled = true; - button.textContent = "生成中..."; - setPublishMessage(resultNode, "正在截取视频画面并生成封面..."); - - try { - const response = await fetch("/api/publish/covers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - task_id: payload.task_id, - output_clip_id: payload.output_clip_id, - video_source: payload.video_source, - title: payload.title, - cover_time_seconds: payload.cover_time_seconds, - }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "封面生成失败"); - } - form.elements.cover_file_path.value = data.cover_file_path || ""; - form.elements.cover_mode.value = "time"; - setCoverPreview(form, data.cover_media_url); - setPublishMessage(resultNode, data.message || "封面已生成。", "success"); - } catch (error) { - setPublishMessage(resultNode, `封面生成失败:${error.message}`, "error"); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll("[data-publish-job-form]").forEach((form) => { - form.addEventListener("submit", async (event) => { - event.preventDefault(); - const submitter = event.submitter; - const payload = publishFormPayload(form, submitter); - const resultNode = form.querySelector("[data-publish-job-result]"); - const originalText = submitter?.textContent || ""; - - if (payload.publish_mode === "api_publish") { - const platformLabel = form.dataset.platform === "douyin" ? "抖音" : "B站"; - const confirmed = window.confirm(`确认发布到真实${platformLabel}平台吗?\n\n请确认账号、标题、视频版本和标签都已经检查过。`); - if (!confirmed) return; - } - - if (submitter) { - submitter.disabled = true; - submitter.textContent = "处理中..."; - } - setPublishMessage(resultNode, "正在创建发布任务..."); - - try { - const response = await fetch("/api/publish/jobs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "创建发布任务失败"); - } - setPublishMessage(resultNode, data.message || "发布任务已创建。", data.status === "failed" ? "error" : "success"); - window.setTimeout(() => window.location.reload(), 900); - } catch (error) { - setPublishMessage(resultNode, `创建失败:${error.message}`, "error"); - } finally { - if (submitter) { - submitter.disabled = false; - submitter.textContent = originalText; - } - } - }); -}); - -document.querySelectorAll("[data-publish-batch-form]").forEach((form) => { - form.addEventListener("submit", async (event) => { - event.preventDefault(); - const submitter = event.submitter; - const platform = form.dataset.platform; - const resultNode = form.querySelector("[data-publish-batch-result]"); - const selectedIds = Array.from(document.querySelectorAll(`[data-batch-output-id='${platform}']:checked`)).map( - (item) => item.value - ); - const payload = publishFormPayload(form, submitter); - payload.platform = platform; - payload.output_clip_ids = selectedIds; - if (!selectedIds.length) { - setPublishMessage(resultNode, "请先勾选至少一条切片。", "error"); - return; - } - - const originalText = submitter?.textContent || ""; - if (submitter) { - submitter.disabled = true; - submitter.textContent = "处理中..."; - } - setPublishMessage(resultNode, "正在批量创建发布任务..."); - - try { - const response = await fetch("/api/publish/jobs/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "批量创建失败"); - } - setPublishMessage(resultNode, data.message || "批量任务已创建。", "success"); - window.setTimeout(() => window.location.reload(), 800); - } catch (error) { - setPublishMessage(resultNode, `批量创建失败:${error.message}`, "error"); - } finally { - if (submitter) { - submitter.disabled = false; - submitter.textContent = originalText; - } - } - }); -}); - -document.querySelectorAll("[data-publish-job-action]").forEach((button) => { - button.addEventListener("click", async () => { - const row = button.closest("[data-publish-job-id]"); - const jobId = row?.dataset.publishJobId; - const action = button.dataset.publishJobAction; - const endpointMap = { - retry: `/api/publish/jobs/${jobId}/retry`, - "mark-published": `/api/publish/jobs/${jobId}/mark-published`, - "mark-failed": `/api/publish/jobs/${jobId}/mark-failed`, - cancel: `/api/publish/jobs/${jobId}/cancel`, - }; - if (!jobId || !endpointMap[action]) return; - if (action === "retry" && !window.confirm("确认重试真实发布吗?")) return; - - const originalText = button.textContent; - button.disabled = true; - button.textContent = "处理中..."; - - try { - const response = await fetch(endpointMap[action], { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "操作失败"); - } - window.location.reload(); - } catch (error) { - window.alert(`操作失败:${error.message}`); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -const sendCenterMessage = document.querySelector("#send-center-message"); -const sendPublishingOverlay = document.querySelector("[data-send-publishing-overlay]"); -const sendPreviewPanel = document.querySelector("[data-send-preview-panel]"); -const publishScheduleForm = document.querySelector("[data-publish-schedule-form]"); -const publishScheduleResult = document.querySelector("[data-publish-schedule-result]"); -const scheduleSelectedCount = document.querySelector("[data-schedule-selected-count]"); - -function setSendCenterMessage(message, tone = "info") { - if (!sendCenterMessage) return; - sendCenterMessage.hidden = false; - sendCenterMessage.textContent = message; - sendCenterMessage.classList.toggle("tone-red", tone === "error"); - sendCenterMessage.classList.toggle("tone-blue", tone !== "error"); -} - -function showSendPublishingOverlay(title = "正在发布", text = "opencli 正在操作平台页面,请不要关闭 Chrome 或本地后台。") { - if (!sendPublishingOverlay) return; - const titleNode = sendPublishingOverlay.querySelector("[data-send-publishing-title]"); - const textNode = sendPublishingOverlay.querySelector("[data-send-publishing-text]"); - if (titleNode) titleNode.textContent = title; - if (textNode) textNode.textContent = text; - sendPublishingOverlay.hidden = false; -} - -function hideSendPublishingOverlay() { - if (sendPublishingOverlay) sendPublishingOverlay.hidden = true; -} - -function publishScheduleCheckboxes() { - return Array.from(document.querySelectorAll("[data-publish-schedule-checkbox]")); -} - -function selectedPublishScheduleJobIds() { - return Array.from( - new Set( - publishScheduleCheckboxes() - .filter((checkbox) => checkbox.checked) - .map((checkbox) => checkbox.value) - .filter(Boolean) - ) - ); -} - -function updateScheduleSelectedCount() { - if (!scheduleSelectedCount) return; - scheduleSelectedCount.textContent = `已选 ${selectedPublishScheduleJobIds().length} 条`; -} - -function localDatetimeValue(date) { - const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); - return offsetDate.toISOString().slice(0, 16); -} - -function setScheduleResult(message, tone = "info") { - if (!publishScheduleResult) return; - publishScheduleResult.textContent = message; - publishScheduleResult.classList.toggle("error-text", tone === "error"); -} - -function formatScheduledTimes() { - document.querySelectorAll("[data-publish-scheduled-at]").forEach((node) => { - const value = node.dataset.publishScheduledAt || ""; - if (!value) { - node.textContent = "未排期"; - return; - } - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - node.textContent = parsed.toLocaleString([], { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); - } - }); -} - -async function submitBatchSchedule(action) { - if (!publishScheduleForm) return; - const jobIds = selectedPublishScheduleJobIds(); - if (!jobIds.length) { - setScheduleResult("请先勾选至少一条未发布任务。", "error"); - return; - } - - const startValue = String(publishScheduleForm.elements.start_at?.value || ""); - if (action === "apply" && !startValue) { - setScheduleResult("请先选择起始时间。", "error"); - return; - } - const startDate = startValue ? new Date(startValue) : null; - if (action === "apply" && (!startDate || Number.isNaN(startDate.getTime()))) { - setScheduleResult("起始时间无效,请重新选择。", "error"); - return; - } - - setScheduleResult(action === "apply" ? "正在应用发布计划..." : "正在清除发布时间..."); - const response = await fetch("/api/publish/jobs/schedule-batch", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - job_ids: jobIds, - action, - start_at: startDate ? startDate.toISOString() : "", - interval_hours: Number(publishScheduleForm.elements.interval_hours?.value || 3), - daily_start_time: String(publishScheduleForm.elements.daily_start_time?.value || "09:00"), - daily_end_time: String(publishScheduleForm.elements.daily_end_time?.value || "21:00"), - }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "发布计划保存失败"); - } - setScheduleResult(data.message || "发布计划已更新。"); - reloadSendCenter(700); -} - -if (publishScheduleForm) { - const startInput = publishScheduleForm.elements.start_at; - if (startInput && !startInput.value) { - startInput.value = localDatetimeValue(new Date(Date.now() + 10 * 60 * 1000)); - } - publishScheduleForm.addEventListener("submit", async (event) => { - event.preventDefault(); - try { - await submitBatchSchedule("apply"); - } catch (error) { - setScheduleResult(`保存失败:${error.message}`, "error"); - } - }); -} - -document.querySelectorAll("[data-publish-schedule-checkbox]").forEach((checkbox) => { - checkbox.addEventListener("change", () => { - publishScheduleCheckboxes() - .filter((item) => item.value === checkbox.value) - .forEach((item) => { - item.checked = checkbox.checked; - }); - updateScheduleSelectedCount(); - }); -}); - -document.querySelector("[data-select-all-schedule]")?.addEventListener("click", () => { - const checkboxes = publishScheduleCheckboxes(); - const shouldCheck = checkboxes.some((checkbox) => !checkbox.checked); - checkboxes.forEach((checkbox) => { - checkbox.checked = shouldCheck; - }); - updateScheduleSelectedCount(); -}); - -document.querySelector("[data-clear-batch-schedule]")?.addEventListener("click", async () => { - try { - await submitBatchSchedule("clear"); - } catch (error) { - setScheduleResult(`清除失败:${error.message}`, "error"); - } -}); - -formatScheduledTimes(); -updateScheduleSelectedCount(); - -function sendJobPayload(form) { - const formData = new FormData(form); - return { - title: String(formData.get("title") || "").trim(), - description: String(formData.get("description") || "").trim(), - tags: String(formData.get("tags") || "").trim(), - visibility: String(formData.get("visibility") || "public"), - cover_file_path: String(formData.get("cover_file_path") || "").trim(), - cover_time_seconds: Number(formData.get("cover_time_seconds") || 0), - allow_download: Boolean(form.elements.allow_download?.checked), - bilibili_tid: String(formData.get("bilibili_tid") || "娱乐").trim(), - bilibili_copyright: String(formData.get("bilibili_copyright") || "original"), - bilibili_source: String(formData.get("bilibili_source") || "").trim(), - }; -} - -function previewValue(value, fallback = "") { - return String(value || fallback || "").trim(); -} - -function updateSendPreviewFromForm(form) { - if (!sendPreviewPanel || !form) return; - const card = form.closest("[data-send-card]"); - const previewImage = sendPreviewPanel.querySelector("[data-send-preview-image]"); - const previewVideo = sendPreviewPanel.querySelector("[data-send-preview-video]"); - const previewTitle = sendPreviewPanel.querySelector("[data-send-preview-title]"); - const previewTags = sendPreviewPanel.querySelector("[data-send-preview-tags]"); - const previewDescription = sendPreviewPanel.querySelector("[data-send-preview-description]"); - const coverImage = card?.querySelector("[data-cover-preview] img:not([hidden])"); - const video = card?.querySelector(".send-card-media video"); - const coverUrl = coverImage?.getAttribute("src") || ""; - const videoUrl = video?.getAttribute("src") || ""; - if (previewImage && previewVideo) { - if (coverUrl) { - previewImage.src = coverUrl; - previewImage.hidden = false; - previewVideo.hidden = true; - } else if (videoUrl) { - previewVideo.src = videoUrl; - previewVideo.hidden = false; - previewImage.hidden = true; - } - } - if (previewTitle) previewTitle.textContent = previewValue(form.elements.title?.value, "未填写标题"); - if (previewTags) previewTags.textContent = previewValue(form.elements.tags?.value, "未填写 #话题"); - if (previewDescription) { - previewDescription.textContent = previewValue(form.elements.description?.value, "未填写正文 / 简介"); - } - document.querySelectorAll("[data-send-card]").forEach((item) => item.classList.toggle("is-previewing", item === card)); -} - -function reloadSendCenter(delay = 900) { - window.setTimeout(() => window.location.reload(), delay); -} - -function activeSendJobIds() { - return Array.from(document.querySelectorAll("[data-send-job-checkbox]:checked")).map((checkbox) => checkbox.value); -} - -function updateSendFilter(filter) { - const normalizedFilter = (filter || "all").toLowerCase(); - document.querySelectorAll("[data-send-card]").forEach((card) => { - const platform = (card.dataset.platform || "").toLowerCase(); - const status = (card.dataset.status || "").toLowerCase(); - const visible = normalizedFilter === "all" || normalizedFilter === platform || normalizedFilter === status; - card.classList.toggle("is-hidden", !visible); - }); -} - -const autoPipelineMonitor = document.querySelector("[data-auto-pipeline-monitor]"); -if (autoPipelineMonitor?.dataset.running === "true") { - window.setTimeout(() => window.location.reload(), 5000); -} - -document.querySelectorAll("[data-send-filter]").forEach((button) => { - button.addEventListener("click", () => { - document.querySelectorAll("[data-send-filter]").forEach((item) => item.classList.toggle("active", item === button)); - updateSendFilter(button.dataset.sendFilter || "all"); - }); -}); - -document.querySelectorAll("[data-refresh-send-queue]").forEach((button) => { - button.addEventListener("click", async () => { - const useAi = button.dataset.useAi === "true"; - const originalText = button.textContent; - button.disabled = true; - button.textContent = useAi ? "AI 生成中..." : "刷新中..."; - setSendCenterMessage(useAi ? "正在用 AI 补齐标题、#话题和简介,并自动选择封面帧..." : "正在从已完成切片刷新发送队列,并自动选择封面帧..."); - - try { - const response = await fetch(`/api/publish/queue/refresh?use_ai=${useAi ? "true" : "false"}`, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "刷新队列失败"); - } - setSendCenterMessage(data.message || "发送队列已刷新,封面帧已自动选择。", "success"); - reloadSendCenter(); - } catch (error) { - setSendCenterMessage(`刷新失败:${error.message}`, "error"); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll("[data-send-job-form]").forEach((form) => { - form.addEventListener("focusin", () => updateSendPreviewFromForm(form)); - form.addEventListener("input", () => updateSendPreviewFromForm(form)); - form.addEventListener("change", () => updateSendPreviewFromForm(form)); - form.closest("[data-send-card]")?.addEventListener("click", () => updateSendPreviewFromForm(form)); - - form.addEventListener("submit", async (event) => { - event.preventDefault(); - const jobId = form.dataset.jobId; - const resultNode = form.querySelector("[data-send-job-result]"); - const submitter = event.submitter; - if (!jobId) { - setPublishMessage(resultNode, "这条切片还没有入队,请先刷新发送队列。", "error"); - return; - } - - const originalText = submitter?.textContent || ""; - if (submitter) { - submitter.disabled = true; - submitter.textContent = "保存中..."; - } - setPublishMessage(resultNode, "正在保存发送内容..."); - - try { - const response = await fetch(`/api/publish/jobs/${jobId}/send-content`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(sendJobPayload(form)), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "保存失败"); - } - updateSendPreviewFromForm(form); - setPublishMessage(resultNode, data.message || "发送内容已保存。", "success"); - } catch (error) { - setPublishMessage(resultNode, `保存失败:${error.message}`, "error"); - } finally { - if (submitter) { - submitter.disabled = false; - submitter.textContent = originalText; - } - } - }); -}); - -function applyCoverFrame(form, frame, button = null) { - form.elements.cover_file_path.value = frame.cover_file_path || ""; - form.elements.cover_time_seconds.value = Number(frame.cover_time_seconds || 0); - setCoverPreview(form, frame.cover_media_url || ""); - form.querySelectorAll("[data-cover-frame-option]").forEach((item) => item.classList.toggle("active", item === button)); - updateSendPreviewFromForm(form); -} - -function renderCoverFrames(form, frames) { - const list = form.querySelector("[data-cover-frame-list]"); - if (!list) return; - list.innerHTML = ""; - frames.forEach((frame, index) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "cover-frame-option"; - button.dataset.coverFrameOption = "true"; - button.innerHTML = `候选封面 ${index + 1}${Number(frame.cover_time_seconds || 0).toFixed(1)}s`; - button.addEventListener("click", () => applyCoverFrame(form, frame, button)); - list.appendChild(button); - if (index === 0) { - applyCoverFrame(form, frame, button); - } - }); -} - -document.querySelectorAll("[data-generate-cover-frames]").forEach((button) => { - button.addEventListener("click", async () => { - const form = button.closest("[data-send-job-form]"); - if (!form) return; - const resultNode = form.querySelector("[data-send-job-result]"); - const originalText = button.textContent; - button.disabled = true; - button.textContent = "生成中..."; - setPublishMessage(resultNode, "正在从视频里截取候选封面帧..."); - - try { - const response = await fetch("/api/publish/covers/frames", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - task_id: form.dataset.taskId, - output_clip_id: form.dataset.outputClipId, - video_source: "original", - title: form.elements.title?.value || "直播切片", - frame_count: 4, - }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "候选封面生成失败"); - } - renderCoverFrames(form, data.frames || []); - setPublishMessage(resultNode, data.message || "候选封面已生成,已先选中第一张;需要更换可点其他帧。", "success"); - } catch (error) { - setPublishMessage(resultNode, `封面帧生成失败:${error.message}`, "error"); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll("[data-regenerate-send-metadata]").forEach((button) => { - button.addEventListener("click", async () => { - const form = button.closest("[data-send-job-form]"); - const jobId = form?.dataset.jobId; - const resultNode = form?.querySelector("[data-send-job-result]"); - if (!form || !jobId) return; - const originalText = button.textContent; - button.disabled = true; - button.textContent = "生成中..."; - setPublishMessage(resultNode, "正在重新生成标题、#话题和简介..."); - - try { - const response = await fetch(`/api/publish/jobs/${jobId}/metadata?use_ai=true`, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "重新生成失败"); - } - const job = data.job || {}; - if (form.elements.title) form.elements.title.value = job.title || form.elements.title.value; - if (form.elements.tags) form.elements.tags.value = job.tags || ""; - if (form.elements.description) form.elements.description.value = job.description || ""; - updateSendPreviewFromForm(form); - setPublishMessage(resultNode, data.message || "AI 元数据已更新。", "success"); - } catch (error) { - setPublishMessage(resultNode, `重新生成失败:${error.message}`, "error"); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll("[data-send-single-job]").forEach((button) => { - button.addEventListener("click", async () => { - const form = button.closest("[data-send-job-form]"); - const jobId = form?.dataset.jobId; - if (!jobId) return; - if (!window.confirm("确认开始发送这一条吗?请先确认 Chrome 已登录对应平台。")) return; - const originalText = button.textContent; - button.disabled = true; - button.textContent = "发送中..."; - updateSendPreviewFromForm(form); - showSendPublishingOverlay("正在发布", "正在发送这一条,opencli 会打开平台页面并自动填写内容。"); - setSendCenterMessage("已提交单条发送任务,opencli 会使用 Chrome 登录态打开平台页面。"); - - try { - const response = await fetch(`/api/publish/jobs/${jobId}/send`, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "发送启动失败"); - } - if (data.status === "empty") { - hideSendPublishingOverlay(); - } - setSendCenterMessage(data.message || "发送任务已开始。", "success"); - reloadSendCenter(1400); - } catch (error) { - hideSendPublishingOverlay(); - setSendCenterMessage(`发送启动失败:${error.message}`, "error"); - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll("[data-start-send-queue]").forEach((button) => { - button.addEventListener("click", async () => { - const selectedIds = activeSendJobIds(); - const label = selectedIds.length ? `${selectedIds.length} 条已勾选任务` : "全部待发送/失败任务"; - if (!window.confirm(`确认开始发送 ${label} 吗?\n\n请先确认 Chrome 已登录抖音创作者中心和 B站创作中心。`)) return; - const originalText = button.textContent; - button.disabled = true; - button.textContent = "启动中..."; - showSendPublishingOverlay("正在发布", selectedIds.length ? `正在发送 ${selectedIds.length} 条已勾选任务,一次只会执行一条。` : "正在发送全部待发送任务,一次只会执行一条。"); - setSendCenterMessage("正在启动发送队列,一次只会执行一条任务。"); - - try { - const response = await fetch("/api/publish/send/start", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ job_ids: selectedIds }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || data.message || "启动队列失败"); - } - if (data.status === "empty") { - hideSendPublishingOverlay(); - } - setSendCenterMessage(data.message || "发送队列已启动。", data.status === "busy" ? "error" : "success"); - reloadSendCenter(1400); - } catch (error) { - hideSendPublishingOverlay(); - setSendCenterMessage(`启动队列失败:${error.message}`, "error"); - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll("[data-send-select-all]").forEach((checkbox) => { - checkbox.addEventListener("change", () => { - const visibleCards = Array.from(document.querySelectorAll("[data-send-card]")).filter( - (card) => !card.classList.contains("is-hidden") - ); - visibleCards.forEach((card) => { - const item = card.querySelector("[data-send-job-checkbox]"); - if (item && !item.disabled) item.checked = checkbox.checked; - }); - updateScheduleSelectedCount(); - }); -}); - -if (document.querySelector("[data-send-card][data-status='publishing'], [data-send-card][data-status='PUBLISHING']")) { - showSendPublishingOverlay("正在发布", "已有任务正在发布中,页面会自动刷新状态。"); - window.setTimeout(() => window.location.reload(), 5000); -} - -const firstSendForm = document.querySelector("[data-send-job-form]"); -if (firstSendForm) { - updateSendPreviewFromForm(firstSendForm); -} +// 发送中心页面行为由 publish-center.js 独立维护,避免全局脚本重复绑定。 diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js new file mode 100644 index 0000000..87f23a2 --- /dev/null +++ b/app/static/js/publish-center.js @@ -0,0 +1,1952 @@ +const publishCenterRoot = document.querySelector("[data-center-panel]"); + +if (publishCenterRoot) { + const APP_TIMEZONE = "Asia/Shanghai"; + const selectedJobIds = new Set(); + const messageNode = document.querySelector("#send-center-message"); + const selectionBar = document.querySelector("[data-selection-bar]"); + const selectedCountNode = document.querySelector("[data-selected-count]"); + const drawer = document.querySelector("[data-schedule-drawer]"); + const drawerBackdrop = document.querySelector("[data-schedule-backdrop]"); + const accountDrawer = document.querySelector("[data-account-drawer]"); + const accountBackdrop = document.querySelector("[data-account-backdrop]"); + const drawerCount = document.querySelector("[data-drawer-count]"); + const scheduleForm = document.querySelector("[data-schedule-form]"); + const previewList = document.querySelector("[data-schedule-preview]"); + const latestScheduleButton = document.querySelector("[data-use-latest-schedule]"); + const latestScheduleNote = document.querySelector("[data-latest-schedule-note]"); + const previewScheduleButton = document.querySelector("[data-preview-schedule]"); + const scheduleFeedbackNode = document.querySelector("[data-schedule-feedback]"); + const confirmScheduleButton = document.querySelector("[data-confirm-schedule]"); + const historyFilter = document.querySelector("[data-history-filter]"); + const historyCalendarCard = document.querySelector("[data-history-calendar-card]"); + const historyCalendarNode = document.querySelector("[data-history-calendar]"); + const historyCalendarTitle = document.querySelector("[data-history-calendar-title]"); + const historyListNode = document.querySelector("[data-history-list]"); + const historyEmpty = document.querySelector("[data-history-empty]"); + const historyListTitle = document.querySelector("[data-history-list-title]"); + const historyListSummary = document.querySelector("[data-history-list-summary]"); + const historyListEyebrow = document.querySelector("[data-history-list-eyebrow]"); + const historyClearDateButton = document.querySelector("[data-history-clear-date]"); + const historyBatchBar = document.querySelector("[data-history-batch-bar]"); + const historySelectedCount = document.querySelector("[data-history-selected-count]"); + const historyBatchHideButton = document.querySelector("[data-history-batch-hide]"); + const historyBatchRestoreButton = document.querySelector("[data-history-batch-restore]"); + const historyPagination = document.querySelector("[data-history-pagination]"); + const historyPageSummary = document.querySelector("[data-history-page-summary]"); + const historyPreviousButton = document.querySelector("[data-history-previous]"); + const historyNextButton = document.querySelector("[data-history-next]"); + const calendarNode = document.querySelector("[data-schedule-calendar]"); + const calendarTitle = document.querySelector("[data-calendar-title]"); + const scheduleEmpty = document.querySelector("[data-schedule-empty]"); + const contentEmpty = document.querySelector("[data-content-empty]"); + const platformListTitle = document.querySelector("[data-platform-list-title]"); + const schedulerHealthNode = document.querySelector("[data-scheduler-health]"); + const backfillCoversButton = document.querySelector("[data-backfill-covers]"); + let latestPreviewSignature = ""; + let latestPreviewItems = []; + let activePlatform = "douyin"; + let calendarMonth = currentBeijingMonth(); + let historyMonth = currentBeijingMonth(); + let historySelectedDate = ""; + let historyDeletedView = false; + let historyPage = 1; + let historyTotalPages = 0; + let historyRefreshFrame = 0; + let historyRefreshCalendar = false; + let historyRequestSequence = 0; + let historyRefreshInFlight = false; + let historyRefreshQueuedCalendar = false; + let historyRefreshQueuedRecords = false; + const selectedHistoryJobIds = new Set(); + let scheduleRefreshFrame = 0; + let workerAvailable = schedulerHealthNode?.dataset.workerAvailable === "true"; + let workerMessage = document.querySelector("[data-worker-message]")?.textContent?.split(" · ")[0] || "Windows 发布 Worker 未连接"; + + function showMessage(message, tone = "info") { + if (!messageNode) return; + messageNode.hidden = false; + messageNode.textContent = message; + messageNode.classList.toggle("tone-red", tone === "error"); + messageNode.classList.toggle("tone-blue", tone !== "error"); + } + + function missingCoverRows() { + return Array.from(document.querySelectorAll('[data-publish-row][data-section="content"]')).filter((row) => { + const editor = row.querySelector("[data-publish-editor]"); + const coverPath = String(editor?.elements?.cover_file_path?.value || "").trim(); + return ( + row.dataset.platform === activePlatform + && row.dataset.outputActive !== "false" + && sectionAllows("content", String(row.dataset.status || "").toUpperCase()) + && !coverPath + ); + }); + } + + function updateBackfillCoversButton() { + if (!backfillCoversButton) return; + const count = missingCoverRows().length; + const loading = backfillCoversButton.dataset.loading === "true"; + backfillCoversButton.dataset.missingCount = String(count); + backfillCoversButton.disabled = loading || count === 0; + backfillCoversButton.textContent = loading + ? `正在补充${platformLabel()} ${count} 条封面…` + : `一键补充${platformLabel()}缺失封面${count ? `(${count})` : ""}`; + } + + function beijingDatetimeValue(timestamp) { + return new Date(timestamp + 8 * 60 * 60 * 1000).toISOString().slice(0, 16); + } + + function beijingInputToTimestamp(value) { + return Date.parse(`${value}:00+08:00`); + } + + function formatBeijingTimestamp(value) { + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: APP_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(value)); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}`; + } + + function beijingDateParts(value = new Date()) { + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: APP_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(value)); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return { year: Number(values.year), month: Number(values.month), day: Number(values.day) }; + } + + function currentBeijingMonth() { + const today = beijingDateParts(); + return { year: today.year, month: today.month }; + } + + function beijingDateKey(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const parts = beijingDateParts(date); + return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`; + } + + function scheduleRows() { + return Array.from(document.querySelectorAll('[data-publish-row][data-section="schedule"]')); + } + + function setTaskGroupExpanded(group, expanded) { + if (!group) return; + group.dataset.expanded = expanded ? "true" : "false"; + const body = group.querySelector("[data-task-group-body]"); + const toggle = group.querySelector("[data-task-group-toggle]"); + if (body) body.hidden = !expanded; + if (toggle) toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); + const action = group.querySelector("[data-task-group-action]"); + if (action) action.textContent = expanded ? "收起" : "展开"; + } + + function syncContentTaskGroups() { + const groups = Array.from(document.querySelectorAll("[data-publish-task-group]")); + const visibleGroups = []; + groups.forEach((group) => { + const rows = Array.from(group.querySelectorAll('[data-publish-row][data-section="content"]')); + const visibleCount = rows.filter((row) => !row.hidden).length; + const count = group.querySelector("[data-task-group-count]"); + if (count) count.textContent = `${visibleCount} 条待准备`; + group.hidden = visibleCount === 0; + if (visibleCount > 0) visibleGroups.push(group); + if (visibleCount === 0) setTaskGroupExpanded(group, false); + }); + if (visibleGroups.length && !visibleGroups.some((group) => group.dataset.expanded === "true")) { + setTaskGroupExpanded(visibleGroups[0], true); + } + visibleGroups.forEach((group) => setTaskGroupExpanded(group, group.dataset.expanded === "true")); + if (contentEmpty) contentEmpty.hidden = visibleGroups.length > 0; + } + + function platformLabel(platform = activePlatform) { + return platform === "bilibili" ? "B站" : "抖音"; + } + + function visibilityLabel(value) { + return { public: "公开", friends: "好友可见", private: "仅自己可见" }[value] || "公开"; + } + + function sendConfirmation(row, actionLabel, visibility = "") { + const title = row?.querySelector("[data-row-title]")?.textContent?.trim() || "未命名任务"; + const account = row?.querySelector("[data-row-account]")?.textContent?.trim() + || row?.querySelector("[data-repair-account-select] option:checked")?.textContent?.trim() + || "未选择"; + const resolvedVisibility = visibility || row?.dataset.visibility || "public"; + return `${actionLabel}\n\n平台:${platformLabel(row?.dataset.platform)}\n账号:${account}\n标题:${title}\n可见范围:${visibilityLabel(resolvedVisibility)}\n\n请确认以上信息无误。`; + } + + function renderPlatformSchedule() { + const rows = scheduleRows(); + ["douyin", "bilibili"].forEach((platform) => { + const available = rows.filter((row) => ( + row.dataset.outputActive !== "false" + && sectionAllows("schedule", row.dataset.status || "") + && row.dataset.platform === platform + )); + const waiting = available.filter((row) => row.dataset.status === "WAITING").length; + const scheduled = available.filter((row) => row.dataset.status === "SCHEDULED").length; + const waitingNode = document.querySelector(`[data-platform-waiting="${platform}"]`); + const scheduledNode = document.querySelector(`[data-platform-scheduled="${platform}"]`); + if (waitingNode) waitingNode.textContent = String(waiting); + if (scheduledNode) scheduledNode.textContent = String(scheduled); + }); + document.querySelectorAll("[data-publish-platform]").forEach((button) => { + const active = button.dataset.publishPlatform === activePlatform; + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + const action = button.querySelector(".platform-card-action"); + if (action) action.textContent = active ? "当前" : "切换"; + }); + let visibleCount = 0; + rows.forEach((row) => { + const visible = ( + row.dataset.outputActive !== "false" + && sectionAllows("schedule", row.dataset.status || "") + && row.dataset.platform === activePlatform + ); + row.hidden = !visible; + if (visible) visibleCount += 1; + }); + document.querySelectorAll('[data-publish-row][data-section="content"]').forEach((row) => { + row.hidden = ( + row.dataset.outputActive === "false" + || !sectionAllows("content", row.dataset.status || "") + || row.dataset.platform !== activePlatform + ); + }); + syncContentTaskGroups(); + document.querySelectorAll("[data-account-row]").forEach((row) => { + row.hidden = row.dataset.accountPlatform !== activePlatform; + }); + document.querySelectorAll("[data-active-platform-label], [data-selection-platform]").forEach((node) => { + node.textContent = platformLabel(); + }); + const accountForm = document.querySelector("[data-account-create]"); + if (accountForm?.elements.platform) accountForm.elements.platform.value = activePlatform; + const accountPlatformLabel = accountForm?.querySelector("[data-account-create-platform]"); + if (accountPlatformLabel) accountPlatformLabel.textContent = platformLabel(); + filterAccountOptions(document.querySelector("[data-batch-account]"), activePlatform); + if (platformListTitle) platformListTitle.textContent = `${platformLabel()}任务清单`; + if (scheduleEmpty) scheduleEmpty.hidden = visibleCount > 0; + updateBackfillCoversButton(); + applyHistoryFilter(); + } + + function renderCalendar() { + if (!calendarNode) return; + const { year, month } = calendarMonth; + if (calendarTitle) calendarTitle.textContent = `${year} 年 ${month} 月 · ${platformLabel()}`; + const firstDay = new Date(Date.UTC(year, month - 1, 1)); + const mondayOffset = (firstDay.getUTCDay() + 6) % 7; + const gridStart = new Date(Date.UTC(year, month - 1, 1 - mondayOffset)); + const todayParts = beijingDateParts(); + const todayKey = `${todayParts.year}-${String(todayParts.month).padStart(2, "0")}-${String(todayParts.day).padStart(2, "0")}`; + const jobsByDate = new Map(); + scheduleRows().forEach((row) => { + if (row.dataset.platform !== activePlatform || row.dataset.status !== "SCHEDULED") return; + const utcValue = row.querySelector("[data-row-schedule]")?.dataset.utc || ""; + const key = beijingDateKey(utcValue); + if (!key) return; + if (!jobsByDate.has(key)) jobsByDate.set(key, []); + jobsByDate.get(key).push({ + id: row.dataset.jobId, + title: row.querySelector("[data-row-title]")?.textContent?.trim() || "未命名任务", + time: formatBeijingTimestamp(utcValue).slice(11), + }); + }); + calendarNode.innerHTML = ""; + for (let index = 0; index < 42; index += 1) { + const date = new Date(gridStart.getTime() + index * 86400000); + const cellYear = date.getUTCFullYear(); + const cellMonth = date.getUTCMonth() + 1; + const cellDay = date.getUTCDate(); + const key = `${cellYear}-${String(cellMonth).padStart(2, "0")}-${String(cellDay).padStart(2, "0")}`; + const jobs = jobsByDate.get(key) || []; + const cell = document.createElement("div"); + cell.className = "publish-calendar-day"; + cell.setAttribute("role", "gridcell"); + cell.dataset.date = key; + cell.classList.toggle("is-outside", cellMonth !== month); + cell.classList.toggle("is-today", key === todayKey); + const number = document.createElement("span"); + number.className = "calendar-day-number"; + number.textContent = String(cellDay); + cell.appendChild(number); + jobs.slice(0, 2).forEach((job) => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "calendar-job-chip"; + chip.dataset.calendarJob = job.id; + chip.title = `${job.time} ${job.title}`; + chip.textContent = `${job.time} ${job.title}`; + cell.appendChild(chip); + }); + if (jobs.length > 2) { + const more = document.createElement("small"); + more.className = "calendar-job-more"; + more.textContent = `另有 ${jobs.length - 2} 条`; + cell.appendChild(more); + } + calendarNode.appendChild(cell); + } + } + + function historyMonthKey() { + return `${historyMonth.year}-${String(historyMonth.month).padStart(2, "0")}`; + } + + function historyPanelIsActive() { + return Boolean(document.querySelector('[data-center-panel="history"].active')); + } + + function createHistoryButton(label, className, dataAttribute) { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = label; + button.dataset[dataAttribute] = ""; + return button; + } + + function createHistoryVisibilitySelect(job, dataAttribute, ariaLabel) { + const select = document.createElement("select"); + select.className = "compact-filter"; + select.dataset[dataAttribute] = ""; + select.setAttribute("aria-label", ariaLabel); + [ + ["public", "公开"], + ["friends", "好友可见"], + ["private", "仅自己可见"], + ].forEach(([value, label]) => { + const option = new Option(label, value, false, String(job.visibility || "public") === value); + select.add(option); + }); + return select; + } + + function appendRepairAccountSelect(actions, job) { + const select = document.createElement("select"); + select.className = "compact-filter"; + select.dataset.repairAccountSelect = ""; + select.setAttribute("aria-label", "选择替代任务账号"); + select.add(new Option("请选择账号", "")); + const source = document.querySelector("[data-batch-account]"); + Array.from(source?.options || []).forEach((option) => { + if (!option.value || option.dataset.accountPlatform !== job.platform) return; + const clone = new Option(option.textContent, option.value); + select.add(clone); + }); + actions.appendChild(select); + return select; + } + + function renderHistoryActions(actions, job) { + const readiness = job.send_readiness || {}; + if (historyDeletedView) { + actions.appendChild(createHistoryButton("恢复记录", "secondary-button", "historyRestore")); + actions.appendChild(createHistoryButton("执行详情", "text-button", "viewEvents")); + return; + } + + if (job.status === "NEED_REVIEW" && readiness.repairable) { + const readinessMessage = document.createElement("small"); + readinessMessage.className = `publish-send-readiness ${readiness.dispatch_ready ? "is-ready" : "is-blocked"}`; + readinessMessage.dataset.readinessMessage = ""; + readinessMessage.textContent = readiness.message || ""; + actions.appendChild(readinessMessage); + actions.appendChild(createHistoryVisibilitySelect(job, "repairVisibility", "替代任务可见范围")); + if (readiness.action === "select_account") { + appendRepairAccountSelect(actions, job); + actions.appendChild(createHistoryButton("选择后修复并发送", "primary-button", "repairJob")); + } else if (readiness.dispatch_ready) { + actions.appendChild(createHistoryButton("修复并发送", "primary-button", "repairJob")); + } else { + const setup = createHistoryButton(setupActionLabel(readiness.action), "secondary-button", "sendSetup"); + setup.dataset.primarySendAction = ""; + actions.appendChild(setup); + } + } + + if (job.platform_url) { + const link = document.createElement("a"); + link.className = "text-button"; + link.href = job.platform_url; + link.target = "_blank"; + link.rel = "noreferrer"; + link.textContent = "平台链接"; + actions.appendChild(link); + } + if (job.status === "FAILED") { + actions.appendChild(createHistoryVisibilitySelect(job, "retryVisibility", "重试任务可见范围")); + actions.appendChild(createHistoryButton("立即发送", "primary-button", "retryJob")); + } + if (job.status === "NEED_REVIEW") { + actions.appendChild(createHistoryButton("打开创作者中心", "secondary-button", "openCreator")); + actions.appendChild(createHistoryButton("标记已发布", "secondary-button", "markPublished")); + actions.appendChild(createHistoryButton("标记失败", "secondary-button", "markFailed")); + } + if (job.is_user_removed) { + actions.appendChild(createHistoryButton("重新加入内容准备", "secondary-button", "restoreJob")); + } + if (["PUBLISHED", "FAILED", "EXPORTED", "CANCELLED"].includes(job.status)) { + actions.appendChild(createHistoryButton("删除记录", "text-button danger", "historyHide")); + } + actions.appendChild(createHistoryButton("执行详情", "text-button", "viewEvents")); + } + + function renderHistoryRow(job) { + const row = document.createElement("article"); + row.className = "publish-execution-row"; + row.dataset.publishRow = ""; + row.dataset.historyRecord = ""; + row.dataset.jobId = job.id; + row.dataset.accountId = job.account_id || ""; + row.dataset.platform = job.platform; + row.dataset.visibility = job.visibility || "public"; + row.dataset.status = job.status; + row.dataset.section = "history"; + row.dataset.sendReadiness = JSON.stringify(job.send_readiness || {}); + + const selectCell = document.createElement("label"); + selectCell.className = "publish-history-select"; + if (historyDeletedView || ["PUBLISHED", "FAILED", "EXPORTED", "CANCELLED"].includes(job.status)) { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.value = job.id; + checkbox.dataset.historySelect = ""; + checkbox.setAttribute("aria-label", `选择 ${job.title || "执行记录"}`); + checkbox.checked = selectedHistoryJobIds.has(job.id); + selectCell.appendChild(checkbox); + } else { + selectCell.textContent = "—"; + } + + const identity = document.createElement("div"); + identity.className = "publish-history-identity"; + const title = document.createElement("strong"); + title.dataset.rowTitle = ""; + title.textContent = job.title || "未命名任务"; + const platform = document.createElement("small"); + platform.textContent = job.platform_label || platformLabel(job.platform); + identity.append(title, platform); + const executionMessage = job.error_message || job.execution_phase_label || ""; + if (executionMessage) { + const message = document.createElement("small"); + message.className = "publish-send-readiness"; + message.dataset.executionMessage = ""; + message.textContent = executionMessage; + identity.appendChild(message); + } + if (job.error_message) { + const details = document.createElement("details"); + details.className = "publish-error-detail"; + const summary = document.createElement("summary"); + summary.textContent = "查看错误"; + const errorText = document.createElement("p"); + errorText.textContent = job.error_message; + const code = document.createElement("code"); + code.textContent = job.error_code || ""; + details.append(summary, errorText, code); + identity.appendChild(details); + } + + const account = document.createElement("span"); + account.dataset.rowAccount = ""; + account.textContent = job.account_name || "未选择账号"; + const scheduled = document.createElement("time"); + scheduled.dataset.rowSchedule = ""; + scheduled.dataset.utc = job.scheduled_at_utc || job.scheduled_at || ""; + scheduled.textContent = job.scheduled_at_display || "未排期"; + const started = document.createElement("time"); + started.textContent = `开始 ${job.started_at_display || "—"}`; + const finished = document.createElement("time"); + finished.textContent = `结束 ${job.finished_at_display || "—"}`; + const actualTimes = document.createElement("span"); + actualTimes.className = "publish-history-time-stack"; + actualTimes.append(started, finished); + const status = document.createElement("span"); + status.className = `status-pill tone-${job.status_tone || "blue"}`; + status.dataset.rowStatus = ""; + status.textContent = job.status_label || statusLabel(job.status); + const actions = document.createElement("div"); + actions.className = "publish-row-actions publish-history-actions"; + const mode = document.createElement("small"); + mode.className = "publish-history-mode"; + mode.textContent = job.publish_mode_label || ""; + actions.appendChild(mode); + renderHistoryActions(actions, job); + row.append(selectCell, identity, account, scheduled, actualTimes, status, actions); + applyRowReadiness(row); + return row; + } + + function updateHistorySelectionUi() { + document.querySelectorAll("[data-history-select]").forEach((checkbox) => { + checkbox.checked = selectedHistoryJobIds.has(checkbox.value); + }); + const count = selectedHistoryJobIds.size; + if (historySelectedCount) historySelectedCount.textContent = String(count); + if (historyBatchBar) historyBatchBar.hidden = count === 0; + if (historyBatchHideButton) historyBatchHideButton.hidden = historyDeletedView; + if (historyBatchRestoreButton) historyBatchRestoreButton.hidden = !historyDeletedView; + } + + function clearHistorySelection() { + selectedHistoryJobIds.clear(); + updateHistorySelectionUi(); + } + + function renderHistoryRecords(data) { + const jobs = data.jobs || []; + const visibleIds = new Set(jobs.map((job) => job.id)); + Array.from(selectedHistoryJobIds).forEach((jobId) => { + if (!visibleIds.has(jobId)) selectedHistoryJobIds.delete(jobId); + }); + historyListNode?.replaceChildren(...jobs.map(renderHistoryRow)); + const pagination = data.pagination || {}; + historyPage = Number(pagination.page || 1); + historyTotalPages = Number(pagination.total_pages || 0); + const total = Number(pagination.total || 0); + if (historyEmpty) historyEmpty.hidden = jobs.length > 0; + if (historyPagination) historyPagination.hidden = historyTotalPages <= 1; + if (historyPageSummary) { + historyPageSummary.textContent = historyTotalPages + ? `第 ${historyPage} / ${historyTotalPages} 页` + : "暂无记录"; + } + if (historyPreviousButton) historyPreviousButton.disabled = historyPage <= 1; + if (historyNextButton) historyNextButton.disabled = !historyTotalPages || historyPage >= historyTotalPages; + if (historyListTitle) { + historyListTitle.textContent = historyDeletedView + ? "已删除记录" + : (historySelectedDate ? `${historySelectedDate} 执行记录` : "全部执行记录"); + } + if (historyListEyebrow) historyListEyebrow.textContent = historyDeletedView ? "Deleted Records" : "History"; + if (historyListSummary) { + historyListSummary.textContent = historyDeletedView + ? `共 ${total} 条 · 删除仅影响页面展示` + : `共 ${total} 条 · 页面及归档日期均使用北京时间`; + } + if (historyClearDateButton) historyClearDateButton.hidden = historyDeletedView || !historySelectedDate; + updateHistorySelectionUi(); + } + + function renderHistoryCalendar(data) { + if (!historyCalendarNode) return; + const { year, month } = historyMonth; + if (historyCalendarTitle) historyCalendarTitle.textContent = `${year} 年 ${month} 月 · ${platformLabel()}执行日历`; + const dayMap = new Map((data.days || []).map((item) => [item.date, item])); + const firstDay = new Date(Date.UTC(year, month - 1, 1)); + const mondayOffset = (firstDay.getUTCDay() + 6) % 7; + const gridStart = new Date(Date.UTC(year, month - 1, 1 - mondayOffset)); + const todayParts = beijingDateParts(); + const todayKey = `${todayParts.year}-${String(todayParts.month).padStart(2, "0")}-${String(todayParts.day).padStart(2, "0")}`; + const statusItems = [ + ["SCHEDULED", "待", "tone-blue"], + ["PUBLISHING", "中", "tone-purple"], + ["PUBLISHED", "成", "tone-green"], + ["FAILED", "败", "tone-red"], + ["NEED_REVIEW", "核", "tone-amber"], + ["EXPORTED", "导", "tone-neutral"], + ["CANCELLED", "取", "tone-neutral"], + ]; + historyCalendarNode.replaceChildren(); + for (let index = 0; index < 42; index += 1) { + const date = new Date(gridStart.getTime() + index * 86400000); + const cellYear = date.getUTCFullYear(); + const cellMonth = date.getUTCMonth() + 1; + const cellDay = date.getUTCDate(); + const key = `${cellYear}-${String(cellMonth).padStart(2, "0")}-${String(cellDay).padStart(2, "0")}`; + const day = dayMap.get(key); + const cell = document.createElement("button"); + cell.type = "button"; + cell.className = "publish-calendar-day publish-history-calendar-day"; + cell.dataset.historyDate = key; + cell.setAttribute("role", "gridcell"); + cell.setAttribute("aria-label", `${key}${day ? `,共 ${day.total} 条执行记录` : ",没有执行记录"}`); + cell.classList.toggle("is-outside", cellMonth !== month); + cell.classList.toggle("is-today", key === todayKey); + cell.classList.toggle("is-selected", key === historySelectedDate); + const number = document.createElement("span"); + number.className = "calendar-day-number"; + number.textContent = String(cellDay); + cell.appendChild(number); + if (day?.total) { + const total = document.createElement("small"); + total.className = "history-calendar-total"; + total.textContent = `共 ${day.total} 条`; + cell.appendChild(total); + const statuses = document.createElement("span"); + statuses.className = "history-calendar-statuses"; + statusItems.forEach(([status, label, tone]) => { + const count = Number(day.counts?.[status] || 0); + if (!count) return; + const badge = document.createElement("small"); + badge.className = tone; + badge.textContent = `${label}${count}`; + statuses.appendChild(badge); + }); + cell.appendChild(statuses); + } + historyCalendarNode.appendChild(cell); + } + } + + async function refreshHistory(options = {}) { + if (!historyListNode || !historyPanelIsActive()) return; + const includeCalendar = options.calendar !== false && !historyDeletedView; + const includeRecords = options.records !== false; + if (historyRefreshInFlight) { + historyRefreshQueuedCalendar = historyRefreshQueuedCalendar || includeCalendar; + historyRefreshQueuedRecords = historyRefreshQueuedRecords || includeRecords; + return; + } + historyRefreshInFlight = true; + const sequence = ++historyRequestSequence; + const requests = []; + if (includeCalendar) { + const calendarUrl = `/api/publish/history/calendar?platform=${encodeURIComponent(activePlatform)}&month=${encodeURIComponent(historyMonthKey())}`; + requests.push( + window.apiFetch(calendarUrl).then((data) => { + if (sequence === historyRequestSequence) renderHistoryCalendar(data); + }), + ); + } + if (includeRecords) { + const params = new URLSearchParams({ + platform: activePlatform, + status: String(historyFilter?.value || "all"), + deleted: historyDeletedView ? "true" : "false", + page: String(historyPage), + page_size: "50", + }); + if (historySelectedDate && !historyDeletedView) params.set("date", historySelectedDate); + requests.push( + window.apiFetch(`/api/publish/history/records?${params.toString()}`).then((data) => { + if (sequence === historyRequestSequence) renderHistoryRecords(data); + }), + ); + } + try { + await Promise.all(requests); + } catch (error) { + if (sequence === historyRequestSequence) showMessage(`加载执行记录失败:${error.message}`, "error"); + } finally { + historyRefreshInFlight = false; + if (historyRefreshQueuedCalendar || historyRefreshQueuedRecords) { + const queuedCalendar = historyRefreshQueuedCalendar; + const queuedRecords = historyRefreshQueuedRecords; + historyRefreshQueuedCalendar = false; + historyRefreshQueuedRecords = false; + void refreshHistory({ calendar: queuedCalendar, records: queuedRecords }); + } + } + } + + function queueHistoryRefresh(includeCalendar = true) { + if (!historyPanelIsActive()) return; + historyRefreshCalendar = historyRefreshCalendar || includeCalendar; + if (historyRefreshFrame) window.cancelAnimationFrame(historyRefreshFrame); + historyRefreshFrame = window.requestAnimationFrame(() => { + const refreshCalendar = historyRefreshCalendar; + historyRefreshFrame = 0; + historyRefreshCalendar = false; + void refreshHistory({ calendar: refreshCalendar, records: true }); + }); + } + + async function updateHistoryVisibility(hidden, jobIds) { + const ids = Array.from(new Set(jobIds.filter(Boolean))); + if (!ids.length) return; + const actionLabel = hidden ? "删除" : "恢复"; + const confirmation = hidden + ? `确认安全删除 ${ids.length} 条执行记录?\n\n记录会从正常列表和日历中隐藏,但视频、执行明细、数据库历史和平台作品都不会删除。` + : `确认恢复 ${ids.length} 条已删除记录?`; + if (!window.confirm(confirmation)) return; + try { + const endpoint = hidden ? "hide" : "restore"; + const data = await window.apiFetch(`/api/publish/history/records/${endpoint}`, { + method: "POST", + body: JSON.stringify({ platform: activePlatform, job_ids: ids }), + }); + clearHistorySelection(); + showMessage(data.message || `${actionLabel}成功。`, "success"); + await refreshHistory({ calendar: true, records: true }); + } catch (error) { + showMessage(`${actionLabel}执行记录失败:${error.message}`, "error"); + } + } + + function refreshScheduleViews() { + renderPlatformSchedule(); + renderCalendar(); + } + + function queueScheduleRefresh() { + if (scheduleRefreshFrame) window.cancelAnimationFrame(scheduleRefreshFrame); + scheduleRefreshFrame = window.requestAnimationFrame(() => { + scheduleRefreshFrame = 0; + refreshScheduleViews(); + }); + } + + function statusLabel(status) { + return { + DRAFT: "草稿", WAITING: "等待安排", SCHEDULED: "已排期", PUBLISHING: "发送中", + PUBLISHED: "已发布", EXPORTED: "已导出发布包", FAILED: "发送失败", + CANCELLED: "已取消", NEED_REVIEW: "需人工复核", + }[status] || status; + } + + function accountStatusLabel(status) { + return { + normal: "正常", + invalid: "登录失效", + login_pending: "等待登录完成", + busy: "账号操作中", + login_required: "需要重新登录", + }[status] || "需要重新登录"; + } + + function rowReadiness(row) { + try { + return JSON.parse(row?.dataset.sendReadiness || "{}"); + } catch (_error) { + return {}; + } + } + + function effectiveReadiness(row) { + const readiness = rowReadiness(row); + if (readiness.requires_worker && !workerAvailable) { + return { + ...readiness, + ready: false, + dispatch_ready: false, + can_auto_resolve: false, + message: workerMessage || "Windows 发布 Worker 未连接", + action: "start_worker", + }; + } + return readiness; + } + + function setupActionLabel(action) { + return { + login_account: "打开登录窗口", + create_account: "新增账号", + select_account: "选择账号", + complete_content: "完善内容", + start_worker: "连接 Worker", + }[action] || "完善发送配置"; + } + + function applyRowReadiness(row, nextReadiness = null) { + if (!row) return; + if (nextReadiness) row.dataset.sendReadiness = JSON.stringify(nextReadiness); + const readiness = effectiveReadiness(row); + if (!Object.keys(readiness).length) return; + const message = row.querySelector("[data-readiness-message]"); + if (message) { + message.textContent = readiness.message || "发布条件尚未满足"; + message.classList.toggle("is-ready", Boolean(readiness.ready || readiness.dispatch_ready)); + message.classList.toggle("is-blocked", !readiness.ready && !readiness.dispatch_ready); + } + const button = row.querySelector("[data-primary-send-action]"); + if (!button) return; + button.removeAttribute("data-publish-now"); + button.removeAttribute("data-send-setup"); + button.removeAttribute("data-repair-job"); + button.hidden = false; + button.disabled = false; + const section = row.dataset.section; + if (section === "schedule") { + if (readiness.ready || readiness.can_auto_resolve) { + button.dataset.publishNow = ""; + button.className = "primary-button"; + button.textContent = readiness.action === "export" ? "立即导出" : (readiness.can_auto_resolve ? "转换并发送" : "立即发送"); + } else { + button.dataset.sendSetup = ""; + button.className = "secondary-button"; + button.textContent = setupActionLabel(readiness.action); + } + return; + } + if (section === "history" && row.dataset.status === "NEED_REVIEW" && readiness.repairable) { + if (readiness.dispatch_ready || (readiness.action === "select_account" && row.querySelector("[data-repair-account-select]"))) { + button.dataset.repairJob = ""; + button.className = "primary-button"; + button.textContent = readiness.action === "select_account" ? "选择后修复并发送" : "修复并发送"; + } else { + button.dataset.sendSetup = ""; + button.className = "secondary-button"; + button.textContent = setupActionLabel(readiness.action); + } + } else { + button.hidden = true; + } + } + + function applyReadinessError(error, row) { + if (!error?.details || !row) return false; + const current = rowReadiness(row); + applyRowReadiness(row, { + ...current, + ...error.details, + repairable: Boolean(current.repairable || error.details.repairable), + }); + showMessage(error.details.message || error.message, "error"); + return true; + } + + function appendAccountRow(account) { + const list = document.querySelector("[data-account-list]"); + if (!list) return null; + const existing = list.querySelector(`[data-account-id="${CSS.escape(account.id)}"]`); + if (existing) return existing; + const article = document.createElement("article"); + article.dataset.accountRow = ""; + article.dataset.accountId = account.id; + article.dataset.accountPlatform = account.platform; + const identity = document.createElement("div"); + const name = document.createElement("strong"); + name.textContent = account.account_name; + const platform = document.createElement("small"); + platform.textContent = account.platform_label; + const message = document.createElement("small"); + message.dataset.accountMessage = ""; + identity.append(name, platform, message); + const status = document.createElement("span"); + status.className = "status-pill tone-amber"; + status.dataset.accountStatus = ""; + const actions = document.createElement("div"); + actions.className = "button-row"; + article.append(identity, status, actions); + list.append(article); + return article; + } + + function updateAccountRow(account) { + if (!account?.id) return; + const article = appendAccountRow(account); + if (!article) return; + article.dataset.accountPlatform = account.platform; + article.hidden = account.platform !== activePlatform; + const status = article.querySelector("[data-account-status]"); + if (status) { + status.textContent = account.login_status_label || accountStatusLabel(account.login_status); + status.classList.toggle("tone-green", account.login_status === "normal"); + status.classList.toggle("tone-red", account.login_status === "invalid"); + status.classList.toggle("tone-amber", !["normal", "invalid"].includes(account.login_status)); + } + const message = article.querySelector("[data-account-message]"); + if (message) message.textContent = account.login_message || ""; + const actions = article.querySelector(".button-row"); + if (!actions) return; + actions.replaceChildren(); + const primary = document.createElement("button"); + primary.type = "button"; + primary.className = "secondary-button"; + const secondary = document.createElement("button"); + secondary.type = "button"; + secondary.className = "text-button"; + if (account.login_status === "normal") { + primary.dataset.accountOpenCenter = ""; + primary.textContent = "打开创作者中心"; + secondary.dataset.accountLogin = ""; + secondary.textContent = "重新登录"; + } else { + primary.dataset.accountLogin = ""; + primary.textContent = account.login_status === "login_pending" ? "再次打开登录窗口" : "登录 / 重新登录"; + secondary.dataset.accountCheck = ""; + secondary.textContent = "检查状态"; + } + actions.append(primary, secondary); + } + + function sectionAllows(section, status) { + if (section === "content") return ["DRAFT", "WAITING", "SCHEDULED"].includes(status); + if (section === "schedule") return ["WAITING", "SCHEDULED"].includes(status); + if (section === "history") { + return ["SCHEDULED", "PUBLISHING", "PUBLISHED", "EXPORTED", "FAILED", "NEED_REVIEW", "CANCELLED"].includes(status); + } + return true; + } + + function applyHistoryFilter() { + queueHistoryRefresh(true); + } + + function updateSelectionUi() { + Array.from(selectedJobIds).forEach((jobId) => { + const row = document.querySelector(`[data-publish-row][data-job-id="${CSS.escape(jobId)}"]`); + if (!row || row.dataset.platform !== activePlatform) selectedJobIds.delete(jobId); + }); + document.querySelectorAll("[data-publish-select]").forEach((checkbox) => { + checkbox.checked = selectedJobIds.has(checkbox.value); + }); + const count = selectedJobIds.size; + if (selectedCountNode) selectedCountNode.textContent = String(count); + if (drawerCount) drawerCount.textContent = String(count); + if (selectionBar) selectionBar.hidden = count === 0; + } + + function setActivePlatform(nextPlatform) { + const next = nextPlatform === "bilibili" ? "bilibili" : "douyin"; + const changed = next !== activePlatform; + activePlatform = next; + if (changed) { + selectedJobIds.clear(); + historySelectedDate = ""; + historyPage = 1; + clearHistorySelection(); + invalidatePreview(); + closeDrawer(); + document.querySelectorAll("[data-publish-task-group]").forEach((group) => setTaskGroupExpanded(group, false)); + showMessage(`已切换到${platformLabel()};之前勾选的任务已清空,不会跨平台发送。`, "success"); + } + updateSelectionUi(); + refreshScheduleViews(); + if (changed) queueHistoryRefresh(true); + } + + function switchTab(tab) { + document.querySelectorAll("[data-center-tab]").forEach((button) => { + button.classList.toggle("active", button.dataset.centerTab === tab); + }); + document.querySelectorAll("[data-center-panel]").forEach((panel) => { + const active = panel.dataset.centerPanel === tab; + panel.hidden = !active; + panel.classList.toggle("active", active); + }); + if (tab === "history") void refreshHistory({ calendar: true, records: true }); + } + + function updateRowFromJob(job) { + if (!job?.id) return; + document.querySelectorAll(`[data-publish-row][data-job-id="${CSS.escape(job.id)}"]`).forEach((row) => { + const status = String(job.status || row.dataset.status || "").toUpperCase(); + row.dataset.status = status; + if (job.platform) row.dataset.platform = job.platform; + if (job.account_id !== undefined) row.dataset.accountId = job.account_id || ""; + if (job.visibility) row.dataset.visibility = job.visibility; + if (job.send_readiness) row.dataset.sendReadiness = JSON.stringify(job.send_readiness); + if (job.output_is_active !== undefined) row.dataset.outputActive = job.output_is_active ? "true" : "false"; + row.hidden = ( + !sectionAllows(row.dataset.section, status) + || row.dataset.platform !== activePlatform + || (row.dataset.section !== "history" && row.dataset.outputActive === "false") + ); + const statusNode = row.querySelector("[data-row-status]"); + if (statusNode) statusNode.textContent = job.status_label || statusLabel(status); + const executionMessage = row.querySelector("[data-execution-message]"); + if (executionMessage) executionMessage.textContent = job.error_message || job.execution_phase_label || ""; + const titleNode = row.querySelector("[data-row-title]"); + if (titleNode && job.title) titleNode.textContent = job.title; + const platformNode = row.querySelector("[data-row-platform]"); + if (platformNode && job.platform_label) platformNode.textContent = job.platform_label; + const accountNode = row.querySelector("[data-row-account]"); + if (accountNode && job.account_name !== undefined) accountNode.textContent = job.account_name || "未选择"; + const editor = row.querySelector("[data-publish-editor]"); + if (editor) { + if (job.platform && editor.elements.platform) editor.elements.platform.value = job.platform; + const resolvedAccountId = job.account_id || job.send_readiness?.resolved_account_id || ""; + if (job.account_id !== undefined && editor.elements.account_id) editor.elements.account_id.value = resolvedAccountId; + const resolvedMode = job.send_readiness?.resolved_publish_mode || job.publish_mode || ""; + if (resolvedMode && editor.elements.publish_mode?.querySelector(`option[value="${CSS.escape(resolvedMode)}"]`)) { + editor.elements.publish_mode.value = resolvedMode; + } + if (job.cover_file_path !== undefined && editor.elements.cover_file_path) { + editor.elements.cover_file_path.value = job.cover_file_path || ""; + } + if (job.cover_time_seconds !== undefined && editor.elements.cover_time_seconds) { + editor.elements.cover_time_seconds.value = String(job.cover_time_seconds || 0); + } + syncPlatformFields(editor); + } + const coverPreview = row.querySelector("[data-cover-preview]"); + if (coverPreview && job.cover_media_url) { + coverPreview.src = job.cover_media_url; + coverPreview.hidden = false; + } + const readyNode = row.querySelector("[data-content-ready]"); + if (readyNode && job.content_complete !== undefined) { + readyNode.textContent = job.content_complete ? "内容完整" : `缺少:${(job.missing_fields || []).join("、")}`; + readyNode.classList.toggle("tone-green", Boolean(job.content_complete)); + readyNode.classList.toggle("tone-amber", !job.content_complete); + } + const restoreButton = row.querySelector("[data-restore-job]"); + if (restoreButton && job.is_user_removed !== undefined) restoreButton.hidden = !job.is_user_removed; + const timeNode = row.querySelector("[data-row-schedule]"); + if (timeNode) { + const utcValue = job.scheduled_at_utc || job.scheduled_at || ""; + timeNode.dataset.utc = utcValue; + timeNode.textContent = job.scheduled_at_display || (utcValue ? formatBeijingTimestamp(utcValue) : "未排期"); + } + applyRowReadiness(row); + }); + if (["PUBLISHED", "EXPORTED", "CANCELLED", "PUBLISHING", "NEED_REVIEW", "FAILED"].includes(String(job.status || ""))) { + selectedJobIds.delete(job.id); + updateSelectionUi(); + } + applyHistoryFilter(); + queueScheduleRefresh(); + updateBackfillCoversButton(); + } + + function cloneRowsForRetry(sourceId, job) { + document.querySelectorAll(`[data-publish-row][data-job-id="${CSS.escape(sourceId)}"]`).forEach((source) => { + const clone = source.cloneNode(true); + clone.dataset.jobId = job.id; + clone.querySelectorAll("[data-publish-select]").forEach((checkbox) => { checkbox.value = job.id; checkbox.checked = false; }); + source.parentElement.appendChild(clone); + }); + updateRowFromJob(job); + } + + function schedulePayload(action = "apply") { + const preset = String(scheduleForm?.elements.interval_preset?.value || "180"); + return { + job_ids: Array.from(selectedJobIds), + platform: activePlatform, + action, + start_at_local: String(scheduleForm?.elements.start_at_local?.value || ""), + timezone: APP_TIMEZONE, + interval_minutes: preset === "custom" ? Number(scheduleForm?.elements.interval_minutes?.value || 180) : Number(preset), + daily_start_time: String(scheduleForm?.elements.daily_start_time?.value || "07:00"), + daily_end_time: String(scheduleForm?.elements.daily_end_time?.value || "00:00"), + }; + } + + function previewSignature(payload) { + return JSON.stringify({ ...payload, confirmed_schedule: undefined }); + } + + function showScheduleFeedback(message = "", tone = "info") { + if (!scheduleFeedbackNode) return; + scheduleFeedbackNode.hidden = !message; + scheduleFeedbackNode.textContent = message; + scheduleFeedbackNode.classList.toggle("tone-red", tone === "error"); + scheduleFeedbackNode.classList.toggle("tone-blue", tone !== "error"); + } + + function showLatestScheduleNote(message = "", tone = "info") { + if (!latestScheduleNote) return; + latestScheduleNote.hidden = !message; + latestScheduleNote.textContent = message; + latestScheduleNote.classList.toggle("tone-red", tone === "error"); + latestScheduleNote.classList.toggle("tone-blue", tone !== "error"); + } + + function invalidatePreview() { + latestPreviewSignature = ""; + latestPreviewItems = []; + if (confirmScheduleButton) confirmScheduleButton.disabled = true; + if (previewList) previewList.innerHTML = '

请先生成预览,再确认应用。

'; + showScheduleFeedback(); + } + + function openDrawer() { + if (!selectedJobIds.size) { + showMessage("请先选择至少一条任务。", "error"); + return; + } + drawer.hidden = false; + drawerBackdrop.hidden = false; + document.body.classList.add("has-schedule-drawer"); + showLatestScheduleNote(); + updateSelectionUi(); + } + + function closeDrawer() { + drawer.hidden = true; + drawerBackdrop.hidden = true; + document.body.classList.remove("has-schedule-drawer"); + } + + function filterAccountOptions(select, platform) { + if (!select) return; + Array.from(select.options).forEach((option) => { + const optionPlatform = option.dataset.accountPlatform || ""; + option.hidden = Boolean(optionPlatform && optionPlatform !== platform); + option.disabled = Boolean(optionPlatform && optionPlatform !== platform); + }); + const chosen = select.selectedOptions[0]; + if (chosen?.disabled) select.value = ""; + } + + function syncPlatformFields(form) { + if (!form) return; + const platform = String(form.elements.platform?.value || "douyin"); + const bilibiliFields = form.querySelector("[data-bilibili-fields]"); + if (bilibiliFields) bilibiliFields.hidden = platform !== "bilibili"; + filterAccountOptions(form.elements.account_id, platform); + const repost = String(form.elements.bilibili_copyright?.value || "original") === "repost"; + const source = form.querySelector("[data-repost-source]"); + if (source) source.hidden = platform !== "bilibili" || !repost; + } + + function openAccountDrawer(platform = "") { + if (!accountDrawer || !accountBackdrop) return; + if (platform && platform !== activePlatform) setActivePlatform(platform); + accountDrawer.hidden = false; + accountBackdrop.hidden = false; + document.body.classList.add("has-schedule-drawer"); + const form = accountDrawer.querySelector("[data-account-create]"); + if (form?.elements.platform) form.elements.platform.value = activePlatform; + const label = form?.querySelector("[data-account-create-platform]"); + if (label) label.textContent = platformLabel(); + document.querySelectorAll("[data-account-row]").forEach((row) => { + row.hidden = row.dataset.accountPlatform !== activePlatform; + }); + } + + function readinessAccountId(readiness) { + if (readiness.resolved_account_id) return readiness.resolved_account_id; + const loginIssue = (readiness.issues || []).find((issue) => issue.action === "login_account"); + return loginIssue?.account_id || ""; + } + + async function handleSendSetup(row) { + const readiness = effectiveReadiness(row); + if (readiness.action === "start_worker") { + document.querySelector("[data-worker-help]")?.removeAttribute("hidden"); + document.querySelector("[data-scheduler-health]")?.scrollIntoView({ behavior: "smooth", block: "center" }); + await refreshSchedulerHealth(true); + return; + } + if (readiness.action === "login_account") { + const accountId = readinessAccountId(readiness); + openAccountDrawer(row.dataset.platform || ""); + if (!accountId) { + showMessage("没有找到需要登录的账号,请先在账号管理中选择账号。", "error"); + return; + } + try { + const data = await window.apiFetch(`/api/publish/accounts/${accountId}/login`, { method: "POST" }); + if (data.account) updateAccountRow(data.account); + showMessage(data.message || data.worker_result?.message || "登录窗口已打开,请在专属 Chrome 中完成登录。", "success"); + await Promise.all([refreshAccounts(), refreshJobs()]); + } catch (error) { + document.querySelector("[data-worker-help]")?.removeAttribute("hidden"); + showMessage(`打开登录窗口失败:${error.message}`, "error"); + } + return; + } + if (readiness.action === "create_account") { + openAccountDrawer(row.dataset.platform || ""); + const nameInput = accountDrawer?.querySelector('[data-account-create] input[name="account_name"]'); + nameInput?.focus(); + showMessage("请先创建对应平台账号;系统不会保存账号密码。", "error"); + return; + } + if (readiness.action === "select_account") { + const repairSelect = row.querySelector("[data-repair-account-select]"); + if (repairSelect) { + repairSelect.focus(); + showMessage("请选择同平台账号,再点击“修复并发送”。", "error"); + return; + } + switchTab("content"); + const editorRow = document.querySelector(`[data-publish-row][data-section="content"][data-job-id="${CSS.escape(row.dataset.jobId)}"]`); + editorRow?.scrollIntoView({ behavior: "smooth", block: "center" }); + editorRow?.querySelector("[data-account-select]")?.focus(); + showMessage("请在内容准备中选择本次使用的同平台账号并保存。", "error"); + return; + } + if (readiness.action === "complete_content") { + switchTab("content"); + const editorRow = document.querySelector(`[data-publish-row][data-section="content"][data-job-id="${CSS.escape(row.dataset.jobId)}"]`); + if (editorRow && !editorRow.hidden) { + editorRow.scrollIntoView({ behavior: "smooth", block: "center" }); + editorRow.querySelector("input, textarea, select")?.focus(); + } + showMessage(readiness.message || "请先补齐发布内容并保存。", "error"); + } + } + + async function refreshJobs() { + try { + const data = await window.apiFetch("/api/publish/jobs"); + (data.jobs || []).forEach(updateRowFromJob); + } catch (_error) { + // 后台轮询失败不遮挡用户正在编辑的内容。 + } + } + + async function refreshAccounts() { + try { + const data = await window.apiFetch("/api/publish/accounts"); + (data.accounts || []).forEach(updateAccountRow); + } catch (_error) { + // 登录窗口仍可继续使用;下一轮会自动重试同步状态。 + } + } + + async function refreshSchedulerHealth(showResult = false) { + const button = document.querySelector("[data-refresh-worker]"); + if (button) button.disabled = true; + try { + const data = await window.apiFetch("/api/publish/scheduler/health"); + const statusNode = document.querySelector("[data-worker-status]"); + const runtimeNode = document.querySelector("[data-scheduler-runtime]"); + const message = document.querySelector("[data-worker-message]"); + const help = document.querySelector("[data-worker-help]"); + const dot = document.querySelector("[data-scheduler-health] .health-dot"); + workerAvailable = Boolean(data.worker_available); + workerMessage = data.worker_message || "Windows 发布 Worker 未连接"; + const schedulerFailures = Number(data.consecutive_failures || 0); + const schedulerHealthy = Boolean(data.running && schedulerFailures === 0); + if (schedulerHealthNode) { + schedulerHealthNode.dataset.workerAvailable = workerAvailable ? "true" : "false"; + schedulerHealthNode.dataset.schedulerFailures = String(schedulerFailures); + } + if (statusNode) statusNode.textContent = data.worker_available ? "正常" : "未连接"; + if (runtimeNode) runtimeNode.textContent = schedulerFailures ? "异常重试中" : (data.running ? "正常" : "已停止"); + if (message) { + const schedulerMessage = schedulerFailures ? `${data.last_error_message || "调度扫描异常,正在自动重试"} · ` : ""; + message.textContent = `${schedulerMessage}${data.worker_message} · 页面及排期均使用北京时间`; + } + if (help) help.hidden = Boolean(data.worker_available); + if (dot) dot.classList.toggle("is-ok", Boolean(schedulerHealthy && data.worker_available)); + document.querySelectorAll('[data-publish-row][data-section="schedule"], [data-publish-row][data-section="history"]').forEach((row) => applyRowReadiness(row)); + if (showResult) { + const ready = schedulerHealthy && data.worker_available; + const resultMessage = schedulerFailures + ? (data.last_error_message || "调度扫描异常,正在自动重试") + : (data.worker_available ? "调度器与 Windows Worker 均已连接。" : "发送服务仍在随 Docker 项目自动启动;请稍候,或在 Docker Desktop 中停止后重新运行本项目。"); + showMessage(resultMessage, ready ? "success" : "error"); + } + } catch (error) { + if (showResult) showMessage(`检测失败:${error.message}`, "error"); + } finally { + if (button) button.disabled = false; + } + } + + document.querySelectorAll("[data-center-tab]").forEach((button) => { + button.addEventListener("click", () => switchTab(button.dataset.centerTab)); + }); + document.querySelectorAll("[data-publish-platform]").forEach((button) => { + button.addEventListener("click", () => setActivePlatform(button.dataset.publishPlatform)); + }); + document.querySelector("[data-calendar-previous]")?.addEventListener("click", () => { + const previous = new Date(Date.UTC(calendarMonth.year, calendarMonth.month - 2, 1)); + calendarMonth = { year: previous.getUTCFullYear(), month: previous.getUTCMonth() + 1 }; + renderCalendar(); + }); + document.querySelector("[data-calendar-next]")?.addEventListener("click", () => { + const next = new Date(Date.UTC(calendarMonth.year, calendarMonth.month, 1)); + calendarMonth = { year: next.getUTCFullYear(), month: next.getUTCMonth() + 1 }; + renderCalendar(); + }); + document.querySelector("[data-calendar-today]")?.addEventListener("click", () => { + calendarMonth = currentBeijingMonth(); + renderCalendar(); + }); + calendarNode?.addEventListener("click", (event) => { + const chip = event.target.closest("[data-calendar-job]"); + if (!chip) return; + const row = document.querySelector(`[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(chip.dataset.calendarJob)}"]`); + if (!row || row.hidden) return; + row.scrollIntoView({ behavior: "smooth", block: "center" }); + row.classList.add("is-calendar-focus"); + window.setTimeout(() => row.classList.remove("is-calendar-focus"), 1600); + }); + historyFilter?.addEventListener("change", () => { + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: false, records: true }); + }); + document.querySelector("[data-history-calendar-previous]")?.addEventListener("click", () => { + const previous = new Date(Date.UTC(historyMonth.year, historyMonth.month - 2, 1)); + historyMonth = { year: previous.getUTCFullYear(), month: previous.getUTCMonth() + 1 }; + historySelectedDate = ""; + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: true, records: true }); + }); + document.querySelector("[data-history-calendar-next]")?.addEventListener("click", () => { + const next = new Date(Date.UTC(historyMonth.year, historyMonth.month, 1)); + historyMonth = { year: next.getUTCFullYear(), month: next.getUTCMonth() + 1 }; + historySelectedDate = ""; + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: true, records: true }); + }); + document.querySelector("[data-history-calendar-today]")?.addEventListener("click", () => { + historyMonth = currentBeijingMonth(); + historySelectedDate = ""; + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: true, records: true }); + }); + historyCalendarNode?.addEventListener("click", (event) => { + const cell = event.target.closest("[data-history-date]"); + if (!cell) return; + const [year, month] = String(cell.dataset.historyDate || "").split("-").map(Number); + if (year && month) historyMonth = { year, month }; + historySelectedDate = String(cell.dataset.historyDate || ""); + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: true, records: true }); + }); + historyClearDateButton?.addEventListener("click", () => { + historySelectedDate = ""; + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: true, records: true }); + }); + document.querySelectorAll("[data-history-view]").forEach((button) => { + button.addEventListener("click", () => { + historyDeletedView = button.dataset.historyView === "deleted"; + document.querySelectorAll("[data-history-view]").forEach((item) => { + item.classList.toggle("is-active", item === button); + }); + if (historyCalendarCard) historyCalendarCard.hidden = historyDeletedView; + historySelectedDate = ""; + historyPage = 1; + clearHistorySelection(); + void refreshHistory({ calendar: !historyDeletedView, records: true }); + }); + }); + historyPreviousButton?.addEventListener("click", () => { + if (historyPage <= 1) return; + historyPage -= 1; + clearHistorySelection(); + void refreshHistory({ calendar: false, records: true }); + }); + historyNextButton?.addEventListener("click", () => { + if (!historyTotalPages || historyPage >= historyTotalPages) return; + historyPage += 1; + clearHistorySelection(); + void refreshHistory({ calendar: false, records: true }); + }); + historyBatchHideButton?.addEventListener("click", () => { + void updateHistoryVisibility(true, Array.from(selectedHistoryJobIds)); + }); + historyBatchRestoreButton?.addEventListener("click", () => { + void updateHistoryVisibility(false, Array.from(selectedHistoryJobIds)); + }); + document.querySelector("[data-history-clear-selection]")?.addEventListener("click", clearHistorySelection); + + document.addEventListener("change", (event) => { + const historyCheckbox = event.target.closest("[data-history-select]"); + if (historyCheckbox) { + if (historyCheckbox.checked) selectedHistoryJobIds.add(historyCheckbox.value); + else selectedHistoryJobIds.delete(historyCheckbox.value); + updateHistorySelectionUi(); + return; + } + const checkbox = event.target.closest("[data-publish-select]"); + if (checkbox) { + const row = checkbox.closest("[data-publish-row]"); + if (checkbox.checked && row?.dataset.platform !== activePlatform) { + checkbox.checked = false; + showMessage("当前平台与任务不一致,已阻止跨平台选择。", "error"); + } else if (checkbox.checked) selectedJobIds.add(checkbox.value); else selectedJobIds.delete(checkbox.value); + updateSelectionUi(); + } + const form = event.target.closest("[data-publish-editor]"); + if (form && (event.target.matches("[data-platform-select]") || event.target.matches("[data-copyright-select]"))) syncPlatformFields(form); + if (event.target.closest("[data-schedule-form]")) invalidatePreview(); + }); + + document.addEventListener("submit", async (event) => { + const form = event.target.closest("[data-publish-editor]"); + if (form) { + event.preventDefault(); + const row = form.closest("[data-publish-row]"); + const jobId = row?.dataset.jobId; + const resultNode = form.querySelector("[data-editor-result]"); + const platform = String(row?.dataset.platform || form.elements.platform.value || "douyin"); + const publishMode = String(form.elements.publish_mode.value || "local_browser"); + const target = { platform, account_id: String(form.elements.account_id.value || ""), publish_mode: publishMode }; + const content = { + title: String(form.elements.title.value || "").trim(), + description: String(form.elements.description.value || "").trim(), + tags: String(form.elements.tags.value || "").trim(), + visibility: String(form.elements.visibility.value || "public"), + cover_file_path: String(form.elements.cover_file_path.value || ""), + cover_time_seconds: Number(form.elements.cover_time_seconds.value || 0), + allow_download: Boolean(form.elements.allow_download.checked), + bilibili_tid: String(form.elements.bilibili_tid.value || "娱乐"), + bilibili_copyright: String(form.elements.bilibili_copyright.value || "original"), + bilibili_source: String(form.elements.bilibili_source.value || ""), + }; + try { + await window.apiFetch(`/api/publish/jobs/${jobId}/target`, { method: "PATCH", body: JSON.stringify(target) }); + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/send-content`, { method: "PATCH", body: JSON.stringify(content) }); + updateRowFromJob(data.job); + if (resultNode) resultNode.textContent = "已保存"; + } catch (error) { + if (resultNode) resultNode.textContent = `保存失败:${error.message}`; + } + return; + } + + const accountForm = event.target.closest("[data-account-create]"); + if (accountForm) { + event.preventDefault(); + const resultNode = accountForm.querySelector("[data-account-form-result]"); + try { + const data = await window.apiFetch("/api/publish/accounts", { + method: "POST", + body: JSON.stringify({ platform: accountForm.elements.platform.value, account_name: accountForm.elements.account_name.value }), + }); + const account = data.account; + document.querySelectorAll("[data-account-select], [data-batch-account]").forEach((select) => { + const option = new Option(`${account.account_name} · ${account.platform_label} · 需登录`, account.id); + option.dataset.accountPlatform = account.platform; + select.add(option); + }); + updateAccountRow(account); + accountForm.reset(); + accountForm.elements.platform.value = activePlatform; + const platformNode = accountForm.querySelector("[data-account-create-platform]"); + if (platformNode) platformNode.textContent = platformLabel(); + if (resultNode) resultNode.textContent = "账号已保存,请点击登录 / 重新登录。"; + showMessage("账号记录已保存;系统没有保存账号密码。", "success"); + await refreshJobs(); + } catch (error) { + if (resultNode) resultNode.textContent = `保存失败:${error.message}`; + } + } + }); + + document.addEventListener("click", async (event) => { + const taskGroupToggle = event.target.closest("[data-task-group-toggle]"); + if (taskGroupToggle) { + const group = taskGroupToggle.closest("[data-publish-task-group]"); + setTaskGroupExpanded(group, group?.dataset.expanded !== "true"); + return; + } + + const historyHide = event.target.closest("[data-history-hide]"); + if (historyHide) { + const jobId = historyHide.closest("[data-history-record]")?.dataset.jobId; + if (jobId) await updateHistoryVisibility(true, [jobId]); + return; + } + + const historyRestore = event.target.closest("[data-history-restore]"); + if (historyRestore) { + const jobId = historyRestore.closest("[data-history-record]")?.dataset.jobId; + if (jobId) await updateHistoryVisibility(false, [jobId]); + return; + } + + const setupButton = event.target.closest("[data-send-setup]"); + if (setupButton) { + await handleSendSetup(setupButton.closest("[data-publish-row]")); + return; + } + + const dismissButton = event.target.closest("[data-dismiss-job]"); + if (dismissButton) { + const row = dismissButton.closest("[data-publish-row]"); + const jobId = row?.dataset.jobId; + const taskName = row?.closest("[data-publish-task-group]")?.querySelector("h3")?.textContent?.trim() || "未命名任务"; + const clipName = row?.querySelector("[data-row-title]")?.textContent?.trim() || "当前片段"; + const confirmation = [ + `确认把“${clipName}”移出${platformLabel(row?.dataset.platform)}内容准备?`, + "", + `所属任务:${taskName}`, + "如果已经排期,排期会同时取消。原视频、裁剪成片、字幕和另一个平台的内容都不会删除。", + "以后可以在“执行记录”中重新加入。", + ].join("\n"); + if (!jobId || !window.confirm(confirmation)) return; + dismissButton.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/dismiss`, { method: "POST" }); + updateRowFromJob(data.job); + showMessage(data.message || "已从当前平台内容准备中移出。", "success"); + } catch (error) { + showMessage(`移出失败:${error.message}`, "error"); + } finally { dismissButton.disabled = false; } + return; + } + + const repairButton = event.target.closest("[data-repair-job]"); + if (repairButton) { + const row = repairButton.closest("[data-publish-row]"); + const sourceId = row?.dataset.jobId; + const accountSelect = row?.querySelector("[data-repair-account-select]"); + const accountId = String(accountSelect?.value || ""); + const visibility = String(row?.querySelector("[data-repair-visibility]")?.value || row?.dataset.visibility || "public"); + if (accountSelect && !accountId) { + showMessage("请先选择用于替代任务的同平台账号。", "error"); + accountSelect.focus(); + return; + } + if (!sourceId || !window.confirm(sendConfirmation(row, "确认转换并发送?原需复核记录会保留,系统只会创建一条同平台的 Windows Chrome 投稿任务。", visibility))) return; + repairButton.disabled = true; + try { + const queryParams = new URLSearchParams({ visibility }); + if (accountId) queryParams.set("account_id", accountId); + const query = `?${queryParams.toString()}`; + const data = await window.apiFetch(`/api/publish/jobs/${sourceId}/repair-and-publish${query}`, { method: "POST" }); + if (!row?.matches("[data-history-record]")) cloneRowsForRetry(sourceId, data.job); + showMessage(data.message || "旧记录已保留,替代任务已进入统一调度器。", "success"); + await refreshHistory({ calendar: true, records: true }); + } catch (error) { + if (!applyReadinessError(error, row)) showMessage(`修复发送失败:${error.message}`, "error"); + } finally { repairButton.disabled = false; } + return; + } + + const publishNowButton = event.target.closest("[data-publish-now]"); + if (publishNowButton) { + const publishRow = publishNowButton.closest("[data-publish-row]"); + const jobId = publishRow?.dataset.jobId; + if (!jobId || !window.confirm(sendConfirmation(publishRow, "确认立即发送?任务会先进入 SCHEDULED,再由统一调度器执行真实投稿。"))) return; + publishNowButton.disabled = true; + try { + const result = await window.apiFetch(`/api/publish/jobs/${jobId}/publish-now`, { method: "POST" }); + if (result.source_job_id && result.job?.id && result.job.id !== jobId) { + cloneRowsForRetry(jobId, result.job); + await refreshJobs(); + } else { + updateRowFromJob(result.job || { id: jobId, status: "SCHEDULED" }); + } + showMessage(result.message || "任务已按当前北京时间加入统一调度器。", "success"); + } catch (error) { + if (!applyReadinessError(error, publishNowButton.closest("[data-publish-row]"))) { + showMessage(`立即发送失败:${error.message}`, "error"); + } + } finally { publishNowButton.disabled = false; } + return; + } + + const clearButton = event.target.closest("[data-clear-schedule]"); + if (clearButton) { + const jobId = clearButton.closest("[data-publish-row]")?.dataset.jobId; + try { + const data = await window.apiFetch("/api/publish/jobs/schedule-batch", { method: "PATCH", body: JSON.stringify({ job_ids: [jobId], platform: activePlatform, action: "clear", timezone: APP_TIMEZONE }) }); + (data.jobs || []).forEach(updateRowFromJob); + showMessage(data.message, "success"); + } catch (error) { showMessage(`清除排期失败:${error.message}`, "error"); } + return; + } + + const cancelButton = event.target.closest("[data-cancel-job]"); + if (cancelButton) { + const row = cancelButton.closest("[data-publish-row]"); + const jobId = row?.dataset.jobId; + const confirmation = [ + "确认取消这条发送安排并返回“内容准备”?", + "", + "当前排期会清除;视频、标题、简介、话题和封面都会保留。", + ].join("\n"); + if (!jobId || !window.confirm(confirmation)) return; + cancelButton.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/cancel`, { method: "POST" }); + updateRowFromJob(data.job); + selectedJobIds.delete(jobId); + updateSelectionUi(); + syncContentTaskGroups(); + const contentRow = document.querySelector( + `[data-publish-row][data-section="content"][data-job-id="${CSS.escape(jobId)}"]`, + ); + setTaskGroupExpanded(contentRow?.closest("[data-publish-task-group]"), true); + switchTab("content"); + contentRow?.scrollIntoView({ behavior: "smooth", block: "center" }); + showMessage(data.message || "已取消发送并返回内容准备。", "success"); + } catch (error) { + showMessage(`取消发送失败:${error.message}`, "error"); + } finally { + cancelButton.disabled = false; + } + return; + } + + const restoreButton = event.target.closest("[data-restore-job]"); + if (restoreButton) { + const row = restoreButton.closest("[data-publish-row]"); + const jobId = row?.dataset.jobId; + if (!jobId || !window.confirm(`确认把这条${platformLabel(row?.dataset.platform)}内容重新加入“内容准备”?\n\n恢复后不会自动排期或发送。`)) return; + restoreButton.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/restore`, { method: "POST" }); + updateRowFromJob(data.job); + showMessage(data.message || "已重新加入内容准备。", "success"); + await refreshHistory({ calendar: true, records: true }); + } catch (error) { + showMessage(`恢复失败:${error.message}`, "error"); + } finally { restoreButton.disabled = false; } + return; + } + + const metadataButton = event.target.closest("[data-generate-metadata]"); + if (metadataButton) { + const row = metadataButton.closest("[data-publish-row]"); + const jobId = row?.dataset.jobId; + metadataButton.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/metadata?use_ai=true`, { method: "POST" }); + const form = row.querySelector("[data-publish-editor]"); + form.elements.title.value = data.job.title || ""; + form.elements.description.value = data.job.description || ""; + form.elements.tags.value = data.job.tags || ""; + updateRowFromJob(data.job); + } catch (error) { showMessage(`AI 补齐失败:${error.message}`, "error"); } + finally { metadataButton.disabled = false; } + return; + } + + const coverButton = event.target.closest("[data-generate-cover]"); + if (coverButton) { + const row = coverButton.closest("[data-publish-row]"); + const form = row?.querySelector("[data-publish-editor]"); + const current = Number(form?.elements.cover_time_seconds?.value || 0); + const rawSeconds = window.prompt("请输入要作为封面的画面秒数(例如 3.5):", String(current)); + if (rawSeconds === null) return; + const seconds = Number(rawSeconds); + if (!Number.isFinite(seconds) || seconds < 0) { showMessage("封面秒数必须是大于或等于 0 的数字。", "error"); return; } + coverButton.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs/${row.dataset.jobId}/cover`, { + method: "POST", + body: JSON.stringify({ + task_id: coverButton.dataset.taskId, + output_clip_id: coverButton.dataset.clipId, + video_source: coverButton.dataset.videoSource || "original", + title: String(form.elements.title.value || "发布封面"), + cover_time_seconds: seconds, + }), + }); + form.elements.cover_file_path.value = data.cover_file_path || ""; + form.elements.cover_time_seconds.value = String(seconds); + const preview = row.querySelector("[data-cover-preview]"); + if (preview && data.cover_media_url) { preview.src = data.cover_media_url; preview.hidden = false; } + updateRowFromJob(data.job); + showMessage(data.message || "封面已生成。", "success"); + } catch (error) { showMessage(`封面生成失败:${error.message}`, "error"); } + finally { coverButton.disabled = false; } + return; + } + + const addPlan = event.target.closest("[data-add-to-plan]"); + if (addPlan) { + const planRow = addPlan.closest("[data-publish-row]"); + const jobId = planRow?.dataset.jobId; + if (planRow?.dataset.platform !== activePlatform) { + showMessage("当前平台与任务不一致,已阻止加入计划。", "error"); + return; + } + selectedJobIds.add(jobId); + updateSelectionUi(); + switchTab("schedule"); + openDrawer(); + return; + } + + const retryButton = event.target.closest("[data-retry-job]"); + if (retryButton) { + const sourceId = retryButton.closest("[data-publish-row]")?.dataset.jobId; + const retryRow = retryButton.closest("[data-publish-row]"); + const visibility = retryRow?.querySelector("[data-retry-visibility]")?.value || retryRow?.dataset.visibility || "public"; + if (!window.confirm(`${sendConfirmation(retryRow, "确认立即发送?", visibility)}\n\n系统会保留原失败记录,并创建一条立即执行的新任务。`)) return; + retryButton.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs/${sourceId}/retry`, { method: "POST", body: JSON.stringify({ visibility }) }); + if (!retryRow?.matches("[data-history-record]")) cloneRowsForRetry(sourceId, data.job); + showMessage("原失败记录已保留,新任务已进入统一调度器并开始立即发送。", "success"); + await refreshHistory({ calendar: true, records: true }); + } catch (error) { + if (!applyReadinessError(error, retryRow)) showMessage(`立即发送失败:${error.message}`, "error"); + } finally { + retryButton.disabled = false; + } + return; + } + + const markPublished = event.target.closest("[data-mark-published]"); + if (markPublished) { + const row = markPublished.closest("[data-publish-row]"); + const jobId = row?.dataset.jobId; + const platformUrl = window.prompt("请粘贴已经人工核对过的平台作品链接。没有链接不能标记成功:", ""); + if (!platformUrl || !window.confirm("确认该链接对应本任务,并将任务标记为已发布?")) return; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/mark-published`, { method: "POST", body: JSON.stringify({ platform_url: platformUrl }) }); + updateRowFromJob(data.job || { id: jobId, status: "PUBLISHED", platform_url: platformUrl }); + await refreshHistory({ calendar: true, records: true }); + } catch (error) { showMessage(`标记失败:${error.message}`, "error"); } + return; + } + + const markFailed = event.target.closest("[data-mark-failed]"); + if (markFailed) { + const jobId = markFailed.closest("[data-publish-row]")?.dataset.jobId; + if (!window.confirm("请先在平台确认没有发布成功。确认将本任务标记为失败?")) return; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/mark-failed`, { method: "POST" }); + updateRowFromJob(data.job || { id: jobId, status: "FAILED" }); + await refreshHistory({ calendar: true, records: true }); + } catch (error) { showMessage(`标记失败:${error.message}`, "error"); } + return; + } + + const openCreator = event.target.closest("[data-open-creator]"); + if (openCreator) { + const accountId = openCreator.closest("[data-publish-row]")?.dataset.accountId; + if (!accountId) { showMessage("这条任务没有发布账号。", "error"); return; } + try { + const data = await window.apiFetch(`/api/publish/accounts/${accountId}/open-center`, { method: "POST" }); + showMessage(data.message || "已打开创作者中心。", "success"); + } catch (error) { showMessage(`打开失败:${error.message}`, "error"); } + return; + } + + const viewEvents = event.target.closest("[data-view-events]"); + if (viewEvents) { + const jobId = viewEvents.closest("[data-publish-row]")?.dataset.jobId; + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/events`); + const lines = (data.events || []).map((item) => `${item.occurred_at} · ${item.from_status || "—"} → ${item.to_status || "—"} · ${item.message || item.event_type}`); + window.alert(lines.join("\n") || "暂无执行事件。"); + } catch (error) { showMessage(`读取执行详情失败:${error.message}`, "error"); } + return; + } + + const accountAction = event.target.closest("[data-account-login], [data-account-check], [data-account-open-center]"); + if (accountAction) { + const accountRow = accountAction.closest("[data-account-row]"); + const accountId = accountRow?.dataset.accountId; + const action = accountAction.matches("[data-account-login]") + ? "login" + : (accountAction.matches("[data-account-open-center]") ? "open-center" : "check"); + accountAction.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/accounts/${accountId}/${action}`, { method: "POST" }); + const account = data.account || {}; + updateAccountRow(account); + showMessage(data.message || data.worker_result?.message || "账号操作已执行。", "success"); + await Promise.all([refreshAccounts(), refreshJobs()]); + } catch (error) { + document.querySelector("[data-worker-help]")?.removeAttribute("hidden"); + showMessage(`账号操作失败:${error.message}`, "error"); + } + finally { accountAction.disabled = false; } + } + }); + + document.querySelector("[data-refresh-worker]")?.addEventListener("click", () => refreshSchedulerHealth(true)); + + document.querySelector("[data-open-schedule-drawer]")?.addEventListener("click", openDrawer); + document.querySelector("[data-close-schedule-drawer]")?.addEventListener("click", closeDrawer); + drawerBackdrop?.addEventListener("click", closeDrawer); + document.querySelector("[data-clear-selection]")?.addEventListener("click", () => { selectedJobIds.clear(); updateSelectionUi(); }); + + document.querySelector("[data-open-account-drawer]")?.addEventListener("click", () => openAccountDrawer()); + function closeAccountDrawer() { accountDrawer.hidden = true; accountBackdrop.hidden = true; document.body.classList.remove("has-schedule-drawer"); } + document.querySelector("[data-close-account-drawer]")?.addEventListener("click", closeAccountDrawer); + accountBackdrop?.addEventListener("click", closeAccountDrawer); + + document.querySelector("[data-apply-batch-target]")?.addEventListener("click", async () => { + const payload = { + job_ids: Array.from(selectedJobIds), + platform: activePlatform, + account_id: document.querySelector("[data-batch-account]").value, + publish_mode: "local_browser", + }; + try { + const data = await window.apiFetch("/api/publish/jobs/target-batch", { method: "PATCH", body: JSON.stringify(payload) }); + (data.jobs || []).forEach(updateRowFromJob); + showMessage(`已更新 ${data.updated_count} 条${platformLabel()}任务的账号。`, "success"); + } catch (error) { showMessage(`批量设置失败:${error.message}`, "error"); } + }); + + document.querySelector("[data-batch-ai]")?.addEventListener("click", async () => { + for (const jobId of Array.from(selectedJobIds)) { + try { + const data = await window.apiFetch(`/api/publish/jobs/${jobId}/metadata?use_ai=true`, { method: "POST" }); + updateRowFromJob(data.job); + } catch (error) { showMessage(`AI 补齐任务 ${jobId} 失败:${error.message}`, "error"); return; } + } + showMessage("所选任务的 AI 标题和简介已补齐。", "success"); + }); + + backfillCoversButton?.addEventListener("click", async () => { + const missingCount = missingCoverRows().length; + if (!missingCount) { + showMessage("当前没有需要补充封面的未发布任务。", "success"); + updateBackfillCoversButton(); + return; + } + backfillCoversButton.dataset.loading = "true"; + updateBackfillCoversButton(); + try { + const data = await window.apiFetch( + `/api/publish/covers/backfill?platform=${encodeURIComponent(activePlatform)}`, + { method: "POST" }, + ); + (data.jobs || []).forEach(updateRowFromJob); + const errorText = (data.errors || []) + .slice(0, 3) + .map((item) => `${item.output_file_name || item.output_clip_id}:${item.message}`) + .join(";"); + showMessage( + `${data.message || "封面补充完成。"}${errorText ? ` ${errorText}` : ""}`, + data.status === "partial" ? "error" : "success", + ); + } catch (error) { + showMessage(`一键补充封面失败:${error.message}`, "error"); + } finally { + backfillCoversButton.dataset.loading = "false"; + updateBackfillCoversButton(); + } + }); + + scheduleForm?.addEventListener("input", () => { + invalidatePreview(); + showLatestScheduleNote(); + }); + + scheduleForm?.elements.interval_preset?.addEventListener("change", () => { + document.querySelector("[data-custom-interval]").hidden = scheduleForm.elements.interval_preset.value !== "custom"; + }); + + latestScheduleButton?.addEventListener("click", async () => { + const payload = schedulePayload("apply"); + const request = { + job_ids: payload.job_ids, + platform: payload.platform, + timezone: payload.timezone, + interval_minutes: payload.interval_minutes, + daily_start_time: payload.daily_start_time, + daily_end_time: payload.daily_end_time, + }; + latestScheduleButton.disabled = true; + latestScheduleButton.textContent = "正在查询当前最晚排期…"; + showLatestScheduleNote(); + showScheduleFeedback("正在读取当前平台的最新排期,请稍候。"); + try { + const data = await window.apiFetch("/api/publish/schedules/next-start", { + method: "POST", + body: JSON.stringify(request), + }); + invalidatePreview(); + if (data.status === "empty") { + showLatestScheduleNote(data.message || "当前平台暂无其他未来排期,请手动选择时间。"); + return; + } + scheduleForm.elements.start_at_local.value = data.next_start_at_local; + showLatestScheduleNote( + `当前最晚:${data.latest_scheduled_at_local_display};本次第 1 条:${data.next_start_at_local_display}`, + ); + } catch (error) { + showScheduleFeedback(`读取最晚排期失败:${error.message}`, "error"); + } finally { + latestScheduleButton.disabled = false; + latestScheduleButton.textContent = "接在当前平台最晚排期后"; + } + }); + + previewScheduleButton?.addEventListener("click", async () => { + const payload = schedulePayload("apply"); + if (!payload.start_at_local || beijingInputToTimestamp(payload.start_at_local) <= Date.now()) { + showScheduleFeedback("请选择晚于当前时间的北京时间。", "error"); + return; + } + previewScheduleButton.disabled = true; + previewScheduleButton.textContent = "正在生成预览…"; + showScheduleFeedback("正在按北京时间计算每一条发布时间,请稍候。"); + try { + const data = await window.apiFetch("/api/publish/schedules/preview", { method: "POST", body: JSON.stringify(payload) }); + previewList.innerHTML = ""; + latestPreviewItems = data.schedule || []; + latestPreviewItems.forEach((item, index) => { + const row = document.querySelector(`[data-publish-row][data-job-id="${CSS.escape(item.job_id)}"]`); + const line = document.createElement("div"); + line.innerHTML = `第 ${index + 1} 条:${row?.querySelector("[data-row-title]")?.textContent || item.job_id}`; + previewList.appendChild(line); + }); + latestPreviewSignature = previewSignature(payload); + confirmScheduleButton.disabled = false; + showScheduleFeedback(`已生成 ${latestPreviewItems.length} 条具体发布时间,请核对后确认应用。`); + } catch (error) { + const row = document.querySelector(`[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(payload.job_ids[0] || "")}"]`); + applyReadinessError(error, row); + showScheduleFeedback(`排期预览失败:${error.message}`, "error"); + } finally { + previewScheduleButton.disabled = false; + previewScheduleButton.textContent = "预览排期"; + } + }); + + scheduleForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + const payload = schedulePayload("apply"); + if (latestPreviewSignature !== previewSignature(payload) || !latestPreviewItems.length) { + showScheduleFeedback("排期参数已变化,请重新预览。", "error"); + return; + } + payload.confirmed_schedule = latestPreviewItems; + confirmScheduleButton.disabled = true; + confirmScheduleButton.textContent = "正在应用排期…"; + showScheduleFeedback("正在保存已确认的具体发布时间,请稍候。"); + try { + const data = await window.apiFetch("/api/publish/jobs/schedule-batch", { method: "PATCH", body: JSON.stringify(payload) }); + (data.jobs || []).forEach(updateRowFromJob); + const saved = (data.schedule || []).map((item, index) => `第 ${index + 1} 条:${item.scheduled_at_local_display}`).join(";"); + showMessage(`${data.message} ${saved}`, "success"); + selectedJobIds.clear(); updateSelectionUi(); closeDrawer(); invalidatePreview(); + } catch (error) { + const row = document.querySelector(`[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(payload.job_ids[0] || "")}"]`); + applyReadinessError(error, row); + showScheduleFeedback(`排期保存失败:${error.message}`, "error"); + confirmScheduleButton.disabled = false; + } finally { + confirmScheduleButton.textContent = "确认应用具体时间"; + } + }); + + document.querySelector("[data-supplement-publish-jobs]")?.addEventListener("click", async (event) => { + const button = event.currentTarget; button.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/queue/refresh?use_ai=false&platform=${encodeURIComponent(activePlatform)}`, { method: "POST" }); + showMessage(data.message || "缺失任务已补充,请稍后查看内容准备区。", "success"); + } catch (error) { showMessage(`补充任务失败:${error.message}`, "error"); } + finally { button.disabled = false; } + }); + + document.querySelectorAll("[data-publish-editor]").forEach(syncPlatformFields); + const focus = document.querySelector("[data-publish-focus]"); + if (focus?.dataset.platform) setActivePlatform(focus.dataset.platform); + if (focus?.dataset.tab) switchTab(focus.dataset.tab); + filterAccountOptions(document.querySelector("[data-batch-account]"), activePlatform); + if (scheduleForm?.elements.start_at_local) scheduleForm.elements.start_at_local.value = beijingDatetimeValue(Date.now() + 10 * 60 * 1000); + document.querySelectorAll('[data-publish-row][data-section="schedule"], [data-publish-row][data-section="history"]').forEach((row) => applyRowReadiness(row)); + updateSelectionUi(); applyHistoryFilter(); refreshScheduleViews(); updateBackfillCoversButton(); + if (focus?.dataset.taskId) { + const group = document.querySelector( + `[data-publish-task-group][data-task-id="${CSS.escape(focus.dataset.taskId)}"]`, + ); + if (group && !group.hidden) { + setTaskGroupExpanded(group, true); + group.scrollIntoView({ behavior: "smooth", block: "start" }); + } else { + showMessage("已定位到该处理任务,但当前平台没有可准备的新版本内容。可切换平台或返回任务页重新同步。"); + } + } + window.setInterval(() => { refreshJobs(); refreshAccounts(); refreshSchedulerHealth(); }, 5000); +} diff --git a/app/templates/base.html b/app/templates/base.html index a905f14..6cb2097 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,7 +8,8 @@ - + + {% block extra_head %}{% endblock %} @@ -60,7 +61,7 @@ + {% block extra_scripts %}{% endblock %} - diff --git a/app/templates/clip_review.html b/app/templates/clip_review.html index 2a85b9c..7ec99fa 100644 --- a/app/templates/clip_review.html +++ b/app/templates/clip_review.html @@ -15,10 +15,10 @@

片段审核 · {{ task.title }}

@@ -46,6 +46,10 @@

片段审核 · {{ task.title }}

{{ "%02d"|format(loop.index) }} + {% if clip.quality_tier %} + {{ clip.quality_tier }} 级 + 质量 {{ "%.1f"|format(clip.quality_score) }} + {% endif %} {{ clip.duration_seconds }} 秒 @@ -59,6 +63,26 @@

片段审核 · {{ task.title }}

+ {% if clip.quality_tier %} +
+
+ {{ "%.0f"|format(clip.humor_score) }}笑点闭环 + {{ "%.0f"|format(clip.completeness_score) }}内容完整 + {{ "%.0f"|format(clip.audio_reaction_score) }}音频反应 +
+ {% if clip.quality_evidence.get('why_selected') %} +

为什么值得剪:{{ clip.quality_evidence.get('why_selected') }}

+ {% endif %} + {% set audio_evidence = clip.quality_evidence.get('audio', {}) %} + {% if audio_evidence.get('labels') %} +

现场反应证据:{{ audio_evidence.get('labels')|join(';') }}

+ {% endif %} + {% if clip.rejection_reason %} +

未自动启用:{{ clip.rejection_reason }}

+ {% endif %} +
+ {% endif %} +
@@ -98,6 +122,18 @@

片段审核 · {{ task.title }}

AI 来源 / 模型
{{ clip.ai_source_label }}
+ +
+ 这条选得怎么样? +
+ + + + + + +
+
{% endfor %} {% else %} @@ -144,13 +180,18 @@

审核操作

- 去字幕推送 + 去字幕推送 + {% if output_clips %} + + 查看本任务发送内容 + {% endif %}

diff --git a/app/templates/new_task.html b/app/templates/new_task.html index 194b248..16c5d3a 100644 --- a/app/templates/new_task.html +++ b/app/templates/new_task.html @@ -6,7 +6,7 @@

New Task

新建任务

-

上传本机视频后,系统会为它创建独立任务目录,并保存到 E 盘工作流存储目录。

+

上传临时文件、原片副本和后续切片都会直接保存在 E 盘工作流存储目录,不占用 C 盘视频空间。

@@ -29,7 +29,7 @@

基本信息

上传本机视频 - 选择视频后,会复制到该任务的 source 目录。 + 选择视频后,会直接写入 E 盘任务的 source 目录;上传临时文件也在 E 盘。
+ +
+
@@ -74,7 +92,7 @@

基本信息

新建后自动跑完整流水线 -

开启后,候选片段数量就是自动切片目标数量,单条切片最长就是时长上限。任务创建成功后会立即自动提取音频、转写、AI 分析并切片,无需进入详情页再次点击开始。

+

开启后,系统按“最终启用目标”生成切片;综艺笑点优先模式只自动处理达到 A 级质量门槛的片段。

@@ -103,7 +121,7 @@

智能 MVP 流程

小贴士

-

AI 偏好不在新建任务时填写。进入任务详情后,在“AI 分析”板块填写它;建议综艺访谈任务选择 2 号 Prompt,让 AI 直接输出更完整、可发布的候选片段。片段审核页保留为检查入口,最终成片仍以启用片段为准。

+

康熙类素材请选择“综艺笑点优先”。候选池默认最多 12 条,真正进入切片队列的 A 级片段默认不超过 5 条。

diff --git a/app/templates/publish.html b/app/templates/publish.html index 62e028e..fe45fe9 100644 --- a/app/templates/publish.html +++ b/app/templates/publish.html @@ -2,314 +2,341 @@ {% block title %}发送中心 - {{ settings.app_name }}{% endblock %} -{% block content %} -
-
-

Send Center 2.0

-

抖音 + B站发送中心

-

切好的视频、自动封面帧、AI 标题和平台 #话题在这里排队;确认后由 opencli 使用 Chrome 登录态逐条发送。

+{% macro account_options(job) %} + + {% for account in accounts %} + + {% endfor %} +{% endmacro %} + +{% macro content_row(job) %} +
+ +
+ + {{ job.title }} 的封面
-
- - - +
+
+
裁剪片段{{ job.output_file_name or job.title }}
+
+ + {{ "内容完整" if job.content_complete else "缺少:" ~ (job.missing_fields|join("、")) }} + + +
+
+
+ + + + + + + +
+ + + +
+ + + +
+
+ + + + + +
+
+
+{% endmacro %} + +{% macro schedule_row(job) %} +{% set readiness = job.send_readiness %} +
+ + +
{{ job.title }}
+ {{ job.platform_label }} + {{ job.account_name }} + {{ job.status_label }} +
+ {{ readiness.message }} + {% if readiness.ready or readiness.can_auto_resolve %} + + {% else %} + + {% endif %} + {% if job.status == "SCHEDULED" %}{% endif %} +
-
+ +{% endmacro %} -{% if publish_message %} -
{{ publish_message }}
-{% endif %} +{% macro history_row(job) %} +{% set readiness = job.send_readiness %} +
+ {{ job.title }} + {{ job.platform_label }} + {{ job.account_name }} + + + + {{ job.status_label }} + {{ job.error_message or job.execution_phase_label or "" }} + {{ job.publish_mode_label }} +
+ {% if job.status == "NEED_REVIEW" and readiness.repairable %} + {{ readiness.message }} + + {% if readiness.action == "select_account" %} + + + {% elif readiness.dispatch_ready %} + + {% else %} + + {% endif %} + {% endif %} + {% if job.platform_url %}平台链接{% endif %} + {% if job.status == "FAILED" %} + + + {% endif %} + {% if job.status == "NEED_REVIEW" %} + + + + {% endif %} + {% if job.is_user_removed %}{% endif %} + +
+ {% if job.error_message %}
查看错误

{{ job.error_message }}

{{ job.error_code or '' }}
{% endif %} +
+{% endmacro %} -{% if not opencli_available %} -
- {{ opencli_status.message }} -

请继续使用 Docker 主页面 http://127.0.0.1:8001,不要切到第二个网页。

-

推荐处理:在项目目录运行 {{ opencli_status.restart_command }},脚本会启动 Windows opencli 辅助服务、刷新 Docker,并打开 {{ opencli_status.publish_url }}

-

页面打开后按 Ctrl + F5 强制刷新,再回到发送中心测试自动发送。

+{% block content %} + +
+

Publish Center

发送中心

先准备内容,再按北京时间排期,最后在执行记录中确认平台结果。

+
-{% endif %} - @@ -118,9 +126,23 @@

AI 分析

+ + @@ -150,7 +172,7 @@

AI 分析

{% endfor %}
-

选择的方案会直接作为本次 AI 片段分析 Prompt。系统会自动替换最大时长、候选数量、AI 偏好和转写文本变量;最终成片仍需要进入人工审核确认。

+

通用模式直接使用所选 Prompt;综艺笑点优先模式会把所选 Prompt 作为口味补充,再执行重叠召回、边界扩展、音频复核和全局评审。

diff --git a/app/templates/tasks.html b/app/templates/tasks.html index 55f0a88..6649250 100644 --- a/app/templates/tasks.html +++ b/app/templates/tasks.html @@ -64,6 +64,10 @@

任务列表

{% if task.output_clip_count > 0 %} 待加字幕 + {% set link = task.publish_link_state or {} %} + + {{ link.label or "等待发送关联" }} + {% elif task.status in ["completed", "completed_with_errors"] %} 等待切片文件 {% else %} @@ -74,8 +78,9 @@

任务列表

详情 审核 - 字幕 - + 字幕 + {% if task.output_clip_count > 0 %}发送中心{% endif %} + {% endfor %} diff --git a/docker-compose.yml b/docker-compose.yml index 89fffe7..02c5b4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,12 @@ services: AI_LOCAL_BASE_URL: http://host.docker.internal:11434/v1 OPENCLI_LOCAL_BASE_URL: http://127.0.0.1:8001 OPENCLI_HOST_BRIDGE_URL: http://host.docker.internal:8765 + APP_TIMEZONE: Asia/Shanghai + PUBLISH_DEFAULT_MODE: local_browser + PUBLISH_SCHEDULER_INTERVAL_SECONDS: 5 + PUBLISH_WORKER_URL: http://host.docker.internal:8765 + PUBLISH_WORKER_TOKEN: ${PUBLISH_WORKER_TOKEN:-} + PUBLISH_ENABLE_OPENCLI_FALLBACK: ${PUBLISH_ENABLE_OPENCLI_FALLBACK:-false} TRANSCRIPTION_DEVICE: cpu TRANSCRIPTION_COMPUTE_TYPE: int8 volumes: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 986a7b6..34d8457 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ ### 1.1 架构形态 -当前 v1.3 为 **FastAPI 单体应用**,运行在 Windows 本地,所有组件打包在同一个进程中。 +当前 v1.5.0 保持 **FastAPI 单体应用 + SQLite**。视频、AI、页面和调度器仍在同一个应用中;只有必须使用宿主系统 Chrome 的真实发布动作由 Windows Worker 执行,不引入 Redis、Celery 或微服务。 ```text ┌─────────────────────────────────────────────────────────┐ @@ -46,16 +46,21 @@ | **视频处理** | FFmpeg / FFprobe | 音频提取、视频切割、字幕合成、封面帧 | | **语音转写** | faster-whisper / 火山引擎 | 本地模型或远程 API,输出逐句时间戳 | | **AI 分析** | DeepSeek API / Ollama | Provider 抽象层,支持 chat/completions 和 responses 协议 | -| **发布辅助** | opencli | 调用已登录 Chrome 辅助浏览器投稿 | +| **真实发布** | Playwright + 系统 Chrome | Windows Worker 使用每个平台/账号独立浏览器目录投稿 | | **容器化** | Docker + docker-compose | 可选部署方式,开发/测试用 | ### 1.3 核心设计决策 -- **单体进程**:所有路由、服务、数据库访问在同一 Python 进程中,无独立 Worker。 +- **单体业务应用**:页面、路由、视频、AI、SQLite 和 Scheduler 保持同一 FastAPI 应用;Windows Worker 只隔离宿主 Chrome 操作。 +- **SQLite 单写入者**:只有 Docker 内的 FastAPI 可以读写 `workflow.sqlite3`;Windows Worker 不导入数据库仓储、不打开 SQLite,只通过 HTTP 返回账号检查/发布结果并写独立执行日志。 - **同步 FFmpeg**:视频处理通过 `subprocess` 同步调用,阻塞当前请求直到完成。 -- **无消息队列**:任务处理由前端按钮触发,无后台 Job Queue / Celery。 +- **无外部消息队列**:发布队列直接使用 SQLite 原子状态更新,无 Redis / Celery。 - **无用户体系**:单用户本地使用,通过 `LOCAL_ADMIN_TOKEN` 做简易鉴权。 -- **无定时调度**:`publish_jobs.scheduled_at` 仅为字段预留,不自动发送。 +- **统一定时调度**:立即发送与未来排期都先写 `SCHEDULED`,再由 `PublishScheduler` 原子领取。 +- **终态原子提交**:平台结果、任务终态和事件由 FastAPI 在同一 SQLite 事务写入;任一步失败都会回滚,避免出现“平台结果已记但任务仍在发送中”。 +- **调度循环自恢复**:单条任务异常不会阻塞后续排期;SQLite 临时异常只结束当前扫描,常驻循环按配置间隔继续重试并在健康接口公开连续失败次数。 +- **执行日志恢复**:Worker 的 `/v1/executions/{execution_id}` 是跨进程中断恢复依据;已确认成功只补记终态,结果不确定一律进入人工复核,不自动重复投稿。 +- **保守结果语义**:只有平台成功证据进入 `PUBLISHED`;不确定结果进入 `NEED_REVIEW`,禁止自动重复上传。 --- @@ -87,8 +92,11 @@ app/ ├── ai_clip_service.py ← AI 片段分析编排 ├── ai_config_service.py ← AI 配置读写 ├── ai_prompt_preset_service.py ← Prompt 方案服务 - ├── publish_service.py ← 发送中心服务 - ├── publish_providers.py ← 发布平台 Provider + ├── publish_service.py ← 内容准备、账号和兼容 API + ├── publish_scheduler.py ← SQLite 排期、原子领取、恢复和状态机 + ├── publish_repository.py← 发布结果脱敏与事件记录 + ├── publish_time.py ← 北京时间输入与 UTC 存储 + ├── publishers/ ← Registry、模式 Publisher、抖音/B站 Publisher、Worker 客户端 └── ai/ ├── base.py ← AI Provider 抽象基类 ├── local_model_provider.py ← Ollama 本地 Provider @@ -108,7 +116,7 @@ app/ - **迁移方式**:`init_db()` 启动时自动执行 `CREATE TABLE IF NOT EXISTS` + 逐列 ALTER TABLE 补齐 - **种子数据**:启动时自动写入默认 AI Prompt 方案、字幕样式、平台配置 -### 3.2 表结构(10 张表) +### 3.2 发布相关表 | 表名 | 用途 | | --- | --- | @@ -122,6 +130,7 @@ app/ | `publish_platform_configs` | 平台 OAuth 配置 | | `publish_accounts` | 发布账号 | | `publish_jobs` | 发布任务队列 | +| `publish_job_events` | 状态流转、领取、重试和平台结果事件 | 详见 [DATABASE_SCHEMA.md](DATABASE_SCHEMA.md) @@ -161,33 +170,37 @@ app/ ## 5. 发送中心架构 -```text -output_clip 生成 + 字幕完成 - │ - ▼ -┌───────────────┐ -│ 发送中心页面 │ -│ /publish │ -└───────┬───────┘ - │ - ▼ -┌───────────────────────────────────────────┐ -│ publish_service.py │ -│ │ -│ ┌─────────┐ ┌─────────┐ ┌──────────┐ │ -│ │ 队列刷新 │ │ AI 文案 │ │ 封面帧 │ │ -│ │ 双平台 │ │ 标题/简介│ │ 07_covers│ │ -│ └─────────┘ └─────────┘ └──────────┘ │ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ opencli 辅助浏览器投稿 │ │ -│ │ 抖音 + B站 Chrome Profile │ │ -│ └─────────────────────────────────────┘ │ -└───────────────────────────────────────────┘ +```mermaid +flowchart TD + A["发送中心:内容准备"] --> B["立即发送:当前时间"] + A --> C["排期计划:未来时间"] + B --> D["publish_jobs / SCHEDULED"] + C --> D + D --> E["SQLite BEGIN IMMEDIATE 原子领取"] + E --> F["PUBLISHING"] + F --> G["Publisher Registry"] + G --> H["LocalBrowserPublisher"] + H --> I["Windows Worker + 独立 Chrome Profile"] + I --> J["DouyinPublisher / BilibiliPublisher"] + J --> K["PUBLISHED"] + J --> L["FAILED"] + J --> M["NEED_REVIEW"] ``` +`manual_export` 是显式的独立模式,成功状态为 `EXPORTED`;真实平台发送失败不会切换成导出包。旧 `opencli_publish` 仅在兼容开关打开时走同一 Scheduler 状态机。 + **安全边界**:不绕过验证码、登录失效、风控和人工确认。 +### 5.1 切片版本与发送中心关联规则 + +- `output_clip.is_active = 1` 是任务当前切片版本,也是内容准备和排期计划唯一允许使用的来源;不新增任务级外键或关联表。 +- 手动切片成功后调用任务级同步服务,为目标平台创建 `WAITING` 内容。通用任务目标为抖音和 B站;平台专属任务只创建对应平台。 +- 同一当前切片、同一平台已经存在非取消记录时保持幂等,不重复创建。全局补充尊重 `user_removed_from_preparation`;任务级显式同步可以恢复该记录,并始终清空旧排期。 +- 新切片激活后,旧切片上的 `DRAFT / WAITING / SCHEDULED` 改为 `CANCELLED + superseded_by_recut`,同时清除排期并写 `superseded_by_recut` 事件。 +- `PUBLISHING / NEED_REVIEW / PUBLISHED / EXPORTED / FAILED` 属于执行证据,不因重新切片而改写;旧版记录只允许出现在执行历史。 +- 新版内容可按 `clip_candidate_id + platform` 继承标题、简介、标签、账号与发布方式;视频路径、封面和排期不继承。封面按新视频生成,排期保持空。 +- 默认自动同步使用原始切片。字幕工作台显式同步优先使用已完成的带字幕成片,但只允许未排期的 `DRAFT / WAITING` 更换视频;`SCHEDULED` 只返回提示,要求先取消排期。 + --- ## 6. 部署架构 @@ -210,9 +223,17 @@ Docker 容器 (niuma-studio) ├── uvicorn app.main:app --host 0.0.0.0 --port 8001 ├── 代码目录 volume 挂载(热更新) ├── 存储目录 volume 挂载(E:\ → /workspace/tasks) -└── opencli 桥接(host.docker.internal:8765) +└── 调用 Windows 发布 Worker(host.docker.internal:8765,Bearer Token) + +Windows 主机 +├── scripts/publish_host_worker.py +├── 系统 Google Chrome(默认有界面) +├── data/browser_profiles/{platform}/{account_id} +└── data/publish_worker/ 执行阶段日志 ``` +Worker 会把容器内 `/workspace/tasks/...` 映射到宿主 `.env` 的 `TASKS_DIR`,并把 `/app/...` 映射到 `PUBLISH_HOST_PROJECT_ROOT`;映射后仍必须通过允许目录和真实文件校验。 + 详见 [DEPLOYMENT.md](DEPLOYMENT.md) --- @@ -227,8 +248,9 @@ Docker 容器 (niuma-studio) → AI 候选片段分析(DeepSeek / Ollama) → 候选片段人工审核(启用/禁用/编辑时间) → FFmpeg 自动切割 → 05_clips/ -→ ASS 字幕 + FFmpeg 合成 → 06_subtitled/ -→ 发送中心队列 → opencli 辅助投稿(抖音 + B站) +→ 全自动模式跳过字幕生成/烧录 +→ 发送中心内容准备与排期 +→ Scheduler + Windows Worker 真实投稿(抖音 + B站) ``` --- @@ -242,7 +264,7 @@ Docker 容器 (niuma-studio) - 本地文件系统存储 - FFmpeg 同步本地处理 - 本地/远程 AI Provider -- opencli 发布辅助 +- 抖音/B站统一真实发布、人工复核和显式手动导出 - 代码检查与 CI 流程 ### 8.2 短期演进(P2-2 ~ P2-3) diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index b23da2c..17666d1 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,5 +1,49 @@ # 数据库结构说明 +## 2026-08-01:康熙笑点优先 V2 兼容迁移 + +- `tasks` 新增 `selection_profile TEXT NOT NULL DEFAULT 'general'` 与 `final_clip_target INTEGER NOT NULL DEFAULT 5`。历史任务自动保持 `general`,不会改变原有分析行为。 +- `clip_candidates` 新增 `quality_tier`、`quality_score`、`text_quality_score`、`humor_score`、`completeness_score`、`audio_reaction_score`、`topic_key`、`key_moment_time`、`quality_evidence_json` 和 `rejection_reason`。 +- 新增 `clip_feedback` 表,保存任务、候选、当次分析、选片模式、保留/拒绝判断、原因、备注和标题/摘要/时间快照;反馈不会删除候选或分析历史。 +- 新增索引 `idx_clip_feedback_profile_created` 与 `idx_clip_feedback_task_clip`,用于读取近期个人口味和定位候选反馈。 +- 所有变更继续使用 `CREATE TABLE IF NOT EXISTS` 与逐列 `ALTER TABLE ADD COLUMN`;不删除字段、不重建历史表、不改写历史候选。 +- `ai_prompt_presets` 新增 4 号内置方案“康熙笑点优先 V2”。若 `preset_004` 已有非空自定义内容,初始化会原样保留,不覆盖用户 Prompt。 + +## 2026-07-28:SQLite 迁移备份安全与保留规则 + +- 发布数据迁移只有在发现旧平台值或真正活跃的重复任务时才生成迁移前快照;失败、已发布、已取消和人工复核历史不会触发重复备份。 +- 备份与数据修复使用 `BEGIN IMMEDIATE` 串行化。快照由独立只读连接写入唯一临时文件,通过 `PRAGMA quick_check` 后再原子改名;备份失败时数据修复回滚。 +- 同类有效备份设置 24 小时冷却时间,并自动保留最近 14 个备份日、每天一份。 +- 维护命令为 `.venv\Scripts\python.exe scripts\cleanup_database_backups.py`;默认只预演,添加 `--apply` 才删除 `data/backups/workflow-before-publish-migration-*.sqlite3`。 +- 清理前必须保证主数据库和每天拟保留的快照完整;脚本不会删除主数据库、任务素材、浏览器登录状态或其他不匹配的文件。 + +## 2026-07-28:执行记录安全隐藏与月历查询 + +- `publish_jobs` 新增 `history_hidden INTEGER NOT NULL DEFAULT 0` 和 `history_hidden_at TEXT`;旧记录迁移后默认可见。 +- “删除记录”只允许 `PUBLISHED / FAILED / EXPORTED / CANCELLED`,仅更新上述字段并写入 `publish_job_events`,不删除发布任务、事件、视频、封面、平台链接或重试关系。 +- “恢复记录”把 `history_hidden` 恢复为 `0` 并清空 `history_hidden_at`,任务原状态和执行结果保持不变。 +- 新增索引 `idx_publish_jobs_history_visibility(history_hidden, platform, status, created_at)`。 +- 执行月历日期依次取 `scheduled_at`、`started_at`、`finished_at`、`created_at`;无时区的旧时间按 `Asia/Shanghai` 解释。 + +## 2026-07-15:v1.5.0 统一真实发布迁移 + +- 迁移继续使用启动时 `CREATE TABLE IF NOT EXISTS` 和逐列 `ALTER TABLE ADD COLUMN`;不删除旧字段、不重建表、不清空历史数据。 +- `publish_jobs` 新增:`claimed_at`、`started_at`、`finished_at`、`max_attempts DEFAULT 3`、`worker_id`、`platform_url`、`needs_manual_review DEFAULT 0`、`timezone DEFAULT 'Asia/Shanghai'`、`next_attempt_at`、`execution_id`、`execution_phase`、`retry_of_job_id`。 +- 保留并规范:`publish_mode`、`scheduled_at`、`published_at`、`attempt_count`、`last_error`、`error_code`、`remote_video_id`、`provider_response`、`publish_result`、`schedule_timezone`。 +- `publish_accounts` 新增:`login_status`、`login_checked_at`、`login_message`、`last_login_at`、`auth_type`。浏览器账号只记录本地登录状态,不保存账号密码或 Cookie。 +- 新增 `publish_job_events`,记录状态流转、原子领取、恢复、安全重试、平台结果及人工操作。 +- 新索引:`idx_publish_jobs_due_retry`、`idx_publish_jobs_execution`、`idx_publish_job_events_job_time`;活跃唯一索引只约束 `DRAFT / WAITING / SCHEDULED / PUBLISHING / NEED_REVIEW`,允许失败任务保留并创建重试副本。 +- 数据库时间统一为带 `+00:00` 的 UTC ISO 8601;业务时区固定记录为 `Asia/Shanghai`。 + +## 2026-07-11:发布平台、执行方式、时区与去重迁移 + +- `publish_jobs.platform` 只保存 `douyin` / `bilibili`;`manual_export`、`local_browser` 等值属于 `publish_mode`。 +- 新增 `schedule_timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai'`;`scheduled_at` 统一保存 UTC ISO 8601,页面按该时区转换显示。 +- 有效任务唯一索引 `uq_publish_jobs_active_clip_platform_mode` 约束同一 `output_clip_id + platform + publish_mode` 只能有一条未完成任务;`PUBLISHED`、`EXPORTED`、`CANCELLED` 历史不受该索引限制。 +- 初始化发现旧值或重复任务时,先通过 SQLite backup API 写入 `data/backups/workflow-before-publish-migration-*.sqlite3`,再迁移数据。 +- 旧 `platform=manual_export` 若 `provider_response.target_platform` 存在,会恢复真实目标平台,并按 `PUBLISH_DEFAULT_MODE` 设置执行方式;其他无效平台从任务平台恢复,最后才回退 `douyin`。 +- 未发布重复任务保留 `updated_at/created_at` 最新的一条,其他写为 `CANCELLED`,错误码为 `migration_duplicate_cancelled`,并在 `provider_response` 保存迁移原因;已发布历史不会删除。 + ## 2026-06-25:全自动配置兼容与发送中心排期 - 不新增数据库列,也不执行破坏性迁移。 @@ -21,7 +65,7 @@ - `tasks` 表新增 `task_dir_name` 字段,用来记录任务在存储盘里的实际文件夹名。 - `id` 仍是任务唯一 ID,用于数据库关联和网页地址;本地文件夹不再默认使用短 ID,而是使用 `task_dir_name`。 - 新建任务时会根据 `task_name` 生成安全的 Windows 文件夹名;重名时自动追加序号,避免覆盖旧目录。 -- `DELETE /api/tasks/{task_id}` 现在会把 `is_deleted` 设为 `1`,写入 `deleted_at`,并把任务文件夹移动到存储根目录下的 `_回收站`;不会删除文件。 +- `DELETE /api/tasks/{task_id}` 会永久删除系统托管的任务目录和发布包,再把 `is_deleted` 设为 `1` 并写入 `deleted_at`;NAS 或任务目录外的原片不会删除。 - 一次性迁移脚本为 `scripts/migrate_task_dirs_to_project_names.py`,默认 dry-run,带 `--apply` 才会移动文件夹并更新路径字段。 ## 2026-05-25:发布后台新增表 @@ -53,7 +97,12 @@ | `open_id` | TEXT | 开放平台 open_id | | `access_token` | TEXT | 接口访问 token | | `refresh_token` | TEXT | 刷新 token | -| `authorization_status` | TEXT | 授权状态:`manual` / `authorized` | +| `authorization_status` | TEXT | 兼容授权状态:`manual` / `authorized` | +| `auth_type` | TEXT | 授权方式,浏览器账号默认 `browser_profile` | +| `login_status` | TEXT | `normal` / `login_required` / `invalid` | +| `login_checked_at` | TEXT | 最近一次登录态检查时间(UTC) | +| `login_message` | TEXT | 登录态说明,不包含 Cookie 或账号密码 | +| `last_login_at` | TEXT | 最近一次确认登录成功时间(UTC) | | `remark` | TEXT | 备注 | ### publish_jobs 表 @@ -64,14 +113,14 @@ | `task_id` | TEXT | 所属视频任务 ID | | `output_clip_id` | TEXT | 所属输出切片 ID | | `account_id` | TEXT | 发布账号 ID | -| `platform` | TEXT | 发布平台 | -| `publish_mode` | TEXT | `draft`、`manual_review`、`api_publish` 或 `opencli_publish` | +| `platform` | TEXT | 目标平台,只允许 `douyin` / `bilibili` | +| `publish_mode` | TEXT | 执行方式:`opencli_publish` / `manual_export` / `api_publish` / `local_browser` | | `video_source` | TEXT | `original` 或 `subtitled` | | `video_file_path` | TEXT | 本次发布使用的视频路径 | | `title` | TEXT | 标题 | | `description` | TEXT | 简介 / 正文 | | `tags` | TEXT | 标签 | -| `status` | TEXT | `ready` / `NEED_REVIEW` / `publishing` / `published` / `failed` / `cancelled` | +| `status` | TEXT | `DRAFT` / `WAITING` / `SCHEDULED` / `PUBLISHING` / `PUBLISHED` / `EXPORTED` / `FAILED` / `CANCELLED` / `NEED_REVIEW` | | `audit_status` | TEXT | 平台审核状态 | | `platform_item_id` | TEXT | 平台稿件 / 视频 ID | | `platform_upload_id` | TEXT | 平台上传 ID | @@ -80,7 +129,37 @@ | `last_error` | TEXT | 最近一次失败说明,供后续自动重试使用 | | `provider_response` | TEXT | 平台响应摘要 JSON | | `retry_count` | INTEGER | 重试次数 | -| `scheduled_at` | TEXT | 计划发布时间(v1.2 仅字段预留,尚无后台定时调度器) | +| `attempt_count` | INTEGER | 实际领取执行次数 | +| `max_attempts` | INTEGER | 上传前安全重试上限,默认 3 | +| `scheduled_at` | TEXT | UTC ISO 8601 计划发布时间,例如 `2026-07-16T01:00:00+00:00` | +| `schedule_timezone` | TEXT | 排期计算和页面显示使用的 IANA 时区,例如 `Asia/Shanghai` | +| `timezone` | TEXT | 当前业务时区,默认 `Asia/Shanghai` | +| `next_attempt_at` | TEXT | Worker 未接收前连接失败的下一次安全重试时间 | +| `claimed_at` | TEXT | Scheduler 原子领取时间 | +| `started_at` | TEXT | 开始执行时间 | +| `finished_at` | TEXT | 完成、失败或进入人工复核时间 | +| `worker_id` | TEXT | 成功领取任务的 Scheduler 标识 | +| `execution_id` | TEXT | Windows Worker 执行日志 ID | +| `execution_phase` | TEXT | `claimed`、`upload_started`、`submit_clicked` 等阶段 | +| `retry_of_job_id` | TEXT | 手动重试来源任务 ID | +| `remote_video_id` | TEXT | 平台作品 / 稿件 ID | +| `platform_url` | TEXT | 平台作品 / 稿件链接 | +| `needs_manual_review` | INTEGER | 是否必须人工核对平台结果 | +| `published_at` | TEXT | 平台确认投稿成功时间;`EXPORTED` 不写此字段 | +| `history_hidden` | INTEGER | 是否从正常执行记录和月历安全隐藏,默认 `0` | +| `history_hidden_at` | TEXT | 安全隐藏时间;恢复后清空 | + +### publish_job_events 表 + +| 字段 | 说明 | +| --- | --- | +| `job_id` | 对应发布任务 | +| `event_type` | 领取、排期、重试、结果或人工操作类型 | +| `from_status` / `to_status` | 本次状态流转 | +| `worker_id` | 执行该事件的 Scheduler | +| `error_code` / `message` | 错误或说明 | +| `payload` | 已脱敏的 JSON 摘要 | +| `occurred_at` | UTC ISO 8601 事件时间 | ## 2026-05-23:AI Prompt 方案 @@ -139,13 +218,13 @@ data/workflow.sqlite3 | --- | --- | --- | | `id` | TEXT | 任务唯一 ID,创建时自动生成 | | `task_name` | TEXT | 任务名称 | -| `task_dir_name` | TEXT | 存储盘实际任务文件夹名;正常任务通常等于项目名,已移入回收站的任务为 `_回收站\项目名` | +| `task_dir_name` | TEXT | 存储盘实际任务文件夹名;永久删除后保留原值作为隐藏历史记录,但对应目录不再存在 | | `source_type` | TEXT | 视频来源:`upload` 或 `nas` | | `platform` | TEXT | 平台类型:`douyin`、`bilibili`、`general` | | `original_video_path` | TEXT | 本地上传视频路径,后续接真实上传后写入 | | `nas_file_path` | TEXT | NAS / 本地已有视频路径 | -| `max_clip_duration` | INTEGER | 单条切片最长时长,单位:分钟;新建任务默认建议为 5 分钟 | -| `candidate_clip_count` | INTEGER | 希望 AI 输出的候选片段数量 | +| `max_clip_duration` | INTEGER | 单条切片最长时长,单位:分钟;新建任务默认 10 分钟 | +| `candidate_clip_count` | INTEGER | 希望 AI 输出的候选片段数量;新建任务默认 12 条 | | `ai_preference` | TEXT | AI 片段选择偏好 | | `ai_prompt_preset_id` | TEXT | 当前使用的 AI Prompt 方案 ID | | `auto_mode` | INTEGER | 是否开启全自动模式,`1` 表示开启 | @@ -285,8 +364,9 @@ data/workflow.sqlite3 早期项目骨架曾使用过 `title`、`source_path`、`max_clip_minutes`、`target_clip_count` 等草稿字段。当前初始化逻辑会自动补齐新字段,并把旧字段数据迁移到当前字段中。 为了不破坏已有本地数据库,旧字段不会被强制删除。后续代码以本文件列出的当前字段为准。 +新任务默认值调整不会批量更新已有 `tasks` 记录;历史任务已经保存的最大时长和候选数量保持不变。 -任务移入回收站采用软删除方式:`DELETE /api/tasks/{task_id}` 会把 `is_deleted` 改为 `1`、写入 `deleted_at`,并把该任务的存储目录移动到 `_回收站`。工作台、任务列表和片段审核总览默认不显示已移入回收站的任务,原视频、音频、转写、AI 分析文件、切片输出和字幕输出都会保留。 +任务删除采用“媒体永久删除、数据库历史隐藏保留”的方式:`DELETE /api/tasks/{task_id}` 只允许删除 `TASKS_DIR` 下与任务精确对应的目录、该任务的手动发布包和可确认归属的旧版项目 `tasks` 目录。删除成功后把 `is_deleted` 改为 `1` 并写入 `deleted_at`,候选片段、切片、字幕和发布历史仍留在 SQLite 中用于审计。NAS 或任务目录外的原片永远不参与删除;运行中的转写、切片或真实发送任务返回 409,避免后台进程重新生成文件。 `clip_candidates.reason` 是早期推荐理由字段,当前审核页优先读取 `highlight_reason`。数据库初始化时会把已有 `reason` 自动补到 `highlight_reason`。 @@ -298,6 +378,8 @@ data/workflow.sqlite3 历史任务可能仍兼容 `task_id` 目录,但新逻辑以 `task_dir_name` 为准。 +浏览器上传超过内存阈值后的临时文件使用 `UPLOAD_TEMP_DIR`,默认位于 `{TASKS_DIR}\_临时上传`;显式手动导出的发布包使用 `PUBLISH_SCHEDULER_EXPORT_DIR`,默认位于 `{STORAGE_ROOT}\_发布包`。应用启动时会验证这些目录可写,不可用时直接报错,不会回退到 C 盘。 + 正式任务目录结构: ```text @@ -326,9 +408,8 @@ data/workflow.sqlite3 ## 2026-06-09 v1.2 补充说明 -- 发送中心当前已有发送队列和 opencli 辅助投稿能力,但还没有真正的定时调度器。 -- `publish_jobs.scheduled_at` 当前只是字段预留,可以保存计划发布时间,但 v1.2 还没有后台定时调度器,不会自动按 `scheduled_at` 发送。 -- 平台发送依赖 opencli 辅助浏览器操作,不绕过验证码、登录失效、风控和人工确认。 +- 以上 v1.2 说明仅是历史记录。v1.5.0 已由 `PublishScheduler` 执行到期任务,并通过 Windows Worker 调用抖音/B站 Publisher。 +- 平台发送不绕过验证码、登录失效、风控和人工确认;结果不确定写 `NEED_REVIEW`。 - 代码中仍存在兼容性 `clips` 子目录(`TASK_SUBDIRECTORIES` 同时包含 `clips` 和 `05_clips`),新任务的正式输出目录是 `05_clips`。旧 `clips` 目录为兼容保留,不建议删除。 # 2026-06-23:v1.4.0 定时发送字段 @@ -336,5 +417,11 @@ data/workflow.sqlite3 - 旧字段继续兼容:`output_clip_id` 等同于 `clip_id`,`description` 等同于 `caption`,`tags` 等同于 `hashtags`,`video_file_path` 等同于 `video_path`,`provider_response` 兼容 `publish_result`,`retry_count` 兼容 `attempt_count`。 - 发布状态使用:`DRAFT`、`SCHEDULED`、`WAITING`、`PUBLISHING`、`PUBLISHED`、`FAILED`、`CANCELLED`、`NEED_REVIEW`。 - 调度器只扫描 `status = SCHEDULED` 且 `scheduled_at <= 当前时间` 的任务;`NEED_REVIEW`、`CANCELLED`、`PUBLISHED` 不会自动发布。 -- 默认发布器为 `manual_export`,成功后写入 `published_at`、`publish_result`、`remote_video_id`;失败后写入 `FAILED`、`last_error`、`error_message`,并增加 `attempt_count`。 +- 该 2026-06-23 版本曾默认使用 `manual_export`,2026-07-11 曾改为 `opencli_publish`;v1.5.0 当前默认是 `local_browser`。发布包导出成功写 `EXPORTED` 且不写 `published_at`;只有平台确认提交成功才写 `PUBLISHED` 和 `published_at`。 - 没有 `scheduled_at` 的旧手动发送任务迁移为 `WAITING`,避免被自动调度器误执行。 + +## 2026-07-27:取消发送状态兼容 + +- 本次没有新增或删除数据库字段。普通“取消发送”把发布任务从 `DRAFT`、`WAITING` 或 `SCHEDULED` 恢复为 `WAITING`,并清空排期与执行占用字段,视频、文案和封面路径保持不变。 +- 旧版本中 `CANCELLED` 且错误信息为“用户取消任务”的最后一条记录,会在数据库初始化时安全恢复为 `WAITING`;同一切片和平台已有活跃任务时不恢复,避免重复。 +- 用户主动“移出内容准备”的 `user_removed_from_preparation` 记录,以及跳过、发布失败、发布完成和系统取消记录仍保持原状态,不参与兼容恢复。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7a50a66..f4dca5a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -71,6 +71,9 @@ copy .env.example .env 然后用记事本或 VS Code 打开 `.env`,按注释填写: - `STORAGE_ROOT`:任务产物存放目录(默认 `E:\直播间切片工作流存储`) +- `TASKS_DIR`:每条任务的原片、音频、切片和字幕目录,默认与 `STORAGE_ROOT` 相同 +- `UPLOAD_TEMP_DIR`:浏览器上传大视频时的临时目录,默认 `E:\直播间切片工作流存储\_临时上传` +- `PUBLISH_SCHEDULER_EXPORT_DIR`:手动发布包目录,默认 `E:\直播间切片工作流存储\_发布包` - `AI_ANALYSIS_REMOTE_API_KEY`:DeepSeek API Key(可选,用远程 AI 分析时需要) - `VOLCENGINE_ASR_API_KEY`:火山引擎转写 Key(可选,用远程转写时需要) - `LOCAL_ADMIN_TOKEN`:管理接口鉴权 Token(可留空或设随机字符串) @@ -261,6 +264,8 @@ Windows 主机(运行 FastAPI) | --- | --- | --- | | `STORAGE_ROOT` | `E:\直播间切片工作流存储` | 任务产物根目录 | | `TASKS_DIR` | 同 `STORAGE_ROOT` | 任务目录(优先级高于 STORAGE_ROOT) | +| `UPLOAD_TEMP_DIR` | `{TASKS_DIR}\_临时上传` | FastAPI 接收大视频时的进程临时目录,不回退到 C 盘 | +| `PUBLISH_SCHEDULER_EXPORT_DIR` | `{STORAGE_ROOT}\_发布包` | 手动导出的本地发布包目录 | | `DATA_DIR` | 项目目录 `data/` | 数据库存放目录 | | `DATABASE_PATH` | `data/workflow.sqlite3` | 数据库文件路径 | | `AI_ANALYSIS_REMOTE_API_KEY` | 空 | DeepSeek API Key | diff --git a/docs/KANGXI_V2_COMPARISON.md b/docs/KANGXI_V2_COMPARISON.md new file mode 100644 index 0000000..fa9713f --- /dev/null +++ b/docs/KANGXI_V2_COMPARISON.md @@ -0,0 +1,43 @@ +# 康熙笑点优先 V2 新旧结果对照 + +对照日期:2026-08-01 + +## 对照方式 + +- 使用现有两集任务的转写与音频,直接运行 V2 分析函数。 +- 使用当前配置的远程文字分析模型,候选池上限 12,最终启用目标 5。 +- 试跑只读取现有数据库、转写和音频;没有写回任务、覆盖旧候选、生成切片或调用抖音/B站发布。 +- 第一次试跑发现句子边界吸附会让部分 60 秒片段回缩到 55–59 秒,随后已加固边界逻辑,并用同一批返回范围做本地后处理复验。 + +## 总体结果 + +| 集数 | 旧版候选 / 启用 | 旧版 60–150 秒 | V2 候选 | V2 A / B | V2 自动启用 | 修复后 60–150 秒 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| E1810 他们在康熙的第一次 | 12 / 12 | 3 / 12 | 12 | 6 / 6 | 5 | 12 / 12 | +| E1811 A到G的身材困扰 | 12 / 12 | 5 / 12 | 4 | 3 / 1 | 3 | 4 / 4 | + +结论:自动启用上限从“12 条全部启用”收敛为最多 5 条;较弱的 E1811 没有补齐数量,只启用 3 条 A 级内容。边界修复后,本次两集进入候选列表的片段均可保持在 60–150 秒。 + +## E1810 自动启用的 5 条 A 级方向 + +1. 陈汉典被问“你有上过康熙吗?”:主持人故意遗忘,陈汉典无奈反应与蔡康永补刀构成闭环。 +2. 穿奥斯卡礼服去学校夜店:普通校园场景与夸张礼服形成直接反差,小 S 补刀。 +3. 汉典拒绝星光大道:爆料、措手不及、追问和纳豆补刀连续推进。 +4. 陈汉典被遗忘,小 S 问“你记得你是谁吗?”:突然发问、尴尬反应和主持人补刀完整。 +5. 乔琪姑娘外号红到国外:外号意外传播到国外,小 S 得意承认,蔡康永再补一刀。 + +第 6 条 A 级“陈汉典道歉却遭小 S 质问”总分达标,但因最终启用目标为 5 而保持关闭,证明候选池与自动启用数量已经分离。 + +## E1811 自动启用的 3 条 A 级方向 + +1. 小 S 夸张描述胸部触感,蔡康永用“水袋”补刀:描述、反差、全场反应和收尾完整。 +2. “内衣脱了不在那的是谁”:突然点名、陈汉典秒答、嘉宾反应和蔡康永补刀节奏紧凑。 +3. 嘉宾哭到一半被小 S 打断:悲伤铺垫被一句“你还没哭完”突然打破,现场反应后自然过渡。 + +“大胸买内衣要加钱,小胸想减钱”为 B 级,保留在审核列表但默认关闭。该集最终只有 3 条自动启用,符合弱集少选规则。 + +## 尚需人工完成的验收 + +- 由用户在片段审核页逐条点击“值得发”或具体拒绝原因;程序不能代替真人判断“每集至少 4 条值得发布”。 +- E1811 当前只有 3 条 A 级;如果人工认为 B 级片段也值得发布,可以手动启用并记录“值得发”,否则应接受本集少于 4 条,不为达到数量目标降低质量线。 +- 真实切片前再核对开头是否能独立理解、笑点后的反应是否保留、同一连续话题是否仍有重复。此次对照没有生成视频,不能替代最终画面审片。 diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 6a3659b..4e13cf2 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -20,6 +20,8 @@ -> 自动切割输出短视频 ``` +针对《康熙来了》类综艺,可以在新建任务或任务详情里选择“综艺笑点优先”。该模式先按重叠窗口找笑点,再补齐前后文,最后全局去重和评分;候选池默认 12 条,但只会默认启用最多 5 条 A 级内容,质量不足时不会凑数。现有直播和通用长视频继续使用“通用内容价值”模式。 + ## 2. 当前项目目录 ```text @@ -34,23 +36,13 @@ Docker 的好处是:不用每次手动激活 `.venv`,端口映射清楚, 第一次启动前,先确认 Docker Desktop 已经打开。 -然后打开 PowerShell,进入项目目录: - -```powershell -cd "C:\Users\10578\Documents\New project 2" -``` - -启动项目: +日常不需要打开 PowerShell,也不需要输入命令: -```powershell -docker compose up --build -``` +1. 打开 Docker Desktop。 +2. 在 Containers 中找到并运行 `niuma-studio`。 +3. 等待发送中心的“Windows Worker”显示“正常”。 -如果你要使用发送中心自动发送,推荐改用这一条。它会同时启动 Windows opencli 辅助服务和 Docker 主页面: - -```powershell -.\scripts\start_docker_opencli.ps1 -``` +项目已经安装 `NiuMa Studio Docker Watcher` 后台观察器。它只等待当前项目的 Docker 容器;容器运行后自动启动 Windows Chrome Worker,项目停止 15 秒后自动关闭 Worker。旧启动脚本继续保留给开发助手诊断,不作为日常操作。 看到服务启动后,在浏览器打开: @@ -209,20 +201,7 @@ http://127.0.0.1:8001 如果 AI 提示缺少 Key,先到系统状态页检查对应的三类接口:音频转写看火山引擎 Key,文字稿分析看 `AI_ANALYSIS_REMOTE_API_KEY`,发送中心文案看 `AI_PUBLISH_REMOTE_API_KEY`。 -如果发送中心提示“还没有连接到 Windows opencli 辅助服务”,先不要点“开始发送全部”。日常仍然只使用 Docker 主页面 `http://127.0.0.1:8001`,按下面顺序处理: - -```powershell -cd "C:\Users\10578\Documents\New project 2" -.\scripts\start_docker_opencli.ps1 -``` - -脚本会自动检查 Windows opencli、启动 opencli 辅助服务、刷新 Docker,并打开 `http://127.0.0.1:8001/publish`。页面打开后按 `Ctrl + F5` 强制刷新。如果脚本提示“没有检测到 opencli”,再执行: - -```powershell -where opencli -``` - -如果 `where opencli` 没有显示路径,说明 opencli 还没装好或没有加入 Windows PATH;如果能显示路径但页面仍报错,把页面红色提示和 `where opencli` 输出发给开发助手继续排查。 +如果发送中心提示“Windows Worker 未连接”,先不要点击“立即发送”。刚运行 Docker 项目时先等待十几秒,再点击“重新检测”。如果持续未连接,在 Docker Desktop 中停止 `niuma-studio`,等待 15 秒后重新运行;不需要输入命令。仍未恢复时,把发送中心提示交给开发助手检查 `data/logs/docker_publish_worker_watcher.log` 和 `publish_worker_8765.err.log`。 如果转写速度很慢,可能是没有使用 NVIDIA 显卡。可以在 `.env` 中把转写配置改成 CPU 模式,但速度会慢一些。 diff --git a/docs/TASK_FLOW.md b/docs/TASK_FLOW.md index b4b43b8..858020e 100644 --- a/docs/TASK_FLOW.md +++ b/docs/TASK_FLOW.md @@ -178,37 +178,52 @@ output_clip 生成成功 发送中心是切片生成后的独立 `publish_jobs` 流程,不直接混入 `tasks.status`: ```text -output_clip 生成成功 + 字幕完成 -→ /publish 发送中心 -→ 刷新发送队列(从已完成切片生成抖音 + B站双平台 opencli 任务) -→ publish_jobs.status = ready -→ 用户确认标题、话题、简介、封面帧 -→ 点击"发送此条" -→ opencli 辅助浏览器打开平台投稿页 -→ 自动填写标题、简介、上传视频、选择封面 -→ 点击发布,等待平台成功信号 -→ publish_jobs.status = publishing → published / failed +output_clip 生成成功 +→ 全自动流程按 metadata.platform 创建 publish_jobs +→ DRAFT / WAITING +→ 用户在“内容准备”复核内容、平台和账号 +→ 排期抽屉先调用 POST /api/publish/schedules/preview +→ 确认后将精确时间列表提交 PATCH /api/publish/jobs/schedule-batch +→ SCHEDULED(scheduled_at 保存 UTC +00:00,timezone 保存 Asia/Shanghai) +→ 调度器到点使用 BEGIN IMMEDIATE 原子领取为 PUBLISHING +→ Registry 按 platform + publish_mode 分发 + ├─ local_browser → Windows Worker → DouyinPublisher / BilibiliPublisher + │ ├─ 平台确认成功 → PUBLISHED + │ ├─ 明确失败 → FAILED + │ └─ 登录/验证/风控/结果不确定 → NEED_REVIEW + ├─ manual_export → 本地发布包 → EXPORTED / FAILED + └─ opencli_publish → 显式兼容开关 → PUBLISHED / FAILED / NEED_REVIEW ``` +`platform` 只能是 `douyin` / `bilibili`;`publish_mode` 只能表示执行方式,禁止互相混用。发送中心的“补充缺失任务”只补缺,不覆盖已有任务的执行方式。 + ### 发送任务状态(publish_jobs.status) | 状态 | 说明 | | --- | --- | -| `NEED_REVIEW` | 全自动文案含风险标记,需要人工复核后再发送 | -| `ready` | 待发送,已整理好标题、封面和视频 | -| `publishing` | 发送中,opencli 正在操作浏览器 | -| `published` | 已发布,平台返回成功信号 | -| `failed` | 发送失败,error_message 记录具体原因 | -| `cancelled` | 已取消,用户主动取消 | - -### scheduled_at 字段说明 - -- `publish_jobs.scheduled_at` 当前可以由全自动流水线写入计划发布时间。 -- v1.3.0 还没有后台定时调度器,不会自动按 `scheduled_at` 发送。 -- 所有发送都需要用户手动在发送中心点击"发送此条"或"开始发送全部"触发。 +| `DRAFT` | 草稿,尚未进入排期或执行 | +| `WAITING` | 内容已生成,等待排期或立即发送 | +| `SCHEDULED` | 已保存 UTC 计划时间,等待调度器扫描 | +| `NEED_REVIEW` | 登录、验证码、风控或平台结果不确定,需要人工核对 | +| `PUBLISHING` | 已被一个调度器原子领取,正在执行 | +| `PUBLISHED` | 平台 Publisher 已取得作品 ID、稿件 ID、作品链接或明确成功证据 | +| `EXPORTED` | 本地发布包已导出,不代表平台已发布 | +| `FAILED` | 明确失败;手动重试会创建带 `retry_of_job_id` 的新任务并保留旧记录 | +| `CANCELLED` | 用户取消,或迁移时取消了较旧的未发布重复任务 | + +### 排期与立即发送 + +- 浏览器提交北京时间 `start_at_local`;后端按 `Asia/Shanghai` 应用每日开始/结束窗口,跨日后顺延到次日开始时间。 +- `scheduled_at` 统一存 UTC ISO 8601,API 同时返回 `scheduled_at_utc` 与 `scheduled_at_local`。 +- 自动调度只读取到期的 `SCHEDULED`;`NEED_REVIEW` 即使有时间也不能执行。 +- “立即发送”允许 `DRAFT`、`WAITING`、`SCHEDULED`,只把 `scheduled_at` 更新为当前 UTC 并唤醒 Scheduler;不直接调用 opencli 或平台页面。 +- 领取任务使用 `BEGIN IMMEDIATE` 与条件更新;只有 `SCHEDULED → PUBLISHING` 更新成功的 Worker 能执行。 +- Worker 未接收任务前的连接故障最多安全重试 3 次;上传开始、点击提交或执行阶段未知时禁止自动重试。 +- 启动恢复会查询 Worker 执行日志;确认未上传才重新排队,旧版未知 `PUBLISHING` 直接进入 `NEED_REVIEW`。 ### 安全边界 -- 平台发送依赖 opencli 辅助浏览器操作。 +- 默认 `local_browser` 依赖 Windows Worker 和专属 Chrome 登录目录;健康状态见 `GET /api/publish/scheduler/health`。 - 不绕过验证码、登录失效、风控和人工确认。 -- 遇到平台验证提示时,任务会标记为 `failed` 并记录具体原因,等待人工处理。 +- 遇到平台验证提示或发布结果不确定时进入 `NEED_REVIEW`,不会标记为已发布,也不会自动重传。 +- 人工标记 `PUBLISHED` 必须从 `NEED_REVIEW` 操作并填写对应平台作品链接。 diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index 70c7a61..cd2caf8 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,107 @@ # UI 参考说明 +## 2026-08-02 更新:续接当前平台最晚排期 + +- 排期抽屉在“第 1 条发布时间”下方新增次级按钮“接在当前平台最晚排期后”,保持手动日期时间输入框可编辑。 +- 点击按钮后原地展示“当前最晚:日期时间;本次第 1 条:日期时间”,并把结果回填到日期时间输入框;无未来排期或请求失败时在同一区域给出提示。 +- 续排只读取当前平台,并排除本次已选任务;抖音和 B站不共用时间线。参数变化后继续清空旧预览并禁用确认按钮。 +- 每日窗口默认显示 `07:00 → 00:00`,两个时间控件仍可修改;跨午夜时段的视觉说明明确 00:00 代表次日午夜。 +- 按钮、提示和输入框继续使用现有浅色抽屉、蓝色强调和紧凑表单样式,不引入新的前端框架。 + +## 2026-08-01 更新:综艺笑点优先选片与反馈 + +- 新建任务页把“候选池上限”和“最终启用目标”拆成两个控件,并新增“通用内容价值 / 综艺笑点优先”模式选择;弱集少选的规则在控件下直接说明。 +- 任务详情 AI 区同步提供选片模式与最终启用目标。用户保存 Prompt 或启动 AI 分析时会同时保存这两个设置,现有 Prompt 文本不因切换模式被覆盖。 +- 片段审核卡片在有 V2 数据时展示 A/B 等级与质量总分,并以三列小卡显示笑点闭环、内容完整和音频反应分;下方展示“为什么值得剪”、现场反应证据和未自动启用原因。 +- 每条候选底部提供“值得发、不好笑、太碎、铺垫不足、重复、拖沓”反馈按钮。点击后当前按钮高亮,并同步启用/关闭当前候选;反馈单独保存在数据库中。 +- “仅高传播价值”筛选调整为“仅 A 级 / 高传播价值”;默认排序优先使用 V2 质量分,旧通用候选继续回退使用置信度。 +- 页面继续沿用 Apple 风格浅色卡片、蓝色主强调色和原有双栏审核布局;小屏下评分区域随既有响应式布局收缩,不增加新的前端框架。 + +## 2026-07-29 更新:任务与发送中心切片关联 + +- 任务列表“后续工作流”增加发送中心关联状态,显示“已关联 N/N”“待同步”或“存在旧版记录”;“字幕”链接使用 `/subtitles/{task_id}`,“发送中心”链接携带当前任务。 +- 任务详情、片段审核和字幕工作台都提供任务级“同步发送中心”按钮以及“查看本任务发送内容”深链。片段审核的“去字幕推送”固定进入当前任务的字幕工作台。 +- 片段审核在生成切片后显示真实同步结果;同步异常不会掩盖切片成功,并保留可重试入口。 +- 字幕工作台顶部显示本任务的抖音/B站关联数量,每条切片分别显示两个平台的“已关联 / 已移出 / 待同步”状态;从此页同步时优先使用已经完成的带字幕成片。 +- `/publish` 支持 `task_id`、`platform` 和 `tab` 查询参数。从任务页进入会自动切到指定平台和区域,展开对应任务组并滚动定位。 +- 内容准备与排期计划只显示当前激活切片;旧切片的取消、失败、待复核、已发布和已导出证据只在执行记录中查看。 + +## 2026-07-29 更新:调度异常重试状态 + +- `/publish` 顶部健康卡保留 Windows Worker 状态,并新增调度器运行状态;正常时显示“正常”,扫描失败且后台仍在重试时显示“异常重试中”,停止时显示“已停止”。 +- 异常卡片只展示安全化后的原因,不直接显示数据库路径、请求凭据或内部堆栈;下一轮扫描成功后自动恢复绿色状态。 +- 页面仍每 5 秒刷新任务、账号和健康状态;执行记录的月历/列表请求改为单飞并合并后续刷新,慢请求不会再被轮询持续作废。 +- 本次只调整健康反馈和刷新稳定性,不改变内容准备、排期计划、执行记录的 Apple 风格布局,也不新增绕过平台验证的操作。 + +## 2026-07-28 更新:执行记录月历与安全清理 + +- `/publish` 的“排期计划”继续显示未来排期月历;“执行记录”新增独立执行月历,按北京时间汇总待执行、进行中、成功、失败、待复核、已导出和已取消数量。 +- 点击执行月历中的日期会筛选下方分页列表;状态筛选与日期、当前平台组合生效,并提供“查看全部日期”。 +- 失败终态使用主按钮“立即发送”,保留原失败记录并创建新任务;普通失败和成功记录不显示“重新加入内容准备”。 +- 执行列表使用独立复选框和批量栏。终态记录的“删除记录”是安全隐藏;“已删除记录”视图可以批量恢复,不删除素材或平台作品。 +- 桌面列表将开始与结束时间合并为“实际时间”,操作按钮允许换行;窄屏改为两列卡片,避免上一版按钮被挤成竖排。 + +## 2026-07-23 更新:Docker 自动联动发布 Worker + +- 调度器健康卡继续展示 Worker 状态和“重新检测”,但离线提示不再要求用户运行 PowerShell 命令。 +- Docker 项目刚运行时显示发送服务正在自动启动;持续离线时引导用户在 Docker Desktop 中停止后重新运行当前项目。 +- Worker 正常后黄色帮助区域自动隐藏,账号登录、单条发送、排期和执行记录布局不变。 +- 本次只调整连接反馈文案,不改变现有 Apple 风格、任务分组或三页签结构。 + +## 2026-07-22 更新:内容准备按任务归类与安全移出 + +- `/publish` 的“内容准备”保留现有编辑卡片,但外层改为按原始处理任务分组;组头固定展示任务名称、原视频文件名、创建时间、当前平台待准备数量和“查看任务详情”。 +- 任务组按创建时间倒序,最新可见任务默认展开,其余折叠;抖音 / B站切换后重新计算组内可见数量,当前平台没有内容的分组隐藏。 +- 卡片标题区使用“裁剪片段 + 输出文件名 / 内容标题”,不再只靠弱化的小字任务名辨认来源;标题、简介、话题、封面、账号和排期控件布局不变。 +- 卡片右上角新增红色文字操作“移出内容准备”。确认提示必须写明:当前平台排期会取消,但原视频、裁剪成片、字幕和另一个平台内容不会删除。 +- 用户主动移出的记录进入执行记录,并显示“重新加入内容准备”;恢复后只回到等待处理,不自动恢复旧排期或发送。 +- 分组使用现有浅色玻璃卡片、圆角和蓝色强调体系;窄屏下组头和操作按钮改为上下排列,不引入 React / Vue。 + +## 2026-07-19 更新:移除旧批量立即发送入口 + +- 底部“已选 N 条”批量栏不再展示“立即发送”;只保留当前平台账号设置、批量 AI 补齐、设置排期和取消选择。 +- 真实投稿继续从“排期计划”的单条任务操作发起,按钮根据就绪状态显示“立即发送 / 转换并发送 / 立即导出”;该入口继续使用统一 Scheduler 与 Windows Worker。 +- 删除上一版发送卡片、平台配置、批量发送队列和投稿预览的无模板引用脚本;当前内容准备、排期抽屉、月历、执行记录和账号抽屉布局不变。 + +## 2026-07-18 更新:发送中心全局平台上下文与账号自动同步 + +- `/publish` 在健康卡下方固定显示统一“抖音 / B站”平台切换卡;当前平台同时约束内容准备、排期月历与清单、执行记录、账号抽屉、新增账号、补充任务和底部批量栏。 +- 切换平台必须清空所有已勾选任务和排期预览;确认框明确写出平台名称。页面不提供把已有任务改成另一平台的控件,平台改为只读标签。 +- 账号抽屉只显示当前平台账号。登录过程状态依次为“等待登录完成 → 正常”或“需要重新登录”;页面每 5 秒自动读取数据库更新,不要求用户刷新。正常账号显示“打开创作者中心”和“重新登录”。 +- 底部批量栏用“仅限抖音 / 仅限 B站”状态标签替代可编辑平台下拉框,账号选项也只保留当前平台;批量栏不提供直接发送,旧排期和真实投稿都必须使用任务右侧的单条操作。 +- 旧 `opencli_publish` 排期进入执行记录的“需人工复核”。逐条转换会保留原记录、创建新的 Windows Chrome 任务;重复操作只返回已有替代任务。 +- 视觉继续沿用 Apple 风格浅色卡片、蓝色强调色和响应式布局;全局平台卡在窄屏下改为单列,不引入 React / Vue。 + +## 2026-07-16 更新:发送就绪状态与旧任务恢复 + +- 排期卡片在操作区固定展示发送就绪说明;按钮文案由 `send_readiness` 决定,不满足条件时不显示“立即发送”。 +- 阻塞按钮使用明确动作:“打开登录窗口”“新增账号”“选择账号”“完善内容”“连接 Worker”;页面从 Worker 健康卡同步在线状态,离线时优先引导启动和重新检测。 +- 旧 `opencli_publish` 待发送任务显示为“旧任务待转换”;账号、登录、内容和 Worker 齐全后才允许“转换并发送”。当前实现会保留旧任务并新建 `local_browser` 替代任务,不再覆盖旧记录。 +- 明确发生在上传前的旧 `NEED_REVIEW` 记录在执行历史中显示“修复并发送”。操作会保留原历史、创建替代任务;多个同平台账号时先在原地选择,结果不确定的任务不展示该操作。 +- API 返回的结构化阻塞原因会立即更新对应卡片,不需要整页刷新;账号创建、登录或检查状态后会重新读取任务就绪状态。 +- `manual_export` 的按钮文案为“立即导出”,继续与真实平台“立即发送”区分;导出不依赖账号和 Windows Worker。 + +## 2026-07-15 更新:v1.5.0 发送中心 + +- `/publish` 固定为“内容准备 / 排期计划 / 执行记录”三个页签,不再把文案、排期、历史错误和全部操作塞进一张长卡片。 +- 内容准备使用紧凑卡片编辑缩略图、标题、简介、话题、封面、平台、账号及 B站分区/原创转载;支持单条保存、AI 补齐、批量平台账号和加入计划。 +- 排期计划使用表格展示计划时间、视频、平台、账号、状态和操作;批量排期必须先预览每条北京时间,再确认保存精确列表。 +- 执行记录展示计划/开始/结束时间、状态、失败原因、平台链接和发布方式,并支持筛选、错误查看、失败重试和人工复核。 +- 账号管理改为内容准备页的抽屉:新增本地账号、打开专属 Chrome 登录、检查登录态、重新登录和打开创作者中心。 +- 页面明确区分 `PUBLISHED`(平台确认成功)、`EXPORTED`(只导出本地包)与 `NEED_REVIEW`(可能已触碰平台,必须人工核对)。 +- 页面继续使用 Jinja2、原生 JavaScript 和现有 Apple 风格 CSS,不引入 Vue 或前端构建工具。 + +## 2026-07-11 更新:发送中心三页签与排期抽屉 +- `/publish` 改为“待安排 / 已排期 / 发送记录”三页签,不再默认展开每条任务的大卡片。 +- 待安排使用紧凑列表:单一复选框、视频缩略图、任务/片段标题、目标平台、状态、计划时间和“展开编辑”。标题、话题、正文和高级设置默认折叠。 +- 选中任务后,页面底部固定显示“已选 N 条”的统一操作栏,提供设置排期、立即发送、批量编辑和取消选择;复选框不再同时承担两套语义。 +- 排期使用右侧抽屉,展示起始时间、1/2/3/6 小时或自定义间隔、每日窗口、浏览器 IANA 时区和预览列表;确认按钮在成功预览前保持禁用。 +- 已排期按 UTC 计划时间排序,但页面按任务时区显示本地时间;可直接立即发送或取消排期。 +- 发送记录只展示执行历史,并用 `PUBLISHED` 与 `EXPORTED` 明确区分平台提交和本地发布包导出。 +- 顶部健康卡展示调度器是否运行、扫描间隔、已排期/发送中数量和 opencli 可用性。 +- 排期、取消排期和编辑成功后局部更新任务行,保留滚动位置和当前选择,不使用整页刷新。 +- 视觉继续使用 Apple 风格浅色玻璃卡片、蓝色强调色和响应式布局,不引入 React / Vue。 + ## 2026-06-25 更新:全自动入口精简与发送中心批量排期 - 新建任务页的全自动模式只保留“新建后自动跑完整流水线”开关;自动切片数量直接使用上方候选片段数量,时长上限直接使用单条切片最长。 - 新建页不再展示自动切片数量、片段时长范围、发布计划、间隔小时、起始时间和固定时段,主按钮改为“创建任务 / 创建并自动处理”。 @@ -74,9 +176,9 @@ - 浏览器标签页和收藏夹使用同一套牛马图标资源:`niuma-studio-favicon.ico`、`niuma-studio-favicon-32.png` 和 `niuma-studio-apple-touch-icon.png`。 - E 盘 `E:\直播间切片工作流存储` 仍作为历史存储目录沿用,本轮不改路径,避免影响已有任务和视频。 -## 2026-05-27 更新:任务列表移入回收站 -- 任务列表里的原“隐藏”操作改为“移入回收站”,按钮仍放在每条任务右侧操作区,属于危险操作样式。 -- 点击后会二次确认,说明该任务会从页面列表隐藏,并且项目文件夹会移动到 `E:\直播间切片工作流存储\_回收站`,不会删除原视频、切片、字幕或日志文件。 +## 2026-08-02 更新:任务列表永久删除 +- 任务列表右侧危险操作为“永久删除”,不再提供 E 盘回收站;点击后明确提示原片副本、音频、转写、切片、字幕、封面和发布包都将永久删除且无法恢复。 +- 确认框同时说明 NAS 或任务目录外的原始视频不会被删除;运行中的处理或真实发布任务由后台拒绝删除并展示具体原因。 - 新建任务后的本地文件夹名与项目名保持一致;任务详情页仍展示任务目录,方便用户直接对照 E 盘项目文件夹查找完成视频。 ## 2026-05-27 更新:AI 片段完整性与检查入口 @@ -179,7 +281,9 @@ docs/design/live_streaming_slicing_workflow_ui_16x9.png 包含搜索、平台筛选、状态筛选、排序按钮、任务表格、状态标签和分页样式。 -当前任务列表已增加“移入回收站”操作。该操作只从页面列表移除任务,不删除 E 盘任务目录和视频文件。任务列表会展示输出切片数量和“后续工作流”,用于提示切片完成后是否进入“待加字幕 / 待推送”;如果任务失败,会在状态标签下方显示一行失败原因,方便先判断是否需要重试远程或确认本地模型。 +当前任务列表提供“永久删除”操作:系统永久删除 E 盘托管任务媒体并隐藏数据库历史,外部原片保留。任务列表会展示输出切片数量和“后续工作流”,用于提示切片完成后是否进入“待加字幕 / 待推送”;如果任务失败,会在状态标签下方显示一行失败原因,方便先判断是否需要重试远程或确认本地模型。 + +系统状态页新增“视频临时与导出目录”卡片,直接展示上传临时目录和手动发布包目录是否已在 E 盘就绪。 ### 3. 新建任务 @@ -215,15 +319,17 @@ v1.3.0 起,新建任务页可勾选“新建后自动跑完整流水线”。 ### 7. 发送中心 -包含待发送队列、平台筛选、批量发送、失败重试、发送记录、候选封面帧和右侧平台投稿预览。 +发送中心由“内容准备 / 排期计划 / 执行记录”三个区域组成,并带账号管理和排期抽屉。 + +内容准备从已完成切片读取数据,默认使用 `05_clips/` 原片、AI 标题/摘要/话题和候选封面。每条记录可独立选择抖音或 B站账号;B站额外展示分区、原创/转载和转载来源。 -当前发送中心不再展示开放平台 API 配置、Client Key、Access Token、OAuth 和账号表单。账号登录全部依赖 Chrome 已登录状态,投稿动作由 opencli 网页自动化执行;页面只负责检查队列、微调标题 / 话题 / 封面并确认发送。 +排期计划所有时间都标注“北京时间”。顶部使用“抖音排期 / B站排期”双卡片分类切换,卡片分别显示待排期与已排期数量;下方月历采用周一到周日的 42 格月视图,只展示当前平台的已排期任务,支持前后翻月、回到本月和点击任务定位清单。未排期任务继续保留在当前平台清单中。批量操作只负责账号、AI 文案和排期;抽屉默认使用 `07:00 → 00:00` 每日窗口,可一键把第 1 条接在当前平台最晚排期后,随后仍须展示逐条预览再确认保存。真实发送必须从单条任务操作发起,并先进入 `SCHEDULED`,不在页面点击事件中直接操作平台。 -待发送队列从已完成切片读取数据:默认使用切好的原片、切片标题、摘要 / 推荐理由 / 转写片段生成的 AI 话题和简介,并为抖音 + B站生成双平台任务。卡片左侧展示视频与封面预览,右侧展示标题、话题、简介、可见范围、B站分区和原创声明等高频字段。 +执行记录顶部使用独立的 42 格执行月历,按计划日期优先归档任务,并汇总每天的待执行、进行中、成功、失败、待复核、已导出和已取消数量。点击日期后,下方分页列表与状态筛选共同生效;“已删除记录”作为单独视图,不参与正常月历统计。 -封面改为“从视频中选一帧”:点击“选封面帧”会生成多张 16:9 候选帧,用户可手动切换并保存,不再默认叠加标题大字封面。右侧手机预览用于快速确认投稿首屏效果,但不替代平台最终审核。 +执行列表展示实时发布阶段和复核入口。发布中会依次显示上传解析、填写正文与话题、设置推荐封面、验证可见范围和等待平台结果;明确失败的记录可重新选择可见范围并“立即发送”,原失败记录保持不变。`NEED_REVIEW` 必须先打开平台创作者中心核对,再填写作品链接标记成功,或确认未发布后标记失败。终态记录支持单条或批量安全删除,数据库历史、素材和平台作品均保留并可恢复。 -发送执行由 opencli 串行处理:抖音打开 `creator.douyin.com` 投稿流程,B站打开创作中心投稿页并填写封面、标题、分区、标签和简介。每个批次一次只跑一条任务;如果遇到验证码、登录失效或平台风控弹窗,任务进入失败 / 待人工处理,不绕过平台限制。 +账号抽屉不收集平台密码。点击登录后由 Windows Worker 打开该账号专属 Chrome 目录;验证码、二维码、短信和平台风控全部保留人工处理边界。调度器健康卡会每 5 秒刷新 Worker 状态,断开时说明发送服务会随 Docker 项目自动启动,并保留“重新检测”按钮,不再要求用户输入启动命令。抖音失败或需要验证时,Chrome 默认保留 10 分钟并显示牛马片场暂停原因。 ### 8. 系统状态 @@ -313,3 +419,26 @@ v1.3.0 起,新建任务页可勾选“新建后自动跑完整流水线”。 - 发布记录现在展示全部发布任务,包括 `manual_export` 导出的本地发布包任务,不再只显示 opencli 任务。 - 统计卡片新增“需复核”,用于提示带 `risk_flags` 或 `NEED_REVIEW` 的任务不会自动发布。 - 本轮 UI 只调整发送中心状态展示和按钮可用条件,整体仍保持 Apple 风格、浅色卡片、蓝色主强调色和人工确认边界。 + +## 2026-07-27 更新:发送中心一键补充封面 + +- “内容准备”标题区右侧新增“一键补充所有封面(数量)”按钮,数量统计抖音和 B站全部尚未发布、且封面为空的任务,不受当前平台切换影响。 +- 点击后按钮进入“正在补充”状态;后台按切片分组生成封面,同一切片的双平台任务复用一张 JPG,已有封面不会被覆盖。 +- 完成后卡片会原地显示封面预览并更新“内容完整 / 缺少字段”标签,不刷新页面,也不会覆盖用户正在编辑但尚未保存的标题、简介和话题。 +- 旧任务使用短视频中点作为封面;新任务优先使用 AI 分析提供的切片内秒数。“生成 / 更换封面帧”继续作为单条人工调整入口。 +- 移动端按钮占满内容标题区宽度,桌面端保持右对齐,继续沿用浅色 Apple 风格和蓝色强调色。 + +## 2026-07-27 更新:跨午夜排期与新任务默认值 + +- 排期抽屉将“起始时间”明确为“第 1 条发布时间(北京时间)”,该时间是第一条任务的最终发布时间,不会被每日时段自动改写。 +- “每日开始 / 每日结束”归入“后续每日可发布时间段”,支持 `06:00 → 00:00` 这类跨午夜窗口;开始和结束相同表示全天。 +- 预览请求期间按钮显示“正在生成预览”,成功、参数错误和发送准备问题都在抽屉内反馈,不再依赖被遮挡的页面顶部提示。 +- 修改排期参数会清空旧预览并禁用确认按钮;预览成功后继续逐条展示北京时间,确认保存时不刷新页面。 +- 新建任务页默认显示单条切片最长 10 分钟、候选片段 12 条,并说明 10 分钟是允许上限,AI 可以根据内容选择更短片段。 + +## 2026-07-27 更新:取消发送返回内容准备 + +- “排期计划”任务行的普通取消入口命名为“取消发送并返回准备”,明确它只取消本次发送安排,不删除视频或准备内容。 +- 确认提示会说明视频、标题、简介、话题和封面均被保留;操作期间按钮禁用,避免重复请求。 +- 操作成功后页面自动切换到“内容准备”,展开任务所在分组并定位到返回的卡片;排期时间清空为“未排期”,用户可直接修改内容或重新设置排期。 +- “取消发送并返回准备”“移出内容准备”“跳过任务”保持三种独立语义:前者回到准备区,移出操作隐藏任务,跳过操作终止任务。 diff --git a/prompts/clip_analysis_prompt.txt b/prompts/clip_analysis_prompt.txt index fb96dfd..a296a63 100644 --- a/prompts/clip_analysis_prompt.txt +++ b/prompts/clip_analysis_prompt.txt @@ -36,6 +36,7 @@ "start_time": "00:12:10", "end_time": "00:14:40", "duration_seconds": 150, + "cover_time_seconds": 15, "summary": "主播围绕直播完播率解释了三个影响因素:开头问题是否足够明确、中段信息是否持续递进、结尾是否给出可执行方法。这一段适合剪成经验分享型短视频,主题可以聚焦在“为什么观众进来又马上走”。", "highlight_reason": "这一段既有具体问题,也有方法拆解,观众能快速对照自己的直播间;内容有实操价值,适合引发从业者讨论。", "spread_value": "高", diff --git a/prompts/default_ai_prompt_preset_001.txt b/prompts/default_ai_prompt_preset_001.txt index b56fdff..923f923 100644 --- a/prompts/default_ai_prompt_preset_001.txt +++ b/prompts/default_ai_prompt_preset_001.txt @@ -192,6 +192,7 @@ selected_by_default 规则: "start_time": "00:12:10", "end_time": "00:14:40", "duration_seconds": 150, + "cover_time_seconds": 15, "summary": "概括这个片段讲了什么,控制在80字以内。", "highlight_reason": "说明为什么这个片段值得剪,控制在100字以内。", "spread_value": "高", diff --git a/prompts/variety_comedy_v2_prompt.txt b/prompts/variety_comedy_v2_prompt.txt new file mode 100644 index 0000000..6c63c5e --- /dev/null +++ b/prompts/variety_comedy_v2_prompt.txt @@ -0,0 +1,13 @@ +你是《康熙来了》类棚内综艺的短视频总编。你的第一目标是真正好笑,其次才是八卦、争议和标题传播力。 + +只推荐具备完整闭环的内容: +1. 观众能迅速理解人物和情境; +2. 中段出现明确笑点、反转、尴尬或意外回答; +3. 笑点后有小S、蔡康永、嘉宾或现场的追问、补刀、解释、笑声或情绪反应; +4. 结尾自然收住,不断在半句话、半个反应或纯铺垫上。 + +不要因为话题敏感、标题夸张或涉及明星八卦就判定为好片段。只有平铺直叙、没有互动变化、需要大量原片背景、重复同一笑点、纯身体话题但没有包袱、仅有一句金句的内容都应降级或淘汰。 + +默认成片以 60–150 秒为主。特别短的内容只有在笑点完全闭环时才可保留;不要为凑数量输出普通段落。 + +用户补充偏好:{{AI_PREFERENCE}} diff --git a/prompts/variety_interview_prompt_preset_002.txt b/prompts/variety_interview_prompt_preset_002.txt index 065ab44..186d983 100644 --- a/prompts/variety_interview_prompt_preset_002.txt +++ b/prompts/variety_interview_prompt_preset_002.txt @@ -53,6 +53,7 @@ suggested_editing 必须给出可执行剪辑建议,例如开头保留哪类 "start_time": "00:12:10", "end_time": "00:16:40", "duration_seconds": 270, + "cover_time_seconds": 35, "summary": "这段先交代了什么情境,再出现什么核心笑点或观点,最后如何自然收住。", "highlight_reason": "说明这段为什么值得剪,重点写笑点、冲突、反差、讨论价值或人物关系张力。", "spread_value": "高", diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..57eaa4f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +playwright diff --git a/requirements.txt b/requirements.txt index ebb24ec..1c1adfc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,12 @@ python-multipart>=0.0.28 # 数据校验 pydantic>=2.13.0 +# Windows 的 zoneinfo IANA 时区数据库(排期需要 Asia/Shanghai 等名称) +tzdata + +# Windows 发布 Worker 使用系统 Chrome;单元测试会 Mock,不会打开真实浏览器。 +playwright>=1.58.0 + # 异步文件操作 aiofiles>=25.1.0 diff --git a/scripts/cleanup_database_backups.py b/scripts/cleanup_database_backups.py new file mode 100644 index 0000000..2be1045 --- /dev/null +++ b/scripts/cleanup_database_backups.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.config import settings # noqa: E402 +from app.services.database_backup_service import ( # noqa: E402 + BackupSafetyError, + apply_cleanup_plan, + build_cleanup_plan, +) + + +def _format_size(byte_count: int) -> str: + return f"{byte_count / 1024**3:.3f} GiB" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="安全清理牛马片场重复或损坏的 SQLite 迁移备份。", + ) + parser.add_argument( + "--apply", + action="store_true", + help="实际执行删除;不提供此参数时只做预演。", + ) + parser.add_argument( + "--keep-days", + type=int, + default=14, + help="保留最近多少个备份日,每天只保留最后一份有效备份(默认 14)。", + ) + args = parser.parse_args() + + database_path = settings.database_path.resolve() + backup_dir = (settings.data_dir / "backups").resolve() + try: + plan = build_cleanup_plan( + database_path, + backup_dir, + keep_days=args.keep_days, + ) + except (BackupSafetyError, ValueError) as exc: + print(f"安全检查未通过:{exc}", file=sys.stderr) + return 2 + + print(f"主数据库:{database_path}") + print(f"备份目录:{backup_dir}") + print(f"保留有效备份:{len(plan.keep_files):,} 份") + print(f"待删除 SQLite 备份:{len(plan.delete_files):,} 份") + print(f"其中损坏备份:{len(plan.invalid_files):,} 份") + print(f"待删除 journal:{len(plan.journal_files):,} 份") + print(f"预计释放:{_format_size(plan.release_bytes)}") + + if not args.apply: + print("当前是预演,没有删除任何文件。确认后添加 --apply 执行。") + return 0 + + try: + result = apply_cleanup_plan(plan) + except BackupSafetyError as exc: + print(f"删除前安全检查未通过:{exc}", file=sys.stderr) + return 2 + + print(f"清理完成:共删除 {result.deleted_files:,} 个文件。") + print(f"实际释放:{_format_size(result.released_bytes)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install_docker_publish_worker_watcher.ps1 b/scripts/install_docker_publish_worker_watcher.ps1 new file mode 100644 index 0000000..1af4a5b --- /dev/null +++ b/scripts/install_docker_publish_worker_watcher.ps1 @@ -0,0 +1,125 @@ +param( + [int]$Port = 8765, + [int]$PollSeconds = 3, + [int]$StopGraceSeconds = 15, + [string]$TaskName = 'NiuMa Studio Docker Watcher' +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path.TrimEnd('\') +$WatcherScript = Join-Path $ProjectRoot 'scripts\watch_docker_publish_worker.ps1' +$LegacyTaskName = 'NiuMa Studio OpenCLI Host Bridge' + +if (-not (Test-Path -LiteralPath $WatcherScript)) { + throw "Docker watcher was not found: $WatcherScript" +} + +function Test-TaskBelongsToProject { + param($Task, [string]$ExpectedScript) + + foreach ($action in @($Task.Actions)) { + $arguments = [string]$action.Arguments + $workingDirectory = [string]$action.WorkingDirectory + if ( + $arguments.IndexOf($ExpectedScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and + $workingDirectory.TrimEnd('\') -ieq $ProjectRoot + ) { + return $true + } + } + return $false +} + +$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +if ($existingTask -and -not (Test-TaskBelongsToProject -Task $existingTask -ExpectedScript $WatcherScript)) { + throw "A scheduled task named '$TaskName' already exists but does not belong to this project." +} +if ($existingTask) { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +} + +$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$arguments = ( + '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -Port {1} -PollSeconds {2} -StopGraceSeconds {3}' -f + $WatcherScript, $Port, $PollSeconds, $StopGraceSeconds +) +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments -WorkingDirectory $ProjectRoot +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user +$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Hours 0) ` + -MultipleInstances IgnoreNew ` + -RestartCount 999 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -StartWhenAvailable ` + -Hidden + +Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Description 'Waits for the NiuMa Studio Docker container and runs the Windows publish worker only while the project is running.' ` + -Force | Out-Null + +Start-ScheduledTask -TaskName $TaskName +$taskRunning = $false +foreach ($attempt in 1..20) { + Start-Sleep -Milliseconds 500 + $task = Get-ScheduledTask -TaskName $TaskName + if ($task.State -eq 'Running') { + $taskRunning = $true + break + } +} +if (-not $taskRunning) { + throw "The Docker watcher task was created but did not enter the Running state." +} + +$dockerCommand = Get-Command docker -ErrorAction SilentlyContinue +$targetRunning = $false +if ($dockerCommand) { + try { + $inspect = @(& $dockerCommand.Source inspect niuma-studio --format '{{.State.Running}}' 2>$null) + $targetRunning = $LASTEXITCODE -eq 0 -and (($inspect -join '').Trim() -eq 'true') + } catch { + $targetRunning = $false + } +} + +if ($targetRunning) { + $connected = $false + foreach ($attempt in 1..90) { + Start-Sleep -Seconds 1 + try { + $health = Invoke-RestMethod -Uri 'http://127.0.0.1:8001/api/publish/scheduler/health' -TimeoutSec 3 + if ($health.running -and $health.worker_available) { + $connected = $true + break + } + } catch { + # Docker or the worker is still starting. + } + } + if (-not $connected) { + throw 'The watcher is running, but publish center did not connect to the Windows worker within 90 seconds. The legacy task was not removed.' + } +} + +$legacyTask = Get-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue +if ($legacyTask) { + $legacyScript = Join-Path $ProjectRoot 'scripts\start_opencli_host_bridge.ps1' + if (Test-TaskBelongsToProject -Task $legacyTask -ExpectedScript $legacyScript) { + Stop-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $LegacyTaskName -Confirm:$false + Write-Host 'Removed the legacy task that pointed to the deleted startup script.' + } else { + Write-Warning "The legacy task '$LegacyTaskName' does not belong to this project and was preserved." + } +} + +Write-Host 'Docker watcher installed successfully.' +Write-Host 'The Windows publish worker will start only while the niuma-studio Docker project is running.' diff --git a/scripts/opencli_host_bridge.py b/scripts/opencli_host_bridge.py index 3ad77fc..7aa9628 100644 --- a/scripts/opencli_host_bridge.py +++ b/scripts/opencli_host_bridge.py @@ -142,14 +142,10 @@ def log_message(self, format: str, *args) -> None: def main() -> None: - parser = argparse.ArgumentParser(description="Windows opencli helper for Docker-hosted NiuMa Studio.") - parser.add_argument("--host", default="0.0.0.0") - parser.add_argument("--port", type=int, default=8765) - args = parser.parse_args() - - server = ThreadingHTTPServer((args.host, args.port), OpenCLIHostBridgeHandler) - print(f"Windows opencli helper listening on http://{args.host}:{args.port}", flush=True) - server.serve_forever() + # 兼容旧启动命令,但实际启动 v1.5 的受保护发布 Worker。 + from scripts.publish_host_worker import main as worker_main + + worker_main() if __name__ == "__main__": diff --git a/scripts/publish_host_worker.py b/scripts/publish_host_worker.py new file mode 100644 index 0000000..ac944ca --- /dev/null +++ b/scripts/publish_host_worker.py @@ -0,0 +1,362 @@ +"""牛马片场 Windows 浏览器发布 Worker。 + +该进程必须运行在安装了 Google Chrome 的 Windows 主机上。FastAPI 调度器通过带 +Bearer Token 的本地 HTTP 接口调用它,Docker 容器自身不接触宿主浏览器。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import threading +from pathlib import Path +from typing import Any + +import uvicorn +from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException +from pydantic import BaseModel, Field + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.config import settings # noqa: E402 +from app.services.publish_time import utc_now_iso # noqa: E402 +from app.services.publishers.base import ( # noqa: E402 + PublishError, + PublishNeedsReview, + PublishOutcome, + PublishResult, + PublishValidationError, +) +from app.services.publishers.browser_runtime import BrowserRuntime # noqa: E402 +from app.services.publishers.registry import get_platform_publisher # noqa: E402 + + +class AccountRequest(BaseModel): + platform: str = Field(pattern="^(douyin|bilibili)$") + account_id: str = Field(min_length=1, max_length=120) + + +class PublishRequest(BaseModel): + job_id: str = Field(min_length=1, max_length=160) + execution_id: str = Field(min_length=1, max_length=160) + platform: str = Field(pattern="^(douyin|bilibili)$") + account_id: str = Field(min_length=1, max_length=120) + task_id: str = "" + clip_id: str = "" + scheduled_at: str = "" + title: str + caption: str + hashtags: str = "" + video_path: str + cover_file_path: str = "" + visibility: str = "public" + allow_download: bool = True + bilibili_tid: str = "" + bilibili_copyright: str = "original" + bilibili_source: str = "" + publisher: str = "local_browser" + + +class OpenCliRunRequest(BaseModel): + command: list[str] + timeout: int = Field(default=600, ge=1, le=1800) + + +class ExecutionJournal: + def __init__(self, execution_id: str) -> None: + self.execution_id = execution_id + self.root = Path(settings.publish_worker_state_dir) / "executions" + self.path = self.root / f"{execution_id}.json" + self._lock = threading.Lock() + self.root.mkdir(parents=True, exist_ok=True) + + def read(self) -> dict[str, Any]: + if not self.path.exists(): + return {"execution_id": self.execution_id, "phase": "unknown"} + try: + parsed = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"execution_id": self.execution_id, "phase": "unknown"} + return parsed if isinstance(parsed, dict) else {"execution_id": self.execution_id, "phase": "unknown"} + + def update(self, phase: str, details: dict[str, Any] | None = None) -> None: + with self._lock: + current = self.read() + current.update({ + "execution_id": self.execution_id, + "phase": phase, + "updated_at": utc_now_iso(), + }) + if details: + current["details"] = details + temporary = self.path.with_suffix(".tmp") + temporary.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(self.path) + + +_ACCOUNT_LOCKS: dict[str, threading.Lock] = {} +_LOCKS_GUARD = threading.Lock() + + +def _account_lock(platform: str, account_id: str) -> threading.Lock: + key = f"{platform}:{account_id}" + with _LOCKS_GUARD: + return _ACCOUNT_LOCKS.setdefault(key, threading.Lock()) + + +def _allowed_roots() -> list[Path]: + roots = [ + Path(settings.publish_host_project_root), + Path(settings.tasks_dir), + Path(settings.data_dir), + ] + configured = str(settings.publish_worker_allowed_roots or "") + roots.extend(Path(item.strip()).expanduser() for item in configured.split(os.pathsep) if item.strip()) + return [root.resolve() for root in roots if str(root)] + + +def _resolve_media_path(raw_value: str, *, required: bool) -> str: + text = str(raw_value or "").strip() + if not text: + if required: + raise PublishValidationError("媒体文件路径不能为空", "missing_media_path") + return "" + path = Path(text).expanduser() + normalized = text.replace("\\", "/") + if not path.exists() and normalized.startswith("/workspace/tasks/"): + relative = normalized[len("/workspace/tasks/"):] + path = Path(settings.tasks_dir) / Path(relative) + elif not path.exists() and normalized.startswith("/app/"): + relative = normalized[len("/app/"):] + path = Path(settings.publish_host_project_root) / Path(relative) + try: + resolved = path.resolve(strict=True) + except OSError as exc: + raise PublishValidationError(f"媒体文件不存在:{text}", "media_not_found") from exc + if not resolved.is_file(): + raise PublishValidationError(f"媒体路径不是文件:{text}", "invalid_media_path") + allowed = any(resolved == root or root in resolved.parents for root in _allowed_roots()) + if not allowed: + raise PublishValidationError("媒体文件不在 Worker 允许目录内", "media_path_not_allowed") + return str(resolved) + + +def create_worker_app(token: str | None = None) -> FastAPI: + worker_token = str(token if token is not None else settings.publish_worker_token) + worker = FastAPI(title="NiuMa Studio Publish Worker", version="1.5.0") + + def require_token(authorization: str = Header(default="")) -> None: + if not worker_token: + raise HTTPException(status_code=503, detail="Worker 未配置 PUBLISH_WORKER_TOKEN") + if authorization != f"Bearer {worker_token}": + raise HTTPException(status_code=401, detail="Worker Token 无效") + + def health_payload() -> dict[str, Any]: + from scripts.opencli_host_bridge import _opencli_executable + + opencli_executable = _opencli_executable() if settings.publish_enable_opencli_fallback else None + return { + "status": "ok", + "worker": "windows_chrome", + "browser_channel": settings.publish_browser_channel, + "timezone": settings.app_timezone, + "token_configured": bool(worker_token), + "opencli_available": bool(opencli_executable), + "opencli_executable": opencli_executable or "", + "message": "Windows 发布 Worker 已启动", + } + + @worker.get("/health") + def health() -> dict[str, Any]: + """供 Windows 本机启动脚本使用的公开健康检查。""" + return health_payload() + + @worker.get("/v1/health", dependencies=[Depends(require_token)]) + def protected_health() -> dict[str, Any]: + """供 Docker 调度器使用,同时验证 Worker Token。""" + return health_payload() + + @worker.post("/run", dependencies=[Depends(require_token)]) + def run_opencli_compat(payload: OpenCliRunRequest) -> dict[str, Any]: + if not settings.publish_enable_opencli_fallback: + raise HTTPException(status_code=403, detail="opencli 兼容模式未开启") + if not payload.command or Path(payload.command[0]).name.lower() not in { + "opencli", "opencli.cmd", "opencli.exe", "opencli.ps1" + }: + raise HTTPException(status_code=400, detail="兼容接口只允许执行 opencli 命令") + from scripts.opencli_host_bridge import _normalize_command + + command = _normalize_command(payload.command) + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=payload.timeout, + ) + return { + "status": "ok", "returncode": result.returncode, + "stdout": result.stdout or "", "stderr": result.stderr or "", + } + except subprocess.TimeoutExpired as exc: + return { + "status": "timeout", "returncode": 124, + "stdout": exc.stdout or "", "stderr": exc.stderr or "opencli 命令超时", + } + + @worker.post("/v1/accounts/check", dependencies=[Depends(require_token)]) + def check_account(payload: AccountRequest) -> dict[str, Any]: + lock = _account_lock(payload.platform, payload.account_id) + if not lock.acquire(blocking=False): + return {"login_status": "busy", "message": "该账号正在执行浏览器操作"} + try: + runtime = BrowserRuntime(payload.platform, payload.account_id) + publisher = get_platform_publisher( + payload.platform, runtime=runtime, account_id=payload.account_id + ) + result = publisher.check_login(payload.account_id) + return result + except PublishError as exc: + return {"login_status": "login_required", "message": exc.message, "error_code": exc.error_code} + finally: + lock.release() + + def login_background(payload: AccountRequest) -> None: + lock = _account_lock(payload.platform, payload.account_id) + if not lock.acquire(blocking=False): + return + try: + runtime = BrowserRuntime(payload.platform, payload.account_id) + publisher = get_platform_publisher( + payload.platform, runtime=runtime, account_id=payload.account_id + ) + publisher.open_login(payload.account_id) + except Exception: + # Worker 只负责宿主浏览器操作,不直接写 Docker 挂载的 SQLite。 + # 登录结果由 FastAPI 后续通过账号检测接口统一落库。 + return + finally: + lock.release() + + @worker.post("/v1/accounts/login", dependencies=[Depends(require_token)], status_code=202) + def login_account(payload: AccountRequest, background_tasks: BackgroundTasks) -> dict[str, Any]: + if _account_lock(payload.platform, payload.account_id).locked(): + raise HTTPException(status_code=409, detail="该账号已有浏览器窗口正在运行") + background_tasks.add_task(login_background, payload) + return {"status": "started", "message": "已打开独立 Chrome,请在窗口中完成平台登录"} + + @worker.post("/v1/accounts/open-center", dependencies=[Depends(require_token)], status_code=202) + def open_center(payload: AccountRequest, background_tasks: BackgroundTasks) -> dict[str, Any]: + if _account_lock(payload.platform, payload.account_id).locked(): + raise HTTPException(status_code=409, detail="该账号已有浏览器窗口正在运行") + background_tasks.add_task(login_background, payload) + return {"status": "started", "message": "已打开平台创作者中心"} + + @worker.post("/v1/publish", dependencies=[Depends(require_token)]) + def publish(payload: PublishRequest) -> dict[str, Any]: + journal = ExecutionJournal(payload.execution_id) + journal.update("received", {"job_id": payload.job_id, "platform": payload.platform}) + lock = _account_lock(payload.platform, payload.account_id) + if not lock.acquire(blocking=False): + result = PublishResult( + outcome=PublishOutcome.FAILED, + message="同一账号已有发布任务正在执行", + error_code="account_busy", + ) + journal.update("rejected", result.as_dict()) + return result.as_dict() + try: + values = payload.model_dump() + values["video_path"] = _resolve_media_path(values["video_path"], required=True) + values["cover_file_path"] = _resolve_media_path(values["cover_file_path"], required=False) + def update_phase(phase: str, details: dict[str, Any] | None = None) -> None: + journal.update(phase, details) + + runtime = BrowserRuntime( + payload.platform, + payload.account_id, + phase_callback=update_phase, + ) + publisher = get_platform_publisher( + payload.platform, + runtime=runtime, + account_id=payload.account_id, + ) + result = publisher.publish(values) + journal.update("confirmed_success" if result.outcome == PublishOutcome.PUBLISHED else result.outcome.value.lower(), result.as_dict()) + return result.as_dict() + except PublishNeedsReview as exc: + current = journal.read() + diagnostics = current.get("details") if isinstance(current.get("details"), dict) else {} + result = PublishResult( + outcome=PublishOutcome.NEED_REVIEW, + message=exc.message, + error_code=exc.error_code, + needs_manual_review=True, + provider_response={"diagnostics": diagnostics}, + ) + journal.update("manual_review", result.as_dict()) + return result.as_dict() + except PublishValidationError as exc: + result = PublishResult( + outcome=PublishOutcome.FAILED, + message=exc.message, + error_code=exc.error_code, + ) + journal.update("failed", result.as_dict()) + return result.as_dict() + except PublishError as exc: + phase = str(journal.read().get("phase") or "unknown") + current = journal.read() + diagnostics = current.get("details") if isinstance(current.get("details"), dict) else {} + uncertain = exc.needs_manual_review or phase in { + "upload_started", "upload_completed", "submit_clicked", "unknown" + } + result = PublishResult( + outcome=PublishOutcome.NEED_REVIEW if uncertain else PublishOutcome.FAILED, + message=exc.message, + error_code=exc.error_code, + needs_manual_review=uncertain, + provider_response={"diagnostics": diagnostics}, + ) + journal.update("manual_review" if uncertain else "failed", result.as_dict()) + return result.as_dict() + except Exception as exc: + result = PublishResult( + outcome=PublishOutcome.NEED_REVIEW, + message=f"Worker 出现未识别异常,请人工确认平台是否已投稿:{exc}", + error_code="worker_result_uncertain", + needs_manual_review=True, + ) + journal.update("manual_review", result.as_dict()) + return result.as_dict() + finally: + lock.release() + + @worker.get("/v1/executions/{execution_id}", dependencies=[Depends(require_token)]) + def execution(execution_id: str) -> dict[str, Any]: + return ExecutionJournal(execution_id).read() + + return worker + + +def main() -> int: + parser = argparse.ArgumentParser(description="NiuMa Studio Windows publish worker") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + if not settings.publish_worker_token: + raise SystemExit("请先在 .env 中设置 PUBLISH_WORKER_TOKEN,再启动发布 Worker。") + uvicorn.run(create_worker_app(), host=args.host, port=args.port, log_level="info") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/purge_deleted_task_media.py b/scripts/purge_deleted_task_media.py new file mode 100644 index 0000000..6e76655 --- /dev/null +++ b/scripts/purge_deleted_task_media.py @@ -0,0 +1,161 @@ +"""预览或永久清理已隐藏任务的系统托管媒体文件。""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.config import settings # noqa: E402 +from app.db.database import get_connection # noqa: E402 +from app.services.database_backup_service import create_media_cleanup_backup # noqa: E402 +from app.services.storage_service import ( # noqa: E402 + StorageSafetyError, + build_task_media_cleanup_plan, + task_media_cleanup_plan_size, +) +from app.services.task_lifecycle_service import delete_task_permanently # noqa: E402 + + +def _deleted_tasks() -> list[dict]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, task_name, task_dir_name, source_type, + original_video_path, nas_file_path, status, + COALESCE(is_deleted, 0) AS is_deleted + FROM tasks + WHERE COALESCE(is_deleted, 0) = 1 + ORDER BY deleted_at, created_at, id + """ + ).fetchall() + return [dict(row) for row in rows] + + +def _active_task_directories() -> dict[str, bool]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, task_dir_name + FROM tasks + WHERE COALESCE(is_deleted, 0) = 0 + ORDER BY id + """ + ).fetchall() + return { + str(settings.tasks_dir / str(row["task_dir_name"] or row["id"])): ( + settings.tasks_dir / str(row["task_dir_name"] or row["id"]) + ).exists() + for row in rows + } + + +def build_report() -> dict: + items = [] + total_bytes = 0 + existing_directories = 0 + for task in _deleted_tasks(): + plan = build_task_media_cleanup_plan(task) + size_bytes = task_media_cleanup_plan_size(plan) + targets = [str(target.path) for target in plan.existing_targets] + total_bytes += size_bytes + existing_directories += len(targets) + items.append( + { + "task_id": task["id"], + "task_name": task["task_name"], + "size_bytes": size_bytes, + "existing_targets": targets, + "external_source_preserved": str(plan.external_source_path or ""), + } + ) + return { + "mode": "dry-run", + "database_path": str(settings.database_path), + "tasks_dir": str(settings.tasks_dir), + "deleted_task_count": len(items), + "existing_directory_count": existing_directories, + "total_bytes": total_bytes, + "items": items, + } + + +def apply_report(report: dict) -> dict: + active_before = _active_task_directories() + missing_active_before = [path for path, existed in active_before.items() if not existed] + if missing_active_before: + raise RuntimeError( + "发现有效任务目录在清理前已经缺失,已中止:" + ";".join(missing_active_before) + ) + + active_resolved = {Path(path).resolve(strict=False) for path in active_before} + cleanup_resolved = { + Path(path).resolve(strict=False) + for item in report["items"] + for path in item["existing_targets"] + } + overlaps = [] + for cleanup_path in cleanup_resolved: + for active_path in active_resolved: + if ( + cleanup_path == active_path + or cleanup_path in active_path.parents + or active_path in cleanup_path.parents + ): + overlaps.append(f"清理目标 {cleanup_path} 与有效任务 {active_path} 重叠") + if overlaps: + raise RuntimeError("发现清理目标与有效任务目录重叠,已中止:" + ";".join(overlaps)) + + backup_path = create_media_cleanup_backup( + settings.database_path, + settings.data_dir / "backups", + ) + results = [] + for item in report["items"]: + results.append(delete_task_permanently(str(item["task_id"]))) + + missing_active_after = [path for path in active_before if not Path(path).exists()] + if missing_active_after: + raise RuntimeError( + "清理后发现有效任务目录缺失,请立即检查数据库备份:" + ";".join(missing_active_after) + ) + + released_bytes = sum(int(result["freed_bytes"]) for result in results) + return { + **report, + "mode": "apply", + "backup_path": str(backup_path), + "released_bytes": released_bytes, + "results": results, + "active_task_count_verified": len(active_before), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="永久清理已隐藏任务的系统托管媒体") + parser.add_argument( + "--apply", + action="store_true", + help="实际永久删除;不带此参数时只生成预览清单", + ) + args = parser.parse_args() + + try: + report = build_report() + if args.apply: + report = apply_report(report) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + except (StorageSafetyError, RuntimeError) as exc: + print(f"清理已中止:{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start_docker_opencli.ps1 b/scripts/start_docker_opencli.ps1 index c5a6547..0348f35 100644 --- a/scripts/start_docker_opencli.ps1 +++ b/scripts/start_docker_opencli.ps1 @@ -1,91 +1,31 @@ -param( +param( [int]$BridgePort = 8765, [switch]$NoBrowser ) $ErrorActionPreference = "Stop" - $ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $ProjectRoot -if ($env:APPDATA) { - $npmDir = Join-Path $env:APPDATA "npm" - if (Test-Path $npmDir) { - $env:PATH = "$npmDir;$env:PATH" - } -} - -$opencli = Get-Command opencli -ErrorAction SilentlyContinue -if (-not $opencli) { - Write-Host 'opencli was not found. Please install opencli and make sure where opencli returns a path.' - exit 1 -} -Write-Host ('opencli found: {0}' -f $opencli.Source) +Write-Host '此兼容脚本现在会启动 v1.5 Windows Chrome 发布 Worker。' +& (Join-Path $PSScriptRoot 'start_publish_worker.ps1') -Port $BridgePort -$bridgeConnections = Get-NetTCPConnection -LocalPort $BridgePort -State Listen -ErrorAction SilentlyContinue -$bridgeProcessIds = @($bridgeConnections | Select-Object -ExpandProperty OwningProcess -Unique) -foreach ($processId in $bridgeProcessIds) { - if (-not $processId -or $processId -eq $PID) { - continue - } - $process = Get-Process -Id $processId -ErrorAction SilentlyContinue - if ($process) { - Write-Host ('Stopping old opencli helper: PID {0} ({1})' -f $processId, $process.ProcessName) - Stop-Process -Id $processId -Force - } -} - -$python = Join-Path $ProjectRoot ".venv\Scripts\python.exe" -if (-not (Test-Path $python)) { - $python = "python" -} - -$bridgeOutLog = Join-Path $ProjectRoot "opencli_bridge_$BridgePort.out.log" -$bridgeErrLog = Join-Path $ProjectRoot "opencli_bridge_$BridgePort.err.log" -Write-Host ('Starting Windows opencli helper: http://127.0.0.1:{0}' -f $BridgePort) -$bridgeScript = Join-Path $ProjectRoot "scripts\opencli_host_bridge.py" -$bridgeArguments = @("`"$bridgeScript`"", "--host", "0.0.0.0", "--port", "$BridgePort") -Start-Process ` - -FilePath $python ` - -ArgumentList $bridgeArguments ` - -WorkingDirectory $ProjectRoot ` - -RedirectStandardOutput $bridgeOutLog ` - -RedirectStandardError $bridgeErrLog ` - -WindowStyle Hidden - -Start-Sleep -Seconds 2 -$previousErrorActionPreference = $ErrorActionPreference -$ErrorActionPreference = "SilentlyContinue" -$bridgeHealth = Invoke-WebRequest -Uri "http://127.0.0.1:$BridgePort/health" -UseBasicParsing -TimeoutSec 5 -$ErrorActionPreference = $previousErrorActionPreference -if ($bridgeHealth) { - Write-Host 'opencli helper is running.' -} else { - Write-Host ('opencli helper is not responding yet. Log: {0}' -f $bridgeErrLog) -} - -Write-Host 'Cleaning old Docker services that may occupy port 8001.' +Write-Host '正在刷新 Docker 服务:http://127.0.0.1:8001' docker compose down --remove-orphans -$portContainerIds = @(docker ps --filter 'publish=8001' --format '{{.ID}}') -foreach ($containerId in $portContainerIds) { - if ($containerId) { - Write-Host ('Stopping old container on port 8001: {0}' -f $containerId) - docker rm -f $containerId | Out-Null - } +if ($LASTEXITCODE -ne 0) { + throw 'docker compose down 执行失败,请确认 Docker Desktop 已启动。' } - -Write-Host 'Refreshing Docker service: http://127.0.0.1:8001' docker compose up -d --build +if ($LASTEXITCODE -ne 0) { + throw 'docker compose up 执行失败,请查看上方 Docker 错误。' +} Start-Sleep -Seconds 3 -$previousErrorActionPreference = $ErrorActionPreference -$ErrorActionPreference = "SilentlyContinue" -$dockerHealth = Invoke-WebRequest -Uri "http://127.0.0.1:8001/health" -UseBasicParsing -TimeoutSec 8 -$ErrorActionPreference = $previousErrorActionPreference -if ($dockerHealth) { - Write-Host 'Docker page is running: http://127.0.0.1:8001' -} else { - Write-Host 'Docker started, but health check is not ready yet. Wait 5 seconds and refresh http://127.0.0.1:8001' +try { + Invoke-WebRequest -Uri 'http://127.0.0.1:8001/health' -UseBasicParsing -TimeoutSec 8 | Out-Null + Write-Host '牛马片场 Docker 页面已启动:http://127.0.0.1:8001' +} catch { + Write-Host 'Docker 已启动但页面还在初始化,请稍等 5 秒后刷新。' } if (-not $NoBrowser) { diff --git a/scripts/start_niuma_studio.ps1 b/scripts/start_niuma_studio.ps1 new file mode 100644 index 0000000..403d63f --- /dev/null +++ b/scripts/start_niuma_studio.ps1 @@ -0,0 +1,66 @@ +param( + [int]$WorkerPort = 8765, + [switch]$NoBrowser, + [switch]$NoBuild +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +Set-Location $ProjectRoot + +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw '没有检测到 Docker。请先启动 Docker Desktop,再重新运行本脚本。' +} + +Write-Host '第 1 步:启动或复用 Windows Chrome 发布 Worker……' +& (Join-Path $PSScriptRoot 'start_publish_worker.ps1') -Port $WorkerPort -SkipDockerSync + +Write-Host '第 2 步:启动牛马片场 Docker 服务……' +$composeArguments = @('compose', 'up', '-d') +if (-not $NoBuild) { + $composeArguments += '--build' +} +& docker $composeArguments +if ($LASTEXITCODE -ne 0) { + throw 'Docker 服务启动失败,请查看上方错误信息。现有数据库和任务文件没有被删除。' +} + +Write-Host '第 3 步:检查页面和发布 Worker 连接……' +$appReady = $false +$schedulerRestartAttempted = $false +foreach ($attempt in 1..60) { + Start-Sleep -Seconds 1 + try { + $app = Invoke-RestMethod -Uri 'http://127.0.0.1:8001/health' -TimeoutSec 3 + $publisher = Invoke-RestMethod -Uri 'http://127.0.0.1:8001/api/publish/scheduler/health' -TimeoutSec 3 + if ($app.status -eq 'ok' -and $publisher.worker_available -and $publisher.running) { + $appReady = $true + break + } + if ( + $app.status -eq 'ok' -and + $publisher.worker_available -and + -not $publisher.running -and + -not $schedulerRestartAttempted + ) { + Write-Host '检测到页面正常但发布调度器未运行,正在重启本项目 workflow 服务……' + & docker compose restart workflow + if ($LASTEXITCODE -ne 0) { + throw '本项目 workflow 服务重启失败;没有删除数据库或任务文件。' + } + $schedulerRestartAttempted = $true + } + } catch { + # Docker 首次构建或启动中,继续等待,最长约 60 秒。 + } +} + +if (-not $appReady) { + throw '项目已经尝试启动,但页面或发布 Worker 在 60 秒内没有连接成功。请查看 Docker 和 publish_worker_8765.err.log。' +} + +$publishUrl = 'http://127.0.0.1:8001/publish' +Write-Host ("启动成功:{0}" -f $publishUrl) +if (-not $NoBrowser) { + Start-Process $publishUrl +} diff --git a/scripts/start_publish_worker.ps1 b/scripts/start_publish_worker.ps1 new file mode 100644 index 0000000..ae60c34 --- /dev/null +++ b/scripts/start_publish_worker.ps1 @@ -0,0 +1,177 @@ +param( + [int]$Port = 8765, + [switch]$SkipDockerSync, + [switch]$Restart +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $ProjectRoot + +$envFile = Join-Path $ProjectRoot ".env" +if (-not (Test-Path $envFile)) { + New-Item -ItemType File -Path $envFile | Out-Null +} + +$envText = Get-Content -LiteralPath $envFile -Raw -ErrorAction SilentlyContinue +$tokenMatch = [regex]::Match($envText, '(?m)^PUBLISH_WORKER_TOKEN=(.+)$') +if ($tokenMatch.Success -and $tokenMatch.Groups[1].Value.Trim()) { + $workerToken = $tokenMatch.Groups[1].Value.Trim() +} else { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + $rng.GetBytes($bytes) + $rng.Dispose() + $workerToken = ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' + if ([regex]::IsMatch($envText, '(?m)^PUBLISH_WORKER_TOKEN=.*$')) { + $envText = [regex]::Replace($envText, '(?m)^PUBLISH_WORKER_TOKEN=.*$', "PUBLISH_WORKER_TOKEN=$workerToken") + Set-Content -LiteralPath $envFile -Value $envText -Encoding UTF8 + } else { + Add-Content -LiteralPath $envFile -Value "`r`nPUBLISH_WORKER_TOKEN=$workerToken" -Encoding UTF8 + } + Write-Host '已在本地 .env 中生成发布 Worker Token(不会提交到 Git)。' +} +$env:PUBLISH_WORKER_TOKEN = $workerToken + +$chromeCandidates = @() +foreach ($basePath in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA)) { + if ($basePath) { + $candidate = Join-Path $basePath 'Google\Chrome\Application\chrome.exe' + if (Test-Path $candidate) { + $chromeCandidates += $candidate + } + } +} +if (-not $chromeCandidates) { + throw '没有检测到 Google Chrome。请先安装 Chrome,再启动真实发布 Worker。' +} + +$workerScript = Join-Path $ProjectRoot 'scripts\publish_host_worker.py' +$legacyWorkerScript = Join-Path $ProjectRoot 'scripts\opencli_host_bridge.py' +$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) +$listenerProcessIds = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique) +$reuseWorker = $false +foreach ($processId in $listenerProcessIds) { + $commandLine = [string](Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue).CommandLine + $belongsToProject = ( + $commandLine.IndexOf($workerScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or + $commandLine.IndexOf($legacyWorkerScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + ) + if ($belongsToProject) { + if ($Restart) { + Write-Host ("正在重启本项目的发布 Worker:PID {0}" -f $processId) + Stop-Process -Id $processId -Force + continue + } + try { + $headers = @{ Authorization = "Bearer $workerToken" } + $existingHealth = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/v1/health" -Headers $headers -TimeoutSec 2 + if ($existingHealth.status -eq 'ok' -and $existingHealth.worker -eq 'windows_chrome') { + $reuseWorker = $true + Write-Host ("检测到健康的发布 Worker,直接复用:PID {0}" -f $processId) + } + } catch { + Write-Host ("旧发布 Worker 无法通过健康或 Token 校验,正在安全重启:PID {0}" -f $processId) + Stop-Process -Id $processId -Force + } + } else { + throw "端口 $Port 已被其他程序占用(PID $processId),为避免误关程序已停止启动。" + } +} + +# Stop-Process 返回时,Windows 可能仍需极短时间才能真正释放监听端口。 +# 必须确认端口已经空闲后再启动新进程,否则重复运行脚本时会偶发 WinError 10048。 +if ($listenerProcessIds -and -not $reuseWorker) { + $releaseDeadline = [DateTime]::UtcNow.AddSeconds(10) + do { + $remainingListeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if (-not $remainingListeners) { + break + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $releaseDeadline) + + if ($remainingListeners) { + $remainingProcessIds = @($remainingListeners | Select-Object -ExpandProperty OwningProcess -Unique) + throw "旧发布 Worker 已停止,但端口 $Port 在 10 秒内仍未释放(PID:$($remainingProcessIds -join ', '))。请稍后重新运行脚本。" + } +} + +$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe' +if (-not (Test-Path $python)) { + $python = 'python' +} +$outLog = Join-Path $ProjectRoot "publish_worker_$Port.out.log" +$errLog = Join-Path $ProjectRoot "publish_worker_$Port.err.log" +$arguments = @("`"$workerScript`"", '--host', '127.0.0.1', '--port', "$Port") + +if (-not $reuseWorker) { +Write-Host ("正在启动 Windows Chrome 发布 Worker:http://127.0.0.1:{0}" -f $Port) +$workerProcess = Start-Process ` + -FilePath $python ` + -ArgumentList $arguments ` + -WorkingDirectory $ProjectRoot ` + -RedirectStandardOutput $outLog ` + -RedirectStandardError $errLog ` + -WindowStyle Hidden ` + -PassThru + +$health = $null +foreach ($attempt in 1..20) { + Start-Sleep -Milliseconds 500 + if ($workerProcess.HasExited) { + break + } + try { + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2 + if ($health.status -eq 'ok') { + break + } + $health = $null + } catch { + # Worker 或端口仍在初始化,继续等待,最多等待约 10 秒。 + } +} + +if ($health -and $health.status -eq 'ok' -and -not $workerProcess.HasExited) { + Write-Host '发布 Worker 已启动。登录账号时会打开牛马片场专属 Chrome 窗口。' +} else { + throw "发布 Worker 未能启动。请查看日志:$errLog" +} +} + +# Docker 容器只会在创建时读取 .env。这里自动重建正在运行的 Web 容器, +# 让刚生成的 Worker Token 和当前 compose 连接地址立即生效;SQLite 与任务目录均为挂载卷,不会被删除。 +$docker = Get-Command docker -ErrorAction SilentlyContinue +if ($docker -and -not $SkipDockerSync) { + try { + $runningServices = @(docker compose ps --services --status running 2>$null) + if ($runningServices -contains 'workflow') { + Write-Host '正在同步 Docker 与 Windows Worker 的连接配置……' + docker compose up -d --force-recreate --no-deps workflow | Out-Host + if ($LASTEXITCODE -ne 0) { + throw 'Docker 容器重建失败。' + } + + $connected = $false + foreach ($attempt in 1..20) { + Start-Sleep -Seconds 1 + try { + $appHealth = Invoke-RestMethod -Uri 'http://127.0.0.1:8001/api/publish/scheduler/health' -TimeoutSec 3 + if ($appHealth.worker_available) { + $connected = $true + break + } + } catch { + # Web 容器仍在启动,继续等待。 + } + } + if (-not $connected) { + throw 'Docker 已重启,但发送中心仍未连接 Worker。请查看 Docker 日志和 Worker 错误日志。' + } + Write-Host 'Docker 已同步完成,发送中心现在可以连接 Windows Worker。' + } + } catch { + throw "Worker 已启动,但 Docker 同步失败:$($_.Exception.Message)" + } +} diff --git a/scripts/uninstall_docker_publish_worker_watcher.ps1 b/scripts/uninstall_docker_publish_worker_watcher.ps1 new file mode 100644 index 0000000..1553553 --- /dev/null +++ b/scripts/uninstall_docker_publish_worker_watcher.ps1 @@ -0,0 +1,58 @@ +param( + [int]$Port = 8765, + [string]$TaskName = 'NiuMa Studio Docker Watcher', + [switch]$KeepWorker +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path.TrimEnd('\') +$WatcherScript = Join-Path $ProjectRoot 'scripts\watch_docker_publish_worker.ps1' +$WorkerScript = Join-Path $ProjectRoot 'scripts\publish_host_worker.py' +$LegacyWorkerScript = Join-Path $ProjectRoot 'scripts\opencli_host_bridge.py' + +$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +if ($task) { + $ownedTask = $false + foreach ($action in @($task.Actions)) { + if ( + ([string]$action.Arguments).IndexOf($WatcherScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and + ([string]$action.WorkingDirectory).TrimEnd('\') -ieq $ProjectRoot + ) { + $ownedTask = $true + } + } + if (-not $ownedTask) { + throw "The scheduled task '$TaskName' does not belong to this project and was preserved." + } + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + Write-Host 'Docker watcher task removed.' +} + +$watcherProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ([string]$_.CommandLine).IndexOf($WatcherScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + } +foreach ($watcherProcess in $watcherProcesses) { + if ($watcherProcess.ProcessId -and $watcherProcess.ProcessId -ne $PID) { + Stop-Process -Id $watcherProcess.ProcessId -Force -ErrorAction SilentlyContinue + } +} + +if (-not $KeepWorker) { + $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + foreach ($listener in $listeners) { + $ownedProcessId = [int]$listener.OwningProcess + $processInfo = Get-CimInstance Win32_Process -Filter "ProcessId=$ownedProcessId" -ErrorAction SilentlyContinue + $commandLine = [string]$processInfo.CommandLine + if ( + $commandLine.IndexOf($WorkerScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or + $commandLine.IndexOf($LegacyWorkerScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + ) { + Stop-Process -Id $ownedProcessId -Force -ErrorAction SilentlyContinue + Write-Host ("Windows publish worker stopped: PID {0}." -f $ownedProcessId) + } + } +} + +Write-Host 'Uninstall completed. Database, task files, Chrome profiles, and logs were preserved.' diff --git a/scripts/watch_docker_publish_worker.ps1 b/scripts/watch_docker_publish_worker.ps1 new file mode 100644 index 0000000..67b4caf --- /dev/null +++ b/scripts/watch_docker_publish_worker.ps1 @@ -0,0 +1,247 @@ +param( + [int]$Port = 8765, + [int]$PollSeconds = 3, + [int]$StopGraceSeconds = 15, + [switch]$RunOnce +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path.TrimEnd('\') +$ContainerName = 'niuma-studio' +$ComposeProject = 'niuma-studio' +$ComposeService = 'workflow' +$WorkerScript = (Join-Path $ProjectRoot 'scripts\publish_host_worker.py') +$LegacyWorkerScript = (Join-Path $ProjectRoot 'scripts\opencli_host_bridge.py') +$StartWorkerScript = (Join-Path $ProjectRoot 'scripts\start_publish_worker.ps1') +$ComposeFile = (Join-Path $ProjectRoot 'docker-compose.yml') +$LogDirectory = Join-Path $ProjectRoot 'data\logs' +$LogFile = Join-Path $LogDirectory 'docker_publish_worker_watcher.log' + +$PollSeconds = [Math]::Max(2, $PollSeconds) +$StopGraceSeconds = [Math]::Max(5, $StopGraceSeconds) + +function Write-WatcherLog { + param([string]$Message) + + if (-not (Test-Path -LiteralPath $LogDirectory)) { + New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null + } + $line = '{0} {1}{2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Message, [Environment]::NewLine + [System.IO.File]::AppendAllText($LogFile, $line, [System.Text.UTF8Encoding]::new($false)) +} + +function Get-DockerCommand { + $command = Get-Command docker -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + $desktopDocker = Join-Path $env:ProgramFiles 'Docker\Docker\resources\bin\docker.exe' + if (Test-Path -LiteralPath $desktopDocker) { + return $desktopDocker + } + return '' +} + +function Get-TargetContainerRunning { + param([string]$DockerCommand) + + if (-not $DockerCommand) { + return $false + } + try { + $raw = @(& $DockerCommand inspect $ContainerName 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $raw) { + return $false + } + $items = @((($raw -join [Environment]::NewLine) | ConvertFrom-Json)) + if (-not $items) { + return $false + } + $container = $items[0] + $labels = $container.Config.Labels + $projectLabel = [string]$labels.PSObject.Properties['com.docker.compose.project'].Value + $serviceLabel = [string]$labels.PSObject.Properties['com.docker.compose.service'].Value + $workingDirectoryLabel = [string]$labels.PSObject.Properties['com.docker.compose.project.working_dir'].Value + if (-not $workingDirectoryLabel) { + return $false + } + $labelRoot = [System.IO.Path]::GetFullPath($workingDirectoryLabel).TrimEnd('\') + return ( + [bool]$container.State.Running -and + $projectLabel -eq $ComposeProject -and + $serviceLabel -eq $ComposeService -and + $labelRoot -ieq $ProjectRoot + ) + } catch { + return $false + } +} + +function Get-OwnedWorkerProcessIds { + $processIds = @() + $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + foreach ($listener in $listeners) { + $ownedProcessId = [int]$listener.OwningProcess + if (-not $ownedProcessId) { + continue + } + $processInfo = Get-CimInstance Win32_Process -Filter "ProcessId=$ownedProcessId" -ErrorAction SilentlyContinue + $commandLine = [string]$processInfo.CommandLine + $belongsToProject = ( + $commandLine.IndexOf($WorkerScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or + $commandLine.IndexOf($LegacyWorkerScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + ) + if ($belongsToProject) { + $processIds += $ownedProcessId + } + } + return @($processIds | Select-Object -Unique) +} + +function Test-OwnedWorkerHealthy { + $processIds = @(Get-OwnedWorkerProcessIds) + if (-not $processIds) { + return $false + } + try { + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2 + return $health.status -eq 'ok' -and $health.worker -eq 'windows_chrome' + } catch { + return $false + } +} + +function Start-OwnedWorker { + Write-WatcherLog 'Target Docker container is running; starting the Windows publish worker.' + try { + & $StartWorkerScript -Port $Port -SkipDockerSync -Restart | Out-Null + if (Test-OwnedWorkerHealthy) { + Write-WatcherLog 'Windows publish worker started.' + return $true + } + Write-WatcherLog 'Worker start script returned, but the health check still failed.' + } catch { + Write-WatcherLog ("Worker start failed: {0}" -f $_.Exception.Message) + } + return $false +} + +function Stop-OwnedWorker { + $processIds = @(Get-OwnedWorkerProcessIds) + foreach ($ownedProcessId in $processIds) { + try { + Stop-Process -Id $ownedProcessId -Force -ErrorAction Stop + Wait-Process -Id $ownedProcessId -Timeout 10 -ErrorAction SilentlyContinue + Write-WatcherLog ("Target Docker container stopped; worker PID {0} was stopped." -f $ownedProcessId) + } catch { + Write-WatcherLog ("Failed to stop worker PID {0}: {1}" -f $ownedProcessId, $_.Exception.Message) + } + } +} + +function Test-DockerWorkerConnection { + try { + $health = Invoke-RestMethod -Uri 'http://127.0.0.1:8001/api/publish/scheduler/health' -TimeoutSec 3 + return [bool]$health.running -and [bool]$health.worker_available + } catch { + return $false + } +} + +function Wait-DockerWorkerConnection { + param([int]$Seconds) + + $deadline = [DateTime]::UtcNow.AddSeconds($Seconds) + do { + if (Test-DockerWorkerConnection) { + return $true + } + Start-Sleep -Seconds 2 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Repair-DockerWorkerConnection { + param([string]$DockerCommand) + + if (Wait-DockerWorkerConnection -Seconds 20) { + Write-WatcherLog 'Docker publish center connected to the Windows publish worker.' + return $true + } + + Write-WatcherLog 'Docker has not completed the authenticated worker check; recreating only workflow to sync local configuration.' + try { + Push-Location $ProjectRoot + & $DockerCommand compose -f $ComposeFile up -d --force-recreate --no-deps $ComposeService 2>&1 | Out-Null + $composeExitCode = $LASTEXITCODE + } catch { + $composeExitCode = 1 + Write-WatcherLog ("Docker configuration sync failed: {0}" -f $_.Exception.Message) + } finally { + Pop-Location + } + if ($composeExitCode -ne 0) { + Write-WatcherLog 'Docker configuration sync failed; database and task directories were preserved.' + return $false + } + if (Wait-DockerWorkerConnection -Seconds 60) { + Write-WatcherLog 'Docker configuration synced and publish center connected to the worker.' + return $true + } + Write-WatcherLog 'Docker is running, but publish center did not connect to the worker within 60 seconds.' + return $false +} + +$createdNew = $false +$watcherMutex = [System.Threading.Mutex]::new($true, 'Local\NiuMaStudioDockerPublishWatcher', [ref]$createdNew) +if (-not $createdNew) { + $watcherMutex.Dispose() + exit 0 +} + +$offlineSince = $null +$connectionCheckedForRun = $false +$lastTargetState = $null + +try { + Write-WatcherLog 'Docker watcher started. The worker will start only while the target container is running.' + do { + $dockerCommand = Get-DockerCommand + $targetRunning = Get-TargetContainerRunning -DockerCommand $dockerCommand + if ($lastTargetState -ne $targetRunning) { + Write-WatcherLog $(if ($targetRunning) { 'Target container is running.' } else { 'Target container is not running; waiting.' }) + $lastTargetState = $targetRunning + } + + if ($targetRunning) { + $offlineSince = $null + if (-not (Test-OwnedWorkerHealthy)) { + $connectionCheckedForRun = $false + Start-OwnedWorker | Out-Null + } + if ((Test-OwnedWorkerHealthy) -and -not $connectionCheckedForRun) { + $connectionCheckedForRun = Repair-DockerWorkerConnection -DockerCommand $dockerCommand + } + } else { + $connectionCheckedForRun = $false + if ($null -eq $offlineSince) { + $offlineSince = [DateTime]::UtcNow + } + if ([DateTime]::UtcNow.Subtract($offlineSince).TotalSeconds -ge $StopGraceSeconds) { + Stop-OwnedWorker + } + } + + if (-not $RunOnce) { + Start-Sleep -Seconds $PollSeconds + } + } while (-not $RunOnce) +} catch { + Write-WatcherLog ("Watcher failed: {0}" -f $_.Exception.Message) + throw +} finally { + if ($createdNew) { + $watcherMutex.ReleaseMutex() + } + $watcherMutex.Dispose() +} diff --git a/tests/test_ai_json_parsing.py b/tests/test_ai_json_parsing.py index 08f73ec..116ae2c 100644 --- a/tests/test_ai_json_parsing.py +++ b/tests/test_ai_json_parsing.py @@ -12,7 +12,9 @@ _replace_python_literals, _normalize_ai_clip_item, _normalize_confidence_score, + _normalize_cover_time_seconds, _normalize_spread_value, + _render_prompt, AIAnalysisError, ) @@ -113,6 +115,35 @@ def test_default_mid(self): assert _normalize_spread_value("随便") == "中" +class TestNormalizeCoverTime: + + def test_valid_cover_time_is_kept(self): + assert _normalize_cover_time_seconds(12.5, 60) == 12.5 + + @pytest.mark.parametrize("value", [None, -1, 60, 999, "not-a-number"]) + def test_missing_or_invalid_cover_time_uses_midpoint(self, value): + assert _normalize_cover_time_seconds(value, 60) == 30 + + def test_old_alias_is_supported(self): + result = _normalize_ai_clip_item( + {"start_time": "00:01:00", "end_time": "00:02:00", "cover_seconds": 18}, + index=1, + ) + assert result["cover_time_seconds"] == 18 + + def test_custom_prompt_always_gets_cover_requirement(self): + prompt = _render_prompt( + max_clip_duration=120, + target_clip_count=2, + ai_preference="完整", + transcript_text="00:00:00 - 00:00:10 测试", + prompt_template="自定义方案\n{{TRANSCRIPT_TEXT}}", + ) + assert "自定义方案" in prompt + assert "cover_time_seconds" in prompt + assert "相对于该条短视频开头" in prompt + + class TestNormalizeAiClipItem: def test_old_fields_converted(self): @@ -150,6 +181,7 @@ def test_duration_calculated_from_times(self): clip = {"start_time": "00:01:00", "end_time": "00:02:30"} result = _normalize_ai_clip_item(clip, index=1) assert result["duration_seconds"] == 90 + assert result["cover_time_seconds"] == 45 class TestLoadsAiJson: diff --git a/tests/test_auto_pipeline.py b/tests/test_auto_pipeline.py index 620a59a..76cbf57 100644 --- a/tests/test_auto_pipeline.py +++ b/tests/test_auto_pipeline.py @@ -57,6 +57,13 @@ def _fake_video(name: str = "source.mp4") -> Path: return path +def _fake_cover(name: str = "cover.jpg") -> Path: + path = settings.tasks_dir / "_test_inputs" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"fake jpg") + return path + + def _create_auto_task(task_id: str = "test-auto-task") -> dict: video = _fake_video(f"{task_id}.mp4") payload = TaskCreate( @@ -72,6 +79,16 @@ def _create_auto_task(task_id: str = "test-auto-task") -> dict: return get_task(task_id, include_video_probe=False) +def test_clip_candidates_schema_has_nullable_cover_time(): + with get_connection() as connection: + columns = { + row["name"]: dict(row) + for row in connection.execute("PRAGMA table_info(clip_candidates)").fetchall() + } + assert "cover_time_seconds" in columns + assert columns["cover_time_seconds"]["notnull"] == 0 + + def test_auto_mode_false_does_not_start_pipeline(monkeypatch): starter = Mock(return_value={"status": "started"}) monkeypatch.setattr("app.routers.tasks.start_auto_pipeline", starter) @@ -204,6 +221,27 @@ def test_schedule_generation_defaults_to_ten_minutes_then_three_hours(): assert scheduled[1].startswith("2026-06-23T11:10:00") +def test_daily_window_schedule_supports_seven_to_midnight_without_looping(): + local_zone = datetime.now().astimezone().tzinfo + start = datetime(2026, 6, 23, 21, 0, tzinfo=local_zone) + scheduled = build_schedule_times( + 3, + { + "auto_schedule_mode": "daily_window", + "auto_schedule_start_at": start.isoformat(timespec="minutes"), + "auto_schedule_interval_hours": 3, + "auto_schedule_daily_start_time": "07:00", + "auto_schedule_daily_end_time": "00:00", + }, + now=start, + ) + assert [datetime.fromisoformat(item).strftime("%Y-%m-%d %H:%M") for item in scheduled] == [ + "2026-06-23 21:00", + "2026-06-24 00:00", + "2026-06-24 07:00", + ] + + def test_create_auto_publish_job_records_scheduled_at(): task = _create_auto_task("test-auto-publish-job") clip_path = _fake_video("publish_clip.mp4") @@ -223,6 +261,11 @@ def test_create_auto_publish_job_records_scheduled_at(): scheduled_items = [ { "output_clip": {"id": "out-1", "output_file_path": str(clip_path)}, + "cover": { + "cover_file_path": str(_fake_cover("publish_clip_cover.jpg")), + "cover_time_seconds": 12.5, + "cover_source": "ai_frame", + }, "metadata": { "platform": "douyin", "title": "康熙名场面", @@ -239,12 +282,16 @@ def test_create_auto_publish_job_records_scheduled_at(): assert result["created_count"] == 1 with get_connection() as connection: row = connection.execute( - "SELECT scheduled_at, status, video_source FROM publish_jobs WHERE task_id = ?", + "SELECT scheduled_at, status, video_source, cover_mode, cover_time_seconds, cover_file_path FROM publish_jobs WHERE task_id = ?", (task["id"],), ).fetchone() assert row["scheduled_at"] == "2026-06-23T08:10:00+00:00" - assert row["status"] == "SCHEDULED" + # local_browser 需要明确账号;没有可用账号时保留计划时间,但先停在 WAITING,避免到点直接失败。 + assert row["status"] == "WAITING" assert row["video_source"] == "original" + assert row["cover_mode"] == "time" + assert row["cover_time_seconds"] == 12.5 + assert row["cover_file_path"].endswith("publish_clip_cover.jpg") def test_create_auto_publish_job_without_schedule_waits_for_send_center(): @@ -268,6 +315,11 @@ def test_create_auto_publish_job_without_schedule_waits_for_send_center(): [ { "output_clip": {"id": "out-waiting", "output_file_path": str(clip_path)}, + "cover": { + "cover_file_path": str(_fake_cover("publish_waiting_cover.jpg")), + "cover_time_seconds": 20, + "cover_source": "midpoint_fallback", + }, "metadata": { "platform": "douyin", "title": "待排期片段", @@ -291,6 +343,54 @@ def test_create_auto_publish_job_without_schedule_waits_for_send_center(): assert row["status"] == "WAITING" +def test_auto_metadata_generates_one_cover_for_both_platforms(monkeypatch): + task = _create_auto_task("test-auto-cover-once") + clip_path = _fake_video("auto_cover_once.mp4") + cover_path = _fake_cover("auto_cover_once.jpg") + output_clip = { + "id": "out-cover-once", + "task_id": task["id"], + "status": "completed", + "file_exists": True, + "output_file_path": str(clip_path), + "cover_time_seconds": 17.5, + } + cover_calls = [] + + def fake_cover(item, preferred_time_seconds=None, video_source="original"): + cover_calls.append((item["id"], preferred_time_seconds, video_source)) + return { + "cover_file_path": str(cover_path), + "cover_time_seconds": preferred_time_seconds, + "cover_source": "ai_frame", + } + + def fake_metadata(_self, _item, platform): + return { + "platform": platform, + "title": f"{platform} 标题", + "caption": "简介", + "hashtags": ["测试"], + "cover_text": "封面", + "risk_flags": [], + "source": "rule", + } + + monkeypatch.setattr("app.services.pipeline_engine.task_service.list_output_clips", lambda _task_id: [output_clip]) + monkeypatch.setattr("app.services.pipeline_engine.generate_publish_cover_for_item", fake_cover) + monkeypatch.setattr("app.services.pipeline_engine.MetadataGenerator.generate", fake_metadata) + + result = PipelineEngine()._generate_metadata( + task["id"], + {"config": {"auto_metadata_use_ai": False}}, + ) + + assert cover_calls == [("out-cover-once", 17.5, "original")] + assert len(result["metadata_items"]) == 2 + assert {item["metadata"]["platform"] for item in result["metadata_items"]} == {"douyin", "bilibili"} + assert {item["cover"]["cover_file_path"] for item in result["metadata_items"]} == {str(cover_path)} + + def test_prepare_source_uses_pathlib_and_writes_reference(): task = _create_auto_task("test-auto-windows-path") result = PipelineEngine()._prepare_source(task["id"], {"config": {}}) diff --git a/tests/test_database_backup_service.py b/tests/test_database_backup_service.py new file mode 100644 index 0000000..80f52f6 --- /dev/null +++ b/tests/test_database_backup_service.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import os +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from app.db import database as database_module +from app.services.database_backup_service import ( + BackupSafetyError, + apply_cleanup_plan, + build_cleanup_plan, + create_media_cleanup_backup, + create_publish_migration_backup, + sqlite_quick_check, +) + + +BEIJING = ZoneInfo("Asia/Shanghai") + + +def _create_database(path: Path, value: str = "ok") -> None: + connection = sqlite3.connect(path) + connection.execute("CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT)") + connection.execute("INSERT INTO sample(value) VALUES (?)", (value,)) + connection.commit() + connection.close() + + +def _set_local_time(path: Path, value: datetime) -> None: + timestamp = value.timestamp() + os.utime(path, (timestamp, timestamp)) + + +def test_cleanup_uses_earlier_valid_backup_when_newest_is_corrupt(tmp_path): + database_path = tmp_path / "workflow.sqlite3" + backup_dir = tmp_path / "backups" + backup_dir.mkdir() + _create_database(database_path) + + older = backup_dir / "workflow-before-publish-migration-older.sqlite3" + newer = backup_dir / "workflow-before-publish-migration-newer.sqlite3" + _create_database(older, "older") + newer.write_bytes(b"not a sqlite database") + _set_local_time(older, datetime(2026, 7, 28, 1, 0, tzinfo=BEIJING)) + _set_local_time(newer, datetime(2026, 7, 28, 2, 0, tzinfo=BEIJING)) + + plan = build_cleanup_plan(database_path, backup_dir) + + assert plan.keep_files == (older,) + assert newer in plan.delete_files + assert plan.invalid_files == (newer,) + + +def test_cleanup_keeps_latest_valid_backup_for_recent_14_days(tmp_path): + database_path = tmp_path / "workflow.sqlite3" + backup_dir = tmp_path / "backups" + backup_dir.mkdir() + _create_database(database_path) + unrelated = backup_dir / "manual-backup.sqlite3" + unrelated.write_text("do not touch", encoding="utf-8") + + backups = [] + start = datetime(2026, 7, 1, 12, 0, tzinfo=BEIJING) + for offset in range(15): + backup = backup_dir / ( + f"workflow-before-publish-migration-day-{offset:02d}.sqlite3" + ) + _create_database(backup, str(offset)) + _set_local_time(backup, start + timedelta(days=offset)) + backups.append(backup) + + plan = build_cleanup_plan(database_path, backup_dir, keep_days=14) + result = apply_cleanup_plan(plan, progress_every=0) + + assert len(plan.keep_files) == 14 + assert plan.delete_files == (backups[0],) + assert result.deleted_files == 1 + assert unrelated.read_text(encoding="utf-8") == "do not touch" + + +def test_repeated_backup_within_24_hours_creates_only_one_file(tmp_path): + database_path = tmp_path / "workflow.sqlite3" + backup_dir = tmp_path / "backups" + _create_database(database_path) + now = datetime(2026, 7, 28, 1, 0, tzinfo=BEIJING) + + first = create_publish_migration_backup( + database_path, + backup_dir, + now=now, + ) + second = create_publish_migration_backup( + database_path, + backup_dir, + now=now + timedelta(hours=1), + ) + + backups = list(backup_dir.glob("workflow-before-publish-migration-*.sqlite3")) + assert first is not None + assert second is None + assert backups == [first] + assert sqlite_quick_check(first) == "ok" + + +def test_media_cleanup_backup_is_always_created_and_valid(tmp_path): + database_path = tmp_path / "workflow.sqlite3" + backup_dir = tmp_path / "backups" + _create_database(database_path) + + backup = create_media_cleanup_backup(database_path, backup_dir) + + assert backup.name.startswith("workflow-before-media-cleanup-") + assert sqlite_quick_check(backup) == "ok" + + +def test_concurrent_publish_migration_creates_one_valid_backup(monkeypatch, tmp_path): + database_path = tmp_path / "workflow.sqlite3" + settings = SimpleNamespace( + database_path=database_path, + data_dir=tmp_path, + publish_default_mode="local_browser", + ) + monkeypatch.setattr(database_module, "settings", settings) + + connection = sqlite3.connect(database_path) + connection.row_factory = sqlite3.Row + connection.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, platform TEXT)") + connection.execute( + """ + CREATE TABLE publish_jobs ( + id TEXT PRIMARY KEY, + task_id TEXT, + output_clip_id TEXT, + platform TEXT, + publish_mode TEXT, + status TEXT, + provider_response TEXT, + created_at TEXT, + updated_at TEXT, + error_code TEXT, + error_message TEXT, + last_error TEXT + ) + """ + ) + database_module._migrate_publish_jobs_table(connection) + connection.commit() + connection.executemany( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, platform, publish_mode, status, + created_at, updated_at + ) VALUES (?, '', 'clip-1', 'douyin', 'local_browser', 'WAITING', ?, ?) + """, + [ + ("job-1", "2026-07-28T00:00:00+08:00", "2026-07-28T00:00:00+08:00"), + ("job-2", "2026-07-28T00:01:00+08:00", "2026-07-28T00:01:00+08:00"), + ], + ) + connection.commit() + connection.close() + + def run_migration() -> None: + worker_connection = sqlite3.connect(database_path, timeout=10) + worker_connection.row_factory = sqlite3.Row + worker_connection.execute("PRAGMA busy_timeout = 10000") + try: + database_module._migrate_publish_jobs_table(worker_connection) + worker_connection.commit() + finally: + worker_connection.close() + + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(lambda _: run_migration(), range(2))) + + backups = list( + (tmp_path / "backups").glob( + "workflow-before-publish-migration-*.sqlite3" + ) + ) + assert len(backups) == 1 + assert sqlite_quick_check(backups[0]) == "ok" + connection = sqlite3.connect(database_path) + statuses = [ + row[0] + for row in connection.execute( + "SELECT status FROM publish_jobs ORDER BY id" + ).fetchall() + ] + connection.close() + assert statuses.count("WAITING") == 1 + assert statuses.count("CANCELLED") == 1 + + +def test_failed_backup_rolls_back_publish_data_migration(monkeypatch, tmp_path): + database_path = tmp_path / "workflow.sqlite3" + settings = SimpleNamespace( + database_path=database_path, + data_dir=tmp_path, + publish_default_mode="local_browser", + ) + monkeypatch.setattr(database_module, "settings", settings) + + connection = sqlite3.connect(database_path) + connection.row_factory = sqlite3.Row + connection.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, platform TEXT)") + connection.execute( + """ + CREATE TABLE publish_jobs ( + id TEXT PRIMARY KEY, + task_id TEXT, + output_clip_id TEXT, + platform TEXT, + publish_mode TEXT, + status TEXT, + provider_response TEXT, + created_at TEXT, + updated_at TEXT, + error_code TEXT, + error_message TEXT, + last_error TEXT + ) + """ + ) + database_module._migrate_publish_jobs_table(connection) + connection.commit() + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, platform, publish_mode, status, + created_at, updated_at + ) VALUES ( + 'job-1', '', 'clip-1', 'manual_export', 'manual_export', 'WAITING', + '2026-07-28T00:00:00+08:00', '2026-07-28T00:00:00+08:00' + ) + """ + ) + connection.commit() + + def fail_backup(*_args, **_kwargs): + raise BackupSafetyError("simulated backup failure") + + monkeypatch.setattr( + database_module, + "create_publish_migration_backup", + fail_backup, + ) + + with pytest.raises(BackupSafetyError, match="simulated backup failure"): + database_module._migrate_publish_jobs_table(connection) + + row = connection.execute( + "SELECT platform, publish_mode FROM publish_jobs WHERE id = 'job-1'" + ).fetchone() + connection.close() + assert tuple(row) == ("manual_export", "manual_export") diff --git a/tests/test_local_browser_publishers.py b/tests/test_local_browser_publishers.py new file mode 100644 index 0000000..ae29bc9 --- /dev/null +++ b/tests/test_local_browser_publishers.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.services.publishers.base import PublishOutcome, PublishResult, PublishValidationError +from app.services.publishers.local_browser import LocalBrowserPublisher + + +class FakeWorker: + def __init__(self, *, login_status: str = "normal", result: PublishResult | None = None) -> None: + self.login_status = login_status + self.result = result or PublishResult( + outcome=PublishOutcome.PUBLISHED, + message="投稿成功", + remote_video_id="remote-123", + platform_url="https://example.test/video/remote-123", + published_at="2026-07-15T02:00:00+00:00", + provider_response={"status": "confirmed", "token": "should-not-leave-worker"}, + ) + self.checked: list[tuple[str, str]] = [] + self.payloads: list[dict] = [] + + def check_account(self, platform: str, account_id: str) -> dict: + self.checked.append((platform, account_id)) + return {"login_status": self.login_status, "message": "登录正常" if self.login_status == "normal" else "需要重新登录"} + + def publish(self, payload: dict) -> PublishResult: + self.payloads.append(payload) + return self.result + + +class FakeRepository: + def __init__(self) -> None: + self.accounts: list[tuple] = [] + self.results: list[tuple[str, PublishResult]] = [] + + def update_account_status(self, *args, **kwargs) -> None: + self.accounts.append((args, kwargs)) + + def record_provider_result(self, job_id: str, result: PublishResult) -> None: + self.results.append((job_id, result)) + + +def make_job(video: Path, platform: str = "douyin") -> dict: + cover = video.with_suffix(".jpg") + cover.write_bytes(b"fake cover") + job = { + "id": "job-1", + "task_id": "task-1", + "clip_id": "clip-1", + "execution_id": "exec-1", + "platform": platform, + "publish_mode": "local_browser", + "account_id": "account-1", + "video_path": str(video), + "title": "测试标题", + "caption": "测试正文", + "hashtags": "测试,视频", + "cover_file_path": str(cover), + "visibility": "private", + } + if platform == "bilibili": + job.update({"bilibili_tid": "17", "bilibili_copyright": "original"}) + return job + + +@pytest.mark.parametrize("platform", ["douyin", "bilibili"]) +def test_local_browser_checks_login_and_forwards_platform_payload(tmp_path, platform): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + worker = FakeWorker() + repository = FakeRepository() + result = LocalBrowserPublisher( + platform=platform, + worker_client=worker, + repository=repository, + ).publish(make_job(video, platform)) + + assert result.outcome == PublishOutcome.PUBLISHED + assert worker.checked == [(platform, "account-1")] + assert worker.payloads[0]["platform"] == platform + assert worker.payloads[0]["video_path"] == str(video.resolve()) + assert repository.results == [] + + +def test_login_expired_becomes_need_review_without_upload(tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + worker = FakeWorker(login_status="login_required") + repository = FakeRepository() + + result = LocalBrowserPublisher( + platform="douyin", worker_client=worker, repository=repository + ).publish(make_job(video)) + + assert result.outcome == PublishOutcome.NEED_REVIEW + assert result.error_code == "account_login_required" + assert result.needs_manual_review is True + assert worker.payloads == [] + assert repository.results == [] + + +def test_bilibili_repost_requires_source(tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + job = make_job(video, "bilibili") + job["bilibili_copyright"] = "repost" + job["bilibili_source"] = "" + + with pytest.raises(PublishValidationError) as caught: + LocalBrowserPublisher(platform="bilibili", worker_client=FakeWorker()).publish(job) + assert caught.value.error_code == "missing_bilibili_source" + + +def test_douyin_title_limit_is_validated_before_worker(tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + job = make_job(video) + job["title"] = "长" * 31 + worker = FakeWorker() + + with pytest.raises(PublishValidationError) as caught: + LocalBrowserPublisher(platform="douyin", worker_client=worker).publish(job) + assert caught.value.error_code == "douyin_title_too_long" + assert worker.checked == [] + + +def test_uncertain_worker_result_remains_need_review(tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + uncertain = PublishResult( + outcome=PublishOutcome.NEED_REVIEW, + message="点击投稿后未读取到结果", + error_code="publish_result_uncertain", + needs_manual_review=True, + ) + result = LocalBrowserPublisher( + platform="douyin", worker_client=FakeWorker(result=uncertain) + ).publish(make_job(video)) + assert result == uncertain + + +@pytest.mark.parametrize( + ("field", "value", "error_code"), + [("hashtags", "", "missing_hashtags"), ("cover_file_path", "", "missing_cover")], +) +def test_local_browser_requires_topics_and_cover(tmp_path, field, value, error_code): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + job = make_job(video) + job[field] = value + with pytest.raises(PublishValidationError) as caught: + LocalBrowserPublisher(platform="douyin", worker_client=FakeWorker()).publish(job) + assert caught.value.error_code == error_code diff --git a/tests/test_media_storage_lifecycle.py b/tests/test_media_storage_lifecycle.py new file mode 100644 index 0000000..f96f270 --- /dev/null +++ b/tests/test_media_storage_lifecycle.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.db.database import get_connection, init_db +from app.main import app +from app.models.task import TaskCreate, TaskStatus +from app.routers import tasks as tasks_router +from app.services import job_service +from app.services import task_lifecycle_service +from app.services.storage_service import ( + StorageSafetyError, + configure_runtime_media_storage, + save_uploaded_video, +) +from app.services.task_lifecycle_service import ( + TaskDeletionConflictError, + create_task_record, + delete_task_permanently, + update_task_status, +) +from scripts.purge_deleted_task_media import apply_report, build_report + + +@pytest.fixture +def isolated_media_settings(tmp_path): + original_temp = tempfile.tempdir + original_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + + project_root = tmp_path / "project" + data_dir = project_root / "data" + storage_root = tmp_path / "e-drive" / "直播间切片工作流存储" + replacements = { + "project_root": project_root, + "data_dir": data_dir, + "database_path": data_dir / "workflow.sqlite3", + "storage_root": storage_root, + "tasks_dir": storage_root, + "upload_temp_dir": storage_root / "_临时上传", + "publish_scheduler_export_dir": storage_root / "_发布包", + } + settings_objects = [] + seen_settings = set() + for module_name, module in tuple(sys.modules.items()): + if not (module_name.startswith("app.") or module_name == "scripts.purge_deleted_task_media"): + continue + candidate = getattr(module, "settings", None) + if candidate is None or not hasattr(candidate, "database_path") or id(candidate) in seen_settings: + continue + seen_settings.add(id(candidate)) + settings_objects.append(candidate) + + original_values = { + id(candidate): { + name: getattr(candidate, name) + for name in replacements + } + for candidate in settings_objects + } + for candidate in settings_objects: + for name, value in replacements.items(): + object.__setattr__(candidate, name, value) + init_db() + + try: + yield replacements + finally: + tempfile.tempdir = original_temp + for name, value in original_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + for candidate in settings_objects: + for name, value in original_values[id(candidate)].items(): + object.__setattr__(candidate, name, value) + + +def _create_managed_task(task_id: str, task_dir: Path, *, auto_mode: bool = False) -> Path: + source_path = task_dir / "source" / "source.mp4" + source_path.parent.mkdir(parents=True, exist_ok=True) + source_path.write_bytes(b"managed-video") + create_task_record( + TaskCreate( + task_name=task_id, + source_type="upload", + original_video_path=str(source_path), + auto_mode=auto_mode, + ), + task_id=task_id, + task_dir_name=task_id, + ) + return source_path + + +def _headers() -> dict[str, str]: + if not settings.local_admin_token: + return {} + return {"Authorization": f"Bearer {settings.local_admin_token}"} + + +def test_runtime_media_storage_uses_configured_e_drive(isolated_media_settings): + result = configure_runtime_media_storage() + + assert Path(result["tasks_dir"]) == settings.tasks_dir.resolve() + assert Path(result["upload_temp_dir"]) == settings.upload_temp_dir.resolve() + assert Path(result["publish_export_dir"]) == settings.publish_scheduler_export_dir.resolve() + assert tempfile.tempdir == str(settings.upload_temp_dir.resolve()) + assert os.environ["TEMP"] == str(settings.upload_temp_dir.resolve()) + assert os.environ["TMP"] == str(settings.upload_temp_dir.resolve()) + + +def test_large_multipart_upload_spools_in_e_drive(monkeypatch, isolated_media_settings): + captured = {} + previous_temp = tempfile.tempdir + previous_temp_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + + def capture_upload(task_id, filename, file_object, task_dir_name=None): + captured["rolled"] = bool(getattr(file_object, "_rolled", False)) + captured["tempdir"] = tempfile.tempdir + return save_uploaded_video(task_id, filename, file_object, task_dir_name) + + monkeypatch.setattr(tasks_router, "save_uploaded_video", capture_upload) + with TestClient(app) as client: + response = client.post( + "/api/tasks/upload", + data={"task_name": "large-upload-e-drive", "platform": "general"}, + files={"video_file": ("source.mp4", b"V" * (2 * 1024 * 1024), "video/mp4")}, + headers=_headers(), + ) + + assert response.status_code == 200 + assert captured["rolled"] is True + assert captured["tempdir"] == str(settings.upload_temp_dir.resolve()) + assert tempfile.tempdir == previous_temp + assert {name: os.environ.get(name) for name in ("TEMP", "TMP")} == previous_temp_env + with get_connection() as connection: + row = connection.execute( + "SELECT original_video_path FROM tasks WHERE id = ?", + (response.json()["id"],), + ).fetchone() + assert Path(row["original_video_path"]).is_relative_to(settings.tasks_dir) + + +def test_failed_upload_removes_partial_task_directory(monkeypatch, isolated_media_settings): + original_limit = settings.max_upload_size_bytes + object.__setattr__(settings, "max_upload_size_bytes", 1024) + monkeypatch.setattr( + tasks_router, + "allocate_task_dir_name", + lambda task_name, exclude_task_id=None: "failed-upload-directory", + ) + try: + response = TestClient(app).post( + "/api/tasks/upload", + data={"task_name": "failed-upload", "platform": "general"}, + files={"video_file": ("source.mp4", b"V" * 2048, "video/mp4")}, + headers=_headers(), + ) + finally: + object.__setattr__(settings, "max_upload_size_bytes", original_limit) + + assert response.status_code == 400 + assert not (settings.tasks_dir / "failed-upload-directory").exists() + + +def test_delete_removes_managed_media_and_keeps_database_history(isolated_media_settings): + task_id = "media-delete-001" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + clip_path = task_dir / "05_clips" / "clip.mp4" + clip_path.parent.mkdir(parents=True, exist_ok=True) + clip_path.write_bytes(b"clip-video") + export_path = settings.publish_scheduler_export_dir / task_id / "clip-1" / "clip.mp4" + export_path.parent.mkdir(parents=True, exist_ok=True) + export_path.write_bytes(b"export-video") + job_service.create_job(task_id, job_service.JOB_TYPE_VIDEO_CUT) + + result = delete_task_permanently(task_id) + + assert result["status"] == "deleted" + assert result["freed_bytes"] >= len(b"managed-videoclip-videoexport-video") + assert not task_dir.exists() + assert not (settings.publish_scheduler_export_dir / task_id).exists() + with get_connection() as connection: + task = connection.execute("SELECT is_deleted FROM tasks WHERE id = ?", (task_id,)).fetchone() + job = connection.execute( + "SELECT status FROM workflow_jobs WHERE task_id = ?", + (task_id,), + ).fetchone() + assert task["is_deleted"] == 1 + assert job["status"] == "cancelled" + + repeated = delete_task_permanently(task_id) + assert repeated["status"] == "already_deleted" + assert repeated["freed_bytes"] == 0 + + +def test_delete_preserves_external_source(isolated_media_settings): + task_id = "media-delete-external" + external_source = settings.storage_root / "共享原片" / "source.mp4" + external_source.parent.mkdir(parents=True, exist_ok=True) + external_source.write_bytes(b"unique-original") + create_task_record( + TaskCreate( + task_name=task_id, + source_type="nas", + nas_file_path=str(external_source), + ), + task_id=task_id, + task_dir_name=task_id, + ) + + result = delete_task_permanently(task_id) + + assert result["external_source_preserved"] is True + assert external_source.read_bytes() == b"unique-original" + assert not (settings.tasks_dir / task_id).exists() + + +def test_delete_rejects_running_task(isolated_media_settings): + task_id = "media-delete-running" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + update_task_status(task_id, TaskStatus.transcribing) + + with pytest.raises(TaskDeletionConflictError, match="正在处理"): + delete_task_permanently(task_id) + + assert task_dir.exists() + with get_connection() as connection: + row = connection.execute("SELECT is_deleted FROM tasks WHERE id = ?", (task_id,)).fetchone() + assert row["is_deleted"] == 0 + + +def test_delete_rejects_running_workflow_job(isolated_media_settings): + task_id = "media-delete-job" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + job = job_service.create_job(task_id, job_service.JOB_TYPE_VIDEO_CUT) + job_service.mark_job_running(job["id"]) + + with pytest.raises(TaskDeletionConflictError, match="后台切片"): + delete_task_permanently(task_id) + + assert task_dir.exists() + + +def test_delete_failure_keeps_task_visible(monkeypatch, isolated_media_settings): + task_id = "media-delete-failure" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + + def fail_cleanup(_plan): + raise RuntimeError("模拟文件被占用") + + monkeypatch.setattr(task_lifecycle_service, "apply_task_media_cleanup_plan", fail_cleanup) + with pytest.raises(RuntimeError, match="文件被占用"): + delete_task_permanently(task_id) + + assert task_dir.exists() + with get_connection() as connection: + row = connection.execute("SELECT is_deleted FROM tasks WHERE id = ?", (task_id,)).fetchone() + assert row["is_deleted"] == 0 + + +def test_delete_rejects_path_traversal(isolated_media_settings): + now = "2026-08-02T00:00:00+00:00" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks (id, task_name, task_dir_name, status, created_at, updated_at) + VALUES ('unsafe-task', 'unsafe-task', '..\\outside', 'completed', ?, ?) + """, + (now, now), + ) + connection.commit() + + with pytest.raises(StorageSafetyError, match="不安全路径"): + delete_task_permanently("unsafe-task") + + +def test_cleanup_report_dry_run_then_apply_only_deletes_hidden_tasks(isolated_media_settings): + hidden_id = "media-hidden-001" + active_id = "media-active-001" + hidden_dir = settings.tasks_dir / hidden_id + active_dir = settings.tasks_dir / active_id + _create_managed_task(hidden_id, hidden_dir) + _create_managed_task(active_id, active_dir) + with get_connection() as connection: + connection.execute( + "UPDATE tasks SET is_deleted = 1, deleted_at = updated_at WHERE id = ?", + (hidden_id,), + ) + connection.commit() + + report = build_report() + + assert report["mode"] == "dry-run" + reported_ids = {item["task_id"] for item in report["items"]} + assert hidden_id in reported_ids + assert active_id not in reported_ids + assert hidden_dir.exists() + assert active_dir.exists() + + applied = apply_report(report) + + assert applied["mode"] == "apply" + assert applied["released_bytes"] > 0 + assert Path(applied["backup_path"]).exists() + assert not hidden_dir.exists() + assert active_dir.exists() + assert applied["active_task_count_verified"] >= 1 + + +def test_cleanup_aborts_before_deleting_overlapping_active_directory(isolated_media_settings): + hidden_id = "media-hidden-overlap" + active_id = "media-active-overlap" + hidden_dir = settings.tasks_dir / hidden_id + active_dir = settings.tasks_dir / active_id + _create_managed_task(hidden_id, hidden_dir) + _create_managed_task(active_id, active_dir) + with get_connection() as connection: + connection.execute( + """ + UPDATE tasks + SET is_deleted = 1, deleted_at = updated_at, task_dir_name = ? + WHERE id = ? + """, + (active_id, hidden_id), + ) + connection.commit() + + report = build_report() + with pytest.raises(RuntimeError, match="有效任务目录重叠"): + apply_report(report) + + assert active_dir.exists() diff --git a/tests/test_page_script_publishers.py b/tests/test_page_script_publishers.py new file mode 100644 index 0000000..3ff71f9 --- /dev/null +++ b/tests/test_page_script_publishers.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from app.services.publishers import page_scripts +from app.services.publishers.base import PublishError, PublishNeedsReview, PublishOutcome +from app.services.publishers.bilibili import BilibiliPublisher +from app.services.publishers.browser_runtime import BrowserRuntime +from app.services.publishers.douyin import DouyinPublisher + + +class FakeUpload: + def __init__(self) -> None: + self.files: list[str] = [] + + def set_input_files(self, value: str) -> None: + self.files.append(value) + + +class FakePage: + url = "https://creator.example.test/upload" + + +class FakeRuntime: + def __init__(self, platform: str, *, confirmed: bool = True) -> None: + self.platform = platform + self.confirmed = confirmed + self.upload = FakeUpload() + self.phases: list[str] = [] + self.scripts: list[str] = [] + + @contextmanager + def page(self, _url: str): + yield FakePage() + + def phase(self, phase: str, _details=None) -> None: + self.phases.append(phase) + + def body_text(self, _page) -> str: + return "上传视频 基础设置" + + def detect_manual_challenge(self, _page) -> None: + return None + + def first_visible(self, _page, _selectors, timeout_ms=1500): + del timeout_ms + return self.upload + + def wait_for_text(self, _page, _patterns, timeout_seconds: int) -> str: + del timeout_seconds + return "视频上传成功" + + def wait_for_script_state( + self, + _page, + script, + *, + phase, + ready_key, + timeout_seconds, + timeout_error_code, + timeout_message, + stable_polls=1, + interval_seconds=1.0, + ): + del script, timeout_seconds, timeout_error_code, timeout_message, interval_seconds + self.phases.append(phase) + return {ready_key: True, "state": "ready", "stable_polls": stable_polls} + + def evaluate_script(self, _page, script: str, *, phase: str, default_error_code="platform_form_changed"): + del default_error_code + self.phases.append(phase) + self.scripts.append(script) + if "return {publish_confirmed:true" in script: + return { + "publish_confirmed": self.confirmed, + "success_text": "发布成功" if self.confirmed else "", + "url": "https://creator.douyin.com/creator-micro/content/manage", + } + if "return {bilibili_publish_confirmed:true" in script: + return { + "bilibili_publish_confirmed": self.confirmed, + "success_text": "投稿成功" if self.confirmed else "", + "url": "https://member.bilibili.com/platform/upload-manager/article", + } + if "return {clicked:true" in script: + return {"clicked": True, "text": "发布"} + if "visibility_verified:true" in script: + return {"visibility_verified": True, "visibility_text": "仅自己可见"} + return {"ok": True} + + def click_first(self, _page, _selectors, *, required=True) -> bool: + del required + return False + + def extract_link(self, _page, _patterns) -> str: + return "" + + def extract_remote_id(self, url: str) -> str: + return BrowserRuntime.extract_remote_id(url) + + def screenshot(self, _page, _name: str) -> str: + return "" + + def hold_for_manual_review(self, _page, _message, _error_code, **_kwargs) -> None: + self.phases.append("manual_review_waiting") + + +def make_job(tmp_path: Path, platform: str) -> dict: + video = tmp_path / f"{platform}.mp4" + cover = tmp_path / f"{platform}.jpg" + video.write_bytes(b"video") + cover.write_bytes(b"cover") + job = { + "id": f"job-{platform}", + "platform": platform, + "account_id": f"account-{platform}", + "video_path": str(video), + "title": "测试标题", + "description": "测试正文", + "caption": "测试正文", + "tags": "测试,视频", + "hashtags": "测试,视频", + "cover_file_path": str(cover), + "visibility": "private", + } + if platform == "bilibili": + job.update({"bilibili_tid": "娱乐", "bilibili_copyright": "original"}) + return job + + +@pytest.mark.parametrize( + ("platform", "publisher_class", "required_phases"), + [ + ( + "douyin", + DouyinPublisher, + { + "upload_waiting", "upload_completed", "description_filled", + "recommended_cover_verified", "form_verified_before_submit", + "visibility_verified", "submit_clicked", "publish_result_checked", + }, + ), + ( + "bilibili", + BilibiliPublisher, + {"local_draft_prompt_checked", "recommended_cover_selected", "declaration_selected", "publish_result_checked"}, + ), + ], +) +def test_playwright_publishers_reuse_robust_page_scripts(tmp_path, platform, publisher_class, required_phases): + runtime = FakeRuntime(platform) + result = publisher_class(runtime=runtime).publish(make_job(tmp_path, platform)) + assert result.outcome == PublishOutcome.PUBLISHED + assert required_phases.issubset(runtime.phases) + assert runtime.upload.files + assert result.provider_response["confirmation"]["success_text"] in {"发布成功", "投稿成功"} + if platform == "douyin": + ordered = [ + "upload_waiting", "upload_completed", "title_filled", "description_filled", + "recommended_cover_verified", "visibility_verified", "submit_clicked", + "publish_result_checked", + ] + assert [runtime.phases.index(phase) for phase in ordered] == sorted( + runtime.phases.index(phase) for phase in ordered + ) + + +@pytest.mark.parametrize(("platform", "publisher_class"), [("douyin", DouyinPublisher), ("bilibili", BilibiliPublisher)]) +def test_no_success_evidence_never_becomes_published(tmp_path, platform, publisher_class): + runtime = FakeRuntime(platform, confirmed=False) + with pytest.raises(PublishNeedsReview) as caught: + publisher_class(runtime=runtime).publish(make_job(tmp_path, platform)) + assert caught.value.error_code == "publish_result_uncertain" + + +def test_shared_scripts_keep_key_form_cover_and_result_markers(): + douyin_upload = page_scripts.douyin_upload_state() + douyin_description = page_scripts.douyin_set_description("正文\n#话题") + douyin_cover = page_scripts.douyin_verify_cover() + douyin_visibility = page_scripts.douyin_set_visibility("private") + douyin_result = page_scripts.douyin_wait_result("标题") + bilibili_description = page_scripts.bilibili_set_description("简介") + bilibili_ready = page_scripts.bilibili_verify_ready("标题", "简介") + bilibili_result = page_scripts.bilibili_wait_result("标题") + assert "文件解析中" in douyin_upload + assert "progress<100" in douyin_upload + assert "preview_count" in douyin_upload + assert "douyin_video_upload_failed" in douyin_upload + assert "douyin_description_editor_not_found" in douyin_description + assert "douyin_cover_not_applied" in douyin_cover + assert "douyin_visibility_not_applied" in douyin_visibility + assert "仅自己可见" in douyin_visibility + assert "douyin_publish_not_confirmed" in douyin_result + assert "bilibili_description_field_not_found" in bilibili_description + assert "bilibili_default_tags_kept:true" in bilibili_ready + assert "bilibili_publish_not_confirmed" in bilibili_result + + +def test_douyin_worker_payload_uses_caption_and_hashtags(): + content = page_scripts.douyin_description( + {"caption": "这是完整正文", "hashtags": "话题一, 话题二"}, + "备用标题", + ) + + assert content == "这是完整正文\n#话题一 #话题二" + + +def test_script_platform_block_is_manual_review(): + class BlockedPage: + def evaluate(self, _script): + raise RuntimeError("Error: douyin_publish_blocked:验证码") + + runtime = BrowserRuntime("douyin", "account-test") + with pytest.raises(PublishNeedsReview) as caught: + runtime.evaluate_script(BlockedPage(), "ignored", phase="publish_result_checked") + assert caught.value.error_code == "douyin_publish_blocked" + + +def test_upload_wait_requires_two_stable_ready_polls(monkeypatch): + class SequencePage: + def __init__(self): + self.results = iter([ + {"state": "processing", "upload_ready": False, "progress": 0}, + {"state": "waiting_preview", "upload_ready": False, "progress": 100}, + {"state": "ready", "upload_ready": True, "progress": 100, "preview_count": 1}, + {"state": "ready", "upload_ready": True, "progress": 100, "preview_count": 1}, + ]) + + def evaluate(self, _script): + return next(self.results) + + def locator(self, _selector): + raise RuntimeError("no body fixture") + + monkeypatch.setattr("app.services.publishers.browser_runtime.time.sleep", lambda _seconds: None) + phases = [] + runtime = BrowserRuntime("douyin", "account-test", phase_callback=lambda phase, details=None: phases.append((phase, details))) + result = runtime.wait_for_script_state( + SequencePage(), + "upload-state", + phase="upload_waiting", + ready_key="upload_ready", + timeout_seconds=5, + timeout_error_code="video_upload_timeout", + timeout_message="上传超时", + stable_polls=2, + ) + assert result["stable_polls"] == 2 + assert result["preview_count"] == 1 + assert any(details and details.get("progress") == 0 for _, details in phases) + assert any(details and details.get("progress") == 100 and not details.get("upload_ready") for _, details in phases) + + +def test_upload_wait_stops_on_explicit_platform_failure(monkeypatch): + class FailedPage: + def evaluate(self, _script): + return { + "state": "failed", + "upload_ready": False, + "error_code": "douyin_video_upload_failed", + "message": "视频处理失败", + } + + def locator(self, _selector): + raise RuntimeError("no body fixture") + + monkeypatch.setattr("app.services.publishers.browser_runtime.time.sleep", lambda _seconds: None) + runtime = BrowserRuntime("douyin", "account-test") + with pytest.raises(PublishError) as caught: + runtime.wait_for_script_state( + FailedPage(), "upload-state", phase="upload_waiting", ready_key="upload_ready", + timeout_seconds=5, timeout_error_code="video_upload_timeout", timeout_message="上传超时", + ) + assert caught.value.error_code == "douyin_video_upload_failed" + + +@pytest.mark.parametrize( + ("visibility", "label"), + [("public", "公开"), ("friends", "好友可见"), ("private", "仅自己可见")], +) +def test_visibility_scripts_require_verified_selected_state(visibility, label): + script = page_scripts.douyin_set_visibility(visibility) + assert label in script + assert "visibility_verified:true" in script + assert "douyin_visibility_option_not_found" in script + assert "douyin_visibility_not_applied" in script + + +def test_douyin_page_scripts_against_real_chrome_dom_fixtures(): + playwright_module = pytest.importorskip("playwright.sync_api") + playwright = playwright_module.sync_playwright().start() + try: + try: + browser = playwright.chromium.launch(channel="chrome", headless=True) + except Exception as exc: # pragma: no cover - 仅无 Chrome 的 CI 跳过 + pytest.skip(f"系统 Chrome 不可用:{exc}") + page = browser.new_page(viewport={"width": 1280, "height": 900}) + try: + page.set_content("
文件解析中,请稍等
0%
") + processing = page.evaluate(page_scripts.douyin_upload_state()) + assert processing["state"] == "processing" + assert processing["upload_ready"] is False + assert processing["progress"] == 0 + + page.set_content("
100%
") + no_preview = page.evaluate(page_scripts.douyin_upload_state()) + assert no_preview["state"] == "waiting_preview" + assert no_preview["upload_ready"] is False + + page.set_content("") + ready = page.evaluate(page_scripts.douyin_upload_state()) + assert ready["state"] == "ready" + assert ready["upload_ready"] is True + assert ready["preview_count"] == 1 + + page.set_content( + "" + "
点击发布后,如作品还在上传中,请勿关闭页面、等待上传发布完成。
" + ) + explanatory_text = page.evaluate(page_scripts.douyin_upload_state()) + assert explanatory_text["upload_ready"] is True + assert explanatory_text["busy_marker"] == "" + + page.set_content( + "" + "
作品描述
测试正文 #测试话题
" + "" + "
点击发布后,如作品还在上传中,请勿关闭页面、等待上传发布完成。
" + ) + publish_ready = page.evaluate( + page_scripts.douyin_verify_ready("测试标题", "测试正文\n#测试话题") + ) + assert publish_ready["publish_ready"] is True + assert publish_ready["preview_checked"] is True + + page.set_content("
视频处理失败
") + failed = page.evaluate(page_scripts.douyin_upload_state()) + assert failed["error_code"] == "douyin_video_upload_failed" + + page.set_content( + """ +
谁可以看 + + + +
+ """ + ) + visibility = page.evaluate(page_scripts.douyin_set_visibility("private")) + assert visibility["visibility_verified"] is True + assert visibility["visibility_text"] == "仅自己可见" + assert page.locator('input[type="radio"]').nth(2).is_checked() + finally: + browser.close() + finally: + playwright.stop() diff --git a/tests/test_publish_account_status.py b/tests/test_publish_account_status.py new file mode 100644 index 0000000..c2c8989 --- /dev/null +++ b/tests/test_publish_account_status.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.services import publish_service +from app.services.publish_repository import PublishRepository +from app.services.publishers.worker_client import PublishWorkerClient +from scripts import publish_host_worker + + +def test_start_login_immediately_marks_account_pending(monkeypatch): + state = { + "id": "account-pending", + "platform": "douyin", + "login_status": "normal", + "last_login_at": "2026-07-18T01:00:00Z", + } + + monkeypatch.setattr(publish_service, "get_account", lambda _account_id: dict(state)) + monkeypatch.setattr( + PublishWorkerClient, + "start_login", + lambda _self, _platform, _account_id: {"status": "started", "message": "登录窗口已打开"}, + ) + + def update(_self, _account_id, status, message, **_kwargs): + state.update({"login_status": status, "login_message": message}) + + monkeypatch.setattr(PublishRepository, "update_account_status", update) + result = publish_service.start_browser_account_login("account-pending") + assert result["account"]["login_status"] == "login_pending" + assert result["message"] == "登录窗口已打开" + + +def test_busy_account_check_is_not_mislabeled_as_expired(monkeypatch): + state = { + "id": "account-busy", + "platform": "douyin", + "login_status": "normal", + "last_login_at": "2026-07-18T01:00:00Z", + } + monkeypatch.setattr(publish_service, "get_account", lambda _account_id: dict(state)) + monkeypatch.setattr( + PublishWorkerClient, + "check_account", + lambda _self, _platform, _account_id: {"login_status": "busy", "message": "账号正在操作"}, + ) + + def update(_self, _account_id, status, message, **_kwargs): + state.update({"login_status": status, "login_message": message}) + + monkeypatch.setattr(PublishRepository, "update_account_status", update) + result = publish_service.check_browser_account("account-busy") + assert result["account"]["login_status"] == "busy" + + +def test_worker_login_background_never_writes_sqlite(monkeypatch): + class FakePublisher: + def open_login(self, _account_id): + return {"login_status": "normal", "message": "登录成功"} + + publisher = FakePublisher() + monkeypatch.setattr(publish_host_worker, "get_platform_publisher", lambda *_args, **_kwargs: publisher) + monkeypatch.setattr( + PublishRepository, + "update_account_status", + lambda *_args, **_kwargs: pytest.fail("Windows Worker 不得直接写 SQLite"), + ) + client = TestClient(publish_host_worker.create_worker_app(token="test-token")) + headers = {"Authorization": "Bearer test-token"} + + response = client.post( + "/v1/accounts/login", + headers=headers, + json={"platform": "douyin", "account_id": "account-background"}, + ) + assert response.status_code == 202 + + +def test_worker_rejects_second_window_for_busy_account(): + lock = publish_host_worker._account_lock("douyin", "account-locked") + assert lock.acquire(blocking=False) + try: + response = TestClient(publish_host_worker.create_worker_app(token="test-token")).post( + "/v1/accounts/login", + headers={"Authorization": "Bearer test-token"}, + json={"platform": "douyin", "account_id": "account-locked"}, + ) + assert response.status_code == 409 + assert "正在运行" in response.json()["detail"] + finally: + lock.release() diff --git a/tests/test_publish_api_flow.py b/tests/test_publish_api_flow.py new file mode 100644 index 0000000..c193ecd --- /dev/null +++ b/tests/test_publish_api_flow.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.main import app +from app.routers import publish as publish_router +from app.services.publish_readiness import PublishPlatformIsolationBlocked, SendReadinessBlocked + + +def _headers() -> dict[str, str]: + if settings.local_admin_token: + return {"Authorization": f"Bearer {settings.local_admin_token}"} + return {} + + +def test_publish_now_api_only_schedules_then_wakes_same_scheduler(monkeypatch): + events: list[tuple[str, str]] = [] + + class FakeScheduler: + def publish_now(self, job_id: str) -> dict: + events.append(("publish_now", job_id)) + return {"status": "scheduled", "job_id": job_id, "scheduled_at": "2026-07-15T02:00:00+00:00"} + + def run_once(self) -> dict: + events.append(("run_once", "")) + return {"status": "ok"} + + monkeypatch.setattr(publish_router, "PublishScheduler", FakeScheduler) + response = TestClient(app).post("/api/publish/jobs/job-1/publish-now", headers=_headers()) + assert response.status_code == 200 + assert response.json()["status"] == "scheduled" + assert events == [("publish_now", "job-1"), ("run_once", "")] + + +def test_past_single_schedule_is_rejected_by_api(): + response = TestClient(app).patch( + "/api/publish/jobs/not-present/schedule", + headers=_headers(), + json={"scheduled_at": "2000-01-01T09:00", "timezone": "Asia/Shanghai"}, + ) + assert response.status_code == 400 + assert "晚于当前时间" in response.json()["detail"] + + +def test_publish_now_returns_structured_readiness_block(monkeypatch): + readiness = { + "ready": False, + "dispatch_ready": False, + "message": "账号尚未登录", + "action": "login_account", + "issues": [{"code": "account_login_required", "action": "login_account"}], + } + + class BlockedScheduler: + def publish_now(self, job_id: str) -> dict: + raise SendReadinessBlocked(readiness) + + monkeypatch.setattr(publish_router, "PublishScheduler", BlockedScheduler) + response = TestClient(app).post("/api/publish/jobs/job-1/publish-now", headers=_headers()) + assert response.status_code == 409 + assert response.json()["detail"] == readiness + + +def test_retry_now_api_runs_preflight_and_wakes_scheduler(monkeypatch): + events: list[tuple[str, str]] = [] + + class FakeScheduler: + def retry_failed(self, job_id: str, _scheduled_at=None, *, visibility=None) -> dict: + events.append(("retry_failed", f"{job_id}:{visibility}")) + return {"status": "scheduled", "job_id": "replacement-job", "retry_of_job_id": job_id} + + def run_once(self) -> dict: + events.append(("run_once", "")) + return {"status": "ok"} + + monkeypatch.setattr(publish_router, "PublishScheduler", FakeScheduler) + response = TestClient(app).post( + "/api/publish/jobs/failed-job/retry", + headers=_headers(), + json={"visibility": "private"}, + ) + assert response.status_code == 200 + assert response.json()["job_id"] == "replacement-job" + assert events == [("retry_failed", "failed-job:private"), ("run_once", "")] + + +def test_retry_now_returns_structured_readiness_block(monkeypatch): + readiness = { + "ready": False, + "dispatch_ready": False, + "message": "Windows 发布 Worker 未连接", + "action": "start_worker", + "issues": [{"code": "publish_worker_unavailable", "action": "start_worker"}], + } + + class BlockedScheduler: + def retry_failed(self, _job_id: str, _scheduled_at=None, *, visibility=None) -> dict: + raise SendReadinessBlocked(readiness) + + monkeypatch.setattr(publish_router, "PublishScheduler", BlockedScheduler) + response = TestClient(app).post( + "/api/publish/jobs/failed-job/retry", + headers=_headers(), + json={"visibility": "public"}, + ) + assert response.status_code == 409 + assert response.json()["detail"] == readiness + + +def test_review_cannot_be_marked_published_without_platform_evidence(): + response = TestClient(app).post( + "/api/publish/jobs/not-present/mark-published", + headers=_headers(), + json={"platform_url": ""}, + ) + assert response.status_code in {400, 422} + + +def test_mixed_platform_target_batch_returns_conflict(monkeypatch): + def blocked(_payload): + raise PublishPlatformIsolationBlocked("抖音和 B站任务不能混合操作") + + monkeypatch.setattr(publish_router.publish_service, "update_publish_jobs_target_batch", blocked) + response = TestClient(app).patch( + "/api/publish/jobs/target-batch", + headers=_headers(), + json={ + "job_ids": ["douyin-job", "bilibili-job"], + "platform": "douyin", + "account_id": "account-1", + "publish_mode": "local_browser", + }, + ) + assert response.status_code == 409 + assert "不能混合" in response.json()["detail"] + + +def test_backfill_covers_api_returns_batch_result(monkeypatch): + expected = { + "status": "ok", + "message": "已补齐", + "generated_cover_count": 1, + "reused_cover_count": 0, + "updated_job_count": 2, + "failed_clip_count": 0, + "errors": [], + "jobs": [], + } + requested_platforms = [] + monkeypatch.setattr( + publish_router.publish_service, + "backfill_missing_publish_covers", + lambda platform=None: requested_platforms.append(platform) or expected, + ) + response = TestClient(app).post("/api/publish/covers/backfill?platform=douyin", headers=_headers()) + assert response.status_code == 200 + assert response.json() == expected + assert requested_platforms == ["douyin"] diff --git a/tests/test_publish_center_browser.py b/tests/test_publish_center_browser.py new file mode 100644 index 0000000..eabcd52 --- /dev/null +++ b/tests/test_publish_center_browser.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import json +import socket +import threading +import time +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 +from zoneinfo import ZoneInfo + +import pytest +import uvicorn + +playwright = pytest.importorskip("playwright.sync_api") + +from app.core.config import settings # noqa: E402 +from app.db.database import get_connection, init_db # noqa: E402 +from app.main import app # noqa: E402 +from app.services import publish_service # noqa: E402 +from app.services.publish_scheduler import PublishScheduler # noqa: E402 + + +PREFIX = "test-browser-publish-" + + +def _seed_job(tmp_path: Path, index: int, platform: str = "douyin") -> str: + now = (datetime.now(timezone.utc) + timedelta(seconds=index)).isoformat(timespec="seconds").replace("+00:00", "Z") + task_id = f"{PREFIX}{uuid4().hex[:8]}" + clip_id = f"{PREFIX}clip-{uuid4().hex[:8]}" + job_id = f"{PREFIX}job-{uuid4().hex[:8]}" + video = tmp_path / f"browser-{index}.mp4" + video.write_bytes(b"fake-video") + with get_connection() as connection: + connection.execute( + "INSERT INTO tasks (id, task_name, task_dir_name, platform, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'COMPLETED', ?, ?)", + (task_id, f"浏览器排期任务 {index}", task_id, platform, now, now), + ) + connection.execute( + "INSERT INTO output_clip (id, task_id, output_file_path, output_file_name, status, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'completed', 1, ?, ?)", + (clip_id, task_id, str(video), video.name, now, now), + ) + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, scheduled_at, schedule_timezone, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'manual_export', 'original', ?, ?, ?, '测试正文', + '测试正文', '测试', '测试', '', 'Asia/Shanghai', 'WAITING', ?, ?) + """, + (job_id, task_id, clip_id, clip_id, platform, str(video), str(video), f"浏览器测试片段 {index}", now, now), + ) + connection.commit() + return job_id + + +def _cleanup(): + with get_connection() as connection: + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_publish_center_schedule_preview_confirm_and_export(monkeypatch, tmp_path): + init_db() + _cleanup() + douyin_jobs = [_seed_job(tmp_path, index) for index in range(1, 11)] + first = douyin_jobs[0] + newest = douyin_jobs[-1] + bilibili = _seed_job(tmp_path, 11, "bilibili") + failed = _seed_job(tmp_path, 12) + failed_schedule = datetime.now(timezone.utc).isoformat(timespec="seconds") + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs + SET status = 'FAILED', scheduled_at = ?, finished_at = ?, + error_code = 'browser_test_failed', error_message = '测试失败记录' + WHERE id = ? + """, + (failed_schedule, failed_schedule, failed), + ) + connection.commit() + generated_cover = tmp_path / "browser-batch-cover.jpg" + generated_cover.write_bytes(b"fake-cover") + + def fake_backfill_covers(platform=None): + with get_connection() as connection: + rows = connection.execute( + "SELECT id FROM publish_jobs WHERE task_id LIKE ? AND status = 'WAITING' AND platform = ?", + (f"{PREFIX}%", platform), + ).fetchall() + connection.execute( + """ + UPDATE publish_jobs + SET cover_mode = 'time', cover_time_seconds = 30, cover_file_path = ? + WHERE task_id LIKE ? AND status = 'WAITING' AND platform = ? + """, + (str(generated_cover), f"{PREFIX}%", platform), + ) + connection.commit() + jobs = [publish_service.get_publish_job(row["id"]) for row in rows] + return { + "status": "ok", + "message": f"已补齐 {len(jobs)} 条发布任务。", + "generated_cover_count": len(jobs), + "reused_cover_count": 0, + "updated_job_count": len(jobs), + "failed_clip_count": 0, + "errors": [], + "jobs": jobs, + } + + monkeypatch.setattr(publish_service, "backfill_missing_publish_covers", fake_backfill_covers) + future_start = datetime.now() + timedelta(days=2) + future_day = future_start.strftime("%Y-%m-%d") + following_day = (future_start + timedelta(days=1)).strftime("%Y-%m-%d") + port = _free_port() + server = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="off") + ) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.time() + 10 + while not server.started and time.time() < deadline: + time.sleep(0.05) + assert server.started + + try: + with playwright.sync_playwright() as runtime: + chrome_path = Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "Google/Chrome/Application/chrome.exe" + if not chrome_path.exists(): + pytest.skip("浏览器级测试需要本机安装 Google Chrome") + browser = runtime.chromium.launch(headless=True, executable_path=str(chrome_path)) + context = browser.new_context(timezone_id="Asia/Shanghai") + page = context.new_page() + page.route( + "**/api/publish/schedules/next-start", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "status": "ok", + "timezone": "Asia/Shanghai", + "latest_scheduled_at_local_display": f"{future_day} 19:00", + "next_start_at_local": f"{future_day}T22:00", + "next_start_at_local_display": f"{future_day} 22:00", + }, + ensure_ascii=False, + ), + ), + ) + page.goto(f"http://127.0.0.1:{port}/publish", wait_until="networkidle") + + assert page.locator( + f'[data-publish-row][data-section="content"][data-job-id="{bilibili}"]' + ).is_hidden() + first_content = page.locator( + f'[data-publish-row][data-section="content"][data-job-id="{first}"]' + ) + newest_content = page.locator( + f'[data-publish-row][data-section="content"][data-job-id="{newest}"]' + ) + cover_button = page.locator("[data-backfill-covers]") + assert "抖音" in cover_button.inner_text() + assert "10" in cover_button.inner_text() + unsaved_title = newest_content.locator('[name="title"]') + unsaved_title.fill("这段标题还没有保存") + cover_button.click() + page.locator("#send-center-message").filter(has_text="已补齐 10 条").wait_for() + assert unsaved_title.input_value() == "这段标题还没有保存" + assert newest_content.locator('[name="cover_file_path"]').input_value() == str(generated_cover) + assert newest_content.locator("[data-cover-preview]").is_visible() + assert cover_button.is_disabled() + assert first_content.is_hidden() + assert newest_content.is_visible() + first_group = first_content.locator("xpath=ancestor::section[@data-publish-task-group]") + newest_group = newest_content.locator("xpath=ancestor::section[@data-publish-task-group]") + assert first_group.locator("[data-task-group-toggle]").inner_text() == "展开" + assert newest_group.locator("[data-task-group-toggle]").inner_text() == "收起" + first_group.locator("[data-task-group-toggle]").click() + assert first_content.is_visible() + + page.locator('[data-center-tab="schedule"]').click() + assert page.locator('[data-schedule-calendar] .publish-calendar-day').count() == 42 + assert "抖音" in page.locator("[data-calendar-title]").inner_text() + assert page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{bilibili}"]' + ).is_hidden() + + page.locator('[data-publish-platform="bilibili"]').click() + assert "B站" in page.locator("[data-calendar-title]").inner_text() + assert "B站" in cover_button.inner_text() + assert "1" in cover_button.inner_text() + assert page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{bilibili}"]' + ).is_visible() + assert page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{first}"]' + ).is_hidden() + + page.locator(f'[data-publish-row][data-section="schedule"][data-job-id="{bilibili}"] [data-publish-select]').check() + assert page.locator("[data-selection-bar]").is_visible() + + page.locator('[data-publish-platform="douyin"]').click() + assert page.locator("[data-selection-bar]").is_hidden() + assert not page.locator(f'[data-publish-row][data-section="schedule"][data-job-id="{bilibili}"] [data-publish-select]').is_checked() + for job_id in douyin_jobs: + page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{job_id}"] [data-publish-select]' + ).check() + page.locator("[data-open-schedule-drawer]").click() + assert page.locator('[name="daily_start_time"]').input_value() == "07:00" + assert page.locator('[name="daily_end_time"]').input_value() == "00:00" + page.locator("[data-use-latest-schedule]").click() + assert page.locator('[name="start_at_local"]').input_value() == f"{future_day}T22:00" + assert page.locator("[data-latest-schedule-note]").inner_text() == ( + f"当前最晚:{future_day} 19:00;本次第 1 条:{future_day} 22:00" + ) + page.locator('[name="start_at_local"]').fill("2020-01-01T06:00") + page.locator("[data-preview-schedule]").click() + assert page.locator("[data-schedule-feedback].tone-red").filter( + has_text="请选择晚于当前时间" + ).is_visible() + page.locator('[name="start_at_local"]').fill(f"{future_day}T06:00") + page.locator('[name="daily_start_time"]').fill("06:00") + page.locator('[name="daily_end_time"]').fill("00:00") + preview_button = page.locator("[data-preview-schedule]") + preview_button.click() + page.locator("[data-confirm-schedule]:not([disabled])").wait_for() + assert page.locator("[data-schedule-feedback]").filter(has_text="已生成 10 条").is_visible() + assert page.locator("[data-schedule-preview] time").all_inner_texts() == [ + f"{future_day} 06:00", + f"{future_day} 09:00", + f"{future_day} 12:00", + f"{future_day} 15:00", + f"{future_day} 18:00", + f"{future_day} 21:00", + f"{following_day} 00:00", + f"{following_day} 06:00", + f"{following_day} 09:00", + f"{following_day} 12:00", + ] + page.locator('[name="daily_end_time"]').fill("21:00") + assert page.locator("[data-confirm-schedule]").is_disabled() + assert "请先生成预览" in page.locator("[data-schedule-preview]").inner_text() + page.locator('[name="daily_end_time"]').fill("00:00") + preview_button.click() + page.locator("[data-confirm-schedule]:not([disabled])").wait_for() + + page.locator("[data-confirm-schedule]").click() + page.locator("[data-schedule-drawer]").wait_for(state="hidden") + assert f"{future_day} 06:00" in page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{first}"] [data-row-schedule]' + ).inner_text() + + dialogs = [] + page.on("dialog", lambda dialog: (dialogs.append(dialog.message), dialog.accept())) + page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{newest}"] [data-cancel-job]' + ).click() + page.locator('[data-center-panel="content"].active').wait_for() + assert any("返回“内容准备”" in message for message in dialogs) + assert newest_content.is_visible() + assert page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{newest}"]' + ).get_attribute("data-status") == "WAITING" + assert "未排期" in page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{newest}"] [data-row-schedule]' + ).inner_text() + assert "已取消发送并返回内容准备" in page.locator("#send-center-message").inner_text() + + page.locator('[data-center-tab="schedule"]').click() + page.locator(f'[data-publish-row][data-section="schedule"][data-job-id="{first}"] [data-publish-now]').click() + assert any("抖音" in message for message in dialogs) + page.locator("#send-center-message").filter(has_text="统一调度").wait_for() + # 此测试使用 lifespan="off",只显式执行当前测试任务,避免扫描同一数据库里的其他排期。 + PublishScheduler().execute_job(first) + page.locator('[data-center-tab="history"]').click() + page.wait_for_function( + "jobId => document.querySelector(`[data-history-record][data-job-id=\"${jobId}\"]`)?.dataset.status === 'EXPORTED'", + arg=first, + timeout=10_000, + ) + assert page.locator("[data-history-calendar] .publish-history-calendar-day").count() == 42 + assert "抖音" in page.locator("[data-history-calendar-title]").inner_text() + + failed_row = page.locator(f'[data-history-record][data-job-id="{failed}"]') + failed_row.wait_for() + assert failed_row.locator("[data-retry-job]").inner_text() == "立即发送" + assert failed_row.locator("[data-restore-job]").count() == 0 + + today = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d") + with page.expect_response( + lambda response: "/api/publish/history/records?" in response.url + and f"date={today}" in response.url + ) as history_response: + page.locator(f'[data-history-date="{today}"]').click() + assert history_response.value.ok + page.locator("[data-history-list-title]").filter(has_text=today).wait_for() + page.locator("[data-history-clear-date]").click() + + exported_row = page.locator(f'[data-history-record][data-job-id="{first}"]') + exported_row.locator("[data-history-hide]").click() + exported_row.wait_for(state="detached") + page.locator('[data-history-view="deleted"]').click() + deleted_row = page.locator(f'[data-history-record][data-job-id="{first}"]') + deleted_row.wait_for() + deleted_row.locator("[data-history-restore]").click() + deleted_row.wait_for(state="detached") + page.locator('[data-history-view="active"]').click() + page.locator(f'[data-history-record][data-job-id="{first}"]').wait_for() + page.set_viewport_size({"width": 720, "height": 1000}) + page.wait_for_timeout(200) + assert page.evaluate( + "document.documentElement.scrollWidth <= document.documentElement.clientWidth" + ) + assert page.locator( + f'[data-history-record][data-job-id="{failed}"] [data-retry-job]' + ).is_visible() + context.close() + browser.close() + finally: + server.should_exit = True + thread.join(timeout=10) + _cleanup() diff --git a/tests/test_publish_center_cleanup.py b/tests/test_publish_center_cleanup.py new file mode 100644 index 0000000..c69eb24 --- /dev/null +++ b/tests/test_publish_center_cleanup.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from app.main import app + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_selection_bar_has_no_legacy_batch_send_but_keeps_scheduling() -> None: + template = (PROJECT_ROOT / "app/templates/publish.html").read_text(encoding="utf-8") + selection_bar = template.split('
", 1)[0] + + assert "data-send-selected" not in selection_bar + assert "data-open-schedule-drawer" in selection_bar + assert "data-apply-batch-target" in selection_bar + assert "data-batch-ai" in selection_bar + assert "data-publish-now" in template + + +def test_legacy_publish_frontend_handlers_are_removed() -> None: + global_script = (PROJECT_ROOT / "app/static/js/app.js").read_text(encoding="utf-8") + publish_script = (PROJECT_ROOT / "app/static/js/publish-center.js").read_text(encoding="utf-8") + + assert "data-publish-tab" not in global_script + assert "data-send-job-form" not in global_script + assert "data-start-send-queue" not in global_script + assert "data-send-selected" not in publish_script + + +def test_current_single_send_route_remains_and_legacy_routes_are_removed() -> None: + route_paths = set(app.openapi()["paths"]) + + assert "/api/publish/jobs/{job_id}/publish-now" in route_paths + assert "/api/publish/jobs/{job_id}/send" not in route_paths + assert "/api/publish/send/start" not in route_paths diff --git a/tests/test_publish_cover_backfill.py b/tests/test_publish_cover_backfill.py new file mode 100644 index 0000000..5b8ff7c --- /dev/null +++ b/tests/test_publish_cover_backfill.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest + +from app.db.database import get_connection, init_db +from app.services import publish_service + + +PREFIX = "test-cover-backfill-" + + +@pytest.fixture(autouse=True) +def cleanup_cover_backfill_rows(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _seed_clip(tmp_path: Path, *, cover_time_seconds: float | None = None) -> dict: + suffix = uuid4().hex[:8] + task_id = f"{PREFIX}task-{suffix}" + candidate_id = f"{PREFIX}candidate-{suffix}" + output_clip_id = f"{PREFIX}clip-{suffix}" + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + video_path = tmp_path / f"{output_clip_id}.mp4" + video_path.write_bytes(b"fake-video") + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks (id, task_name, task_dir_name, platform, status, created_at, updated_at) + VALUES (?, ?, ?, 'general', 'COMPLETED', ?, ?) + """, + (task_id, task_id, task_id, now, now), + ) + connection.execute( + """ + INSERT INTO clip_candidates ( + id, task_id, clip_key, title, start_time, end_time, duration_seconds, + cover_time_seconds, created_at, updated_at + ) + VALUES (?, ?, ?, '测试候选片段', '00:00:10', '00:01:10', 60, ?, ?, ?) + """, + (candidate_id, task_id, candidate_id, cover_time_seconds, now, now), + ) + connection.execute( + """ + INSERT INTO output_clip ( + id, task_id, clip_candidate_id, output_file_path, output_file_name, + status, is_active, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, 'completed', 1, ?, ?) + """, + (output_clip_id, task_id, candidate_id, str(video_path), video_path.name, now, now), + ) + connection.commit() + return { + "task_id": task_id, + "output_clip_id": output_clip_id, + "video_path": video_path, + } + + +def _seed_job( + clip: dict, + *, + platform: str, + status: str = "WAITING", + cover_file_path: str = "", + cover_time_seconds: float = 0, +) -> str: + job_id = f"{PREFIX}job-{uuid4().hex[:8]}" + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with get_connection() as connection: + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, cover_mode, cover_time_seconds, cover_file_path, + status, provider_response, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, 'local_browser', 'original', ?, ?, '测试标题', + '测试简介', '测试简介', '测试', '测试', ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, + clip["task_id"], + clip["output_clip_id"], + clip["output_clip_id"], + platform, + str(clip["video_path"]), + str(clip["video_path"]), + "time" if cover_file_path else "auto", + cover_time_seconds, + cover_file_path, + status, + json.dumps({"source": "test"}, ensure_ascii=False), + now, + now, + ), + ) + connection.commit() + return job_id + + +def test_backfill_generates_once_and_updates_both_platforms(monkeypatch, tmp_path): + clip = _seed_clip(tmp_path) + douyin_job = _seed_job(clip, platform="douyin") + bilibili_job = _seed_job(clip, platform="bilibili") + cover_path = tmp_path / "generated-midpoint.jpg" + cover_path.write_bytes(b"cover") + calls: list[tuple[str, object]] = [] + + def fake_generate(item, preferred_time_seconds=None, video_source="original"): + calls.append((item["output_clip_id"], preferred_time_seconds)) + return { + "cover_file_path": str(cover_path), + "cover_media_url": "/fake-cover", + "cover_time_seconds": 30, + "cover_source": "midpoint_fallback", + } + + monkeypatch.setattr(publish_service, "generate_publish_cover_for_item", fake_generate) + result = publish_service.backfill_missing_publish_covers() + + assert result["status"] == "ok" + assert result["generated_cover_count"] == 1 + assert result["updated_job_count"] == 2 + assert calls == [(clip["output_clip_id"], None)] + with get_connection() as connection: + rows = connection.execute( + "SELECT id, cover_mode, cover_time_seconds, cover_file_path FROM publish_jobs WHERE id IN (?, ?) ORDER BY id", + (douyin_job, bilibili_job), + ).fetchall() + assert len(rows) == 2 + assert all(row["cover_mode"] == "time" for row in rows) + assert all(row["cover_time_seconds"] == 30 for row in rows) + assert all(row["cover_file_path"] == str(cover_path) for row in rows) + + +def test_backfill_only_updates_requested_platform(monkeypatch, tmp_path): + clip = _seed_clip(tmp_path) + douyin_job = _seed_job(clip, platform="douyin") + bilibili_job = _seed_job(clip, platform="bilibili") + cover_path = tmp_path / "douyin-only.jpg" + cover_path.write_bytes(b"cover") + + monkeypatch.setattr( + publish_service, + "generate_publish_cover_for_item", + lambda *_args, **_kwargs: { + "cover_file_path": str(cover_path), + "cover_media_url": "/fake-cover", + "cover_time_seconds": 20, + "cover_source": "midpoint_fallback", + }, + ) + + result = publish_service.backfill_missing_publish_covers("douyin") + + assert result["updated_job_count"] == 1 + assert [job["id"] for job in result["jobs"]] == [douyin_job] + with get_connection() as connection: + rows = { + row["id"]: dict(row) + for row in connection.execute( + "SELECT id, cover_file_path FROM publish_jobs WHERE id IN (?, ?)", + (douyin_job, bilibili_job), + ).fetchall() + } + assert rows[douyin_job]["cover_file_path"] == str(cover_path) + assert rows[bilibili_job]["cover_file_path"] in {"", None} + + +def test_backfill_rejects_unsupported_platform(): + with pytest.raises(ValueError, match="暂不支持"): + publish_service.backfill_missing_publish_covers("unknown") + + +def test_backfill_reuses_existing_cover_and_skips_cancelled(monkeypatch, tmp_path): + clip = _seed_clip(tmp_path, cover_time_seconds=12.5) + existing_cover = tmp_path / "existing.jpg" + existing_cover.write_bytes(b"existing-cover") + existing_job = _seed_job( + clip, + platform="douyin", + status="PUBLISHED", + cover_file_path=str(existing_cover), + cover_time_seconds=8, + ) + missing_job = _seed_job(clip, platform="bilibili") + cancelled_clip = _seed_clip(tmp_path) + cancelled_job = _seed_job(cancelled_clip, platform="douyin", status="CANCELLED") + monkeypatch.setattr( + publish_service, + "generate_publish_cover_for_item", + lambda *_args, **_kwargs: pytest.fail("已有同切片封面时不应再次调用 FFmpeg"), + ) + + result = publish_service.backfill_missing_publish_covers() + + assert result["generated_cover_count"] == 0 + assert result["reused_cover_count"] == 1 + assert result["updated_job_count"] == 1 + with get_connection() as connection: + rows = { + row["id"]: dict(row) + for row in connection.execute( + "SELECT id, cover_file_path, cover_time_seconds FROM publish_jobs WHERE id IN (?, ?, ?)", + (existing_job, missing_job, cancelled_job), + ).fetchall() + } + assert rows[existing_job]["cover_file_path"] == str(existing_cover) + assert rows[missing_job]["cover_file_path"] == str(existing_cover) + assert rows[missing_job]["cover_time_seconds"] == 8 + assert rows[cancelled_job]["cover_file_path"] in {"", None} + + +def test_backfill_returns_partial_when_one_clip_fails(monkeypatch, tmp_path): + first = _seed_clip(tmp_path, cover_time_seconds=9) + second = _seed_clip(tmp_path) + first_job = _seed_job(first, platform="douyin") + second_job = _seed_job(second, platform="bilibili") + cover_path = tmp_path / "partial-success.jpg" + cover_path.write_bytes(b"cover") + + def fake_generate(item, preferred_time_seconds=None, video_source="original"): + if item["output_clip_id"] == second["output_clip_id"]: + raise ValueError("模拟 FFmpeg 失败") + assert preferred_time_seconds == 9 + return { + "cover_file_path": str(cover_path), + "cover_media_url": "/fake-cover", + "cover_time_seconds": 9, + "cover_source": "ai_frame", + } + + monkeypatch.setattr(publish_service, "generate_publish_cover_for_item", fake_generate) + result = publish_service.backfill_missing_publish_covers() + + assert result["status"] == "partial" + assert result["generated_cover_count"] == 1 + assert result["updated_job_count"] == 1 + assert result["failed_clip_count"] == 1 + assert "模拟 FFmpeg 失败" in result["errors"][0]["message"] + with get_connection() as connection: + first_row = connection.execute("SELECT cover_file_path FROM publish_jobs WHERE id = ?", (first_job,)).fetchone() + second_row = connection.execute("SELECT cover_file_path FROM publish_jobs WHERE id = ?", (second_job,)).fetchone() + assert first_row["cover_file_path"] == str(cover_path) + assert second_row["cover_file_path"] in {"", None} diff --git a/tests/test_publish_history.py b/tests/test_publish_history.py new file mode 100644 index 0000000..16a1fb7 --- /dev/null +++ b/tests/test_publish_history.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest + +from app.db.database import get_connection, init_db +from app.services import publish_service +from app.services.publish_readiness import PublishPlatformIsolationBlocked + + +PREFIX = "test-publish-history-" + + +@pytest.fixture(autouse=True) +def clean_publish_history_rows(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _seed_job( + tmp_path: Path, + *, + status: str, + platform: str = "douyin", + scheduled_at: str = "", + started_at: str = "", + finished_at: str = "", + history_hidden: int = 0, +) -> str: + suffix = uuid4().hex[:10] + task_id = f"{PREFIX}{suffix}" + clip_id = f"{PREFIX}clip-{suffix}" + job_id = f"{PREFIX}job-{suffix}" + video = tmp_path / f"{suffix}.mp4" + video.write_bytes(b"fake video") + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks ( + id, task_name, task_dir_name, platform, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'COMPLETED', ?, ?) + """, + (task_id, task_id, task_id, platform, now, now), + ) + connection.execute( + """ + INSERT INTO output_clip ( + id, task_id, output_file_path, output_file_name, + status, is_active, created_at, updated_at + ) VALUES (?, ?, ?, 'clip.mp4', 'completed', 1, ?, ?) + """, + (clip_id, task_id, str(video), now, now), + ) + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, visibility, scheduled_at, schedule_timezone, timezone, + status, started_at, finished_at, history_hidden, history_hidden_at, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, 'manual_export', + 'original', ?, ?, '执行记录测试', '测试正文', '测试正文', + '测试', '测试', 'public', ?, 'Asia/Shanghai', 'Asia/Shanghai', + ?, ?, ?, ?, ?, ?, ? + ) + """, + ( + job_id, + task_id, + clip_id, + clip_id, + platform, + str(video), + str(video), + scheduled_at, + status, + started_at or None, + finished_at or None, + history_hidden, + now if history_hidden else None, + now, + now, + ), + ) + connection.commit() + return job_id + + +def _raw(job_id: str) -> dict: + with get_connection() as connection: + row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + return dict(row) + + +def test_publish_history_schema_is_backward_compatible(): + with get_connection() as connection: + columns = {row["name"] for row in connection.execute("PRAGMA table_info(publish_jobs)")} + index = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_publish_jobs_history_visibility'" + ).fetchone() + assert {"history_hidden", "history_hidden_at"}.issubset(columns) + assert index is not None + + +def test_history_calendar_uses_schedule_then_start_and_excludes_hidden(tmp_path): + scheduled = _seed_job( + tmp_path, + status="PUBLISHED", + scheduled_at="2026-07-27T16:30:00+00:00", + finished_at="2026-07-29T18:00:00+00:00", + ) + started = _seed_job( + tmp_path, + status="FAILED", + started_at="2026-07-28T09:00:00", + finished_at="2026-07-28T10:00:00", + ) + _seed_job( + tmp_path, + status="FAILED", + scheduled_at="2026-07-27T17:00:00+00:00", + history_hidden=1, + ) + _seed_job( + tmp_path, + status="PUBLISHED", + scheduled_at="2026-07-31T16:30:00+00:00", + ) + + calendar = publish_service.get_publish_history_calendar("douyin", "2026-07") + day = next(item for item in calendar["days"] if item["date"] == "2026-07-28") + + assert day["total"] == 2 + assert day["counts"]["PUBLISHED"] == 1 + assert day["counts"]["FAILED"] == 1 + assert all(item["date"] != "2026-08-01" for item in calendar["days"]) + + records = publish_service.list_publish_history_records( + platform="douyin", + date="2026-07-28", + status="all", + page=1, + page_size=50, + ) + assert {job["id"] for job in records["jobs"]} == {scheduled, started} + assert all(job["history_date"] == "2026-07-28" for job in records["jobs"]) + + +def test_history_hide_is_atomic_and_restore_preserves_job(tmp_path): + failed_id = _seed_job( + tmp_path, + status="FAILED", + scheduled_at="2026-07-27T16:30:00+00:00", + finished_at="2026-07-27T17:00:00+00:00", + ) + publishing_id = _seed_job( + tmp_path, + status="PUBLISHING", + scheduled_at="2026-07-27T16:40:00+00:00", + started_at="2026-07-27T16:40:00+00:00", + ) + before = _raw(failed_id) + + with pytest.raises(ValueError, match="终态记录"): + publish_service.hide_publish_history_records( + [failed_id, publishing_id], + platform="douyin", + ) + assert _raw(failed_id)["history_hidden"] == 0 + assert _raw(publishing_id)["history_hidden"] == 0 + + hidden = publish_service.hide_publish_history_records([failed_id], platform="douyin") + after_hide = _raw(failed_id) + assert hidden["affected_count"] == 1 + assert after_hide["history_hidden"] == 1 + assert after_hide["status"] == before["status"] + assert after_hide["scheduled_at"] == before["scheduled_at"] + assert after_hide["finished_at"] == before["finished_at"] + + deleted_records = publish_service.list_publish_history_records( + platform="douyin", + deleted=True, + ) + assert [job["id"] for job in deleted_records["jobs"]] == [failed_id] + + restored = publish_service.restore_publish_history_records([failed_id], platform="douyin") + assert restored["affected_count"] == 1 + assert _raw(failed_id)["history_hidden"] == 0 + with get_connection() as connection: + event_types = [ + row["event_type"] + for row in connection.execute( + "SELECT event_type FROM publish_job_events WHERE job_id = ? ORDER BY id", + (failed_id,), + ).fetchall() + ] + assert event_types[-2:] == ["history_record_hidden", "history_record_restored"] + + +def test_history_batch_rejects_cross_platform_records(tmp_path): + douyin = _seed_job(tmp_path, status="FAILED", platform="douyin") + bilibili = _seed_job(tmp_path, status="FAILED", platform="bilibili") + + with pytest.raises(PublishPlatformIsolationBlocked): + publish_service.hide_publish_history_records([douyin, bilibili], platform="douyin") + + assert _raw(douyin)["history_hidden"] == 0 + assert _raw(bilibili)["history_hidden"] == 0 diff --git a/tests/test_publish_readiness.py b/tests/test_publish_readiness.py new file mode 100644 index 0000000..16d9237 --- /dev/null +++ b/tests/test_publish_readiness.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest + +from app.db.database import get_connection, init_db +from app.services.publish_readiness import SendReadinessBlocked, build_send_readiness +from app.services.publish_scheduler import PublishScheduler +from app.services.publishers.base import PublishOutcome, PublishResult, PublishWorkerUnavailable + + +PREFIX = "test-send-readiness-" + + +@pytest.fixture(autouse=True) +def clean_readiness_rows(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + connection.execute("DELETE FROM publish_job_events WHERE job_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM publish_accounts WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _iso(seconds: int = 0) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(timespec="seconds") + + +def _account(*, platform: str = "douyin", login_status: str = "normal", name: str = "测试账号") -> dict: + return { + "id": f"{PREFIX}account-{uuid4().hex[:8]}", + "platform": platform, + "account_name": name, + "login_status": login_status, + "login_message": "", + } + + +def _job_payload(*, platform: str = "douyin", publish_mode: str = "local_browser", account_id: str = "") -> dict: + return { + "id": f"{PREFIX}unit-job", + "status": "WAITING", + "platform": platform, + "publish_mode": publish_mode, + "account_id": account_id, + "title": "测试标题", + "caption": "测试正文", + "hashtags": "测试", + "cover_file_path": "cover.jpg", + "video_path": "video.mp4", + "bilibili_tid": "娱乐", + "bilibili_copyright": "original", + } + + +def test_readiness_handles_no_unique_multiple_unlogged_and_mismatched_accounts(): + job = _job_payload() + assert build_send_readiness(job, accounts=[])["action"] == "create_account" + + unique = _account() + ready = build_send_readiness(job, accounts=[unique]) + assert ready["dispatch_ready"] is True + assert ready["auto_selected_account"] is True + assert ready["resolved_account_id"] == unique["id"] + + multiple = build_send_readiness(job, accounts=[unique, _account(name="第二账号")]) + assert multiple["action"] == "select_account" + + unlogged = build_send_readiness(job, accounts=[_account(login_status="login_required")]) + assert unlogged["action"] == "login_account" + + mismatch_account = _account(platform="bilibili") + mismatch = build_send_readiness( + _job_payload(account_id=mismatch_account["id"]), + accounts=[mismatch_account], + ) + assert mismatch["action"] == "select_account" + + +def test_manual_export_does_not_require_account_cover_tags_or_worker(): + job = _job_payload(publish_mode="manual_export") + job.update({"hashtags": "", "cover_file_path": ""}) + readiness = build_send_readiness(job, accounts=[], worker_available=False) + assert readiness["ready"] is True + assert readiness["requires_worker"] is False + assert readiness["action"] == "export" + + +class FakeWorker: + def __init__(self, *, available: bool = True) -> None: + self.available = available + self.publish_calls: list[dict] = [] + + def health(self) -> dict: + if not self.available: + raise PublishWorkerUnavailable("测试 Worker 离线") + return {"status": "ok"} + + def check_account(self, platform: str, account_id: str) -> dict: + return {"status": "normal", "login_status": "normal", "platform": platform, "account_id": account_id} + + def publish(self, payload: dict) -> PublishResult: + self.publish_calls.append(payload) + return PublishResult( + outcome=PublishOutcome.PUBLISHED, + message="测试投稿成功", + remote_video_id="remote-test-1", + platform_url="https://www.douyin.com/video/test-1", + ) + + +def _insert_account(*, login_status: str = "normal", platform: str = "douyin") -> str: + account_id = f"{PREFIX}account-{uuid4().hex[:8]}" + now = _iso() + with get_connection() as connection: + connection.execute( + """ + INSERT INTO publish_accounts ( + id, platform, account_name, auth_type, login_status, login_message, created_at, updated_at + ) VALUES (?, ?, ?, 'browser_profile', ?, '', ?, ?) + """, + (account_id, platform, f"账号-{account_id[-4:]}", login_status, now, now), + ) + connection.commit() + return account_id + + +def _insert_job( + tmp_path: Path, + *, + status: str = "WAITING", + publish_mode: str = "opencli_publish", + account_id: str = "", + error_code: str = "", + remote_video_id: str = "", +) -> str: + suffix = uuid4().hex[:8] + task_id = f"{PREFIX}task-{suffix}" + clip_id = f"{PREFIX}clip-{suffix}" + job_id = f"{PREFIX}job-{suffix}" + video = tmp_path / f"{suffix}.mp4" + cover = tmp_path / f"{suffix}.jpg" + video.write_bytes(b"fake-video") + cover.write_bytes(b"fake-cover") + now = _iso() + with get_connection() as connection: + connection.execute( + "INSERT INTO tasks (id, task_name, task_dir_name, platform, status, created_at, updated_at) VALUES (?, ?, ?, 'douyin', 'COMPLETED', ?, ?)", + (task_id, task_id, task_id, now, now), + ) + connection.execute( + "INSERT INTO output_clip (id, task_id, output_file_path, output_file_name, status, is_active, created_at, updated_at) VALUES (?, ?, ?, 'clip.mp4', 'completed', 1, ?, ?)", + (clip_id, task_id, str(video), now, now), + ) + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, account_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, cover_file_path, scheduled_at, schedule_timezone, timezone, + status, error_code, remote_video_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'douyin', ?, 'original', ?, ?, '测试标题', '测试正文', + '测试正文', '测试', '测试', ?, ?, 'Asia/Shanghai', 'Asia/Shanghai', ?, ?, ?, ?, ?) + """, + ( + job_id, + task_id, + clip_id, + clip_id, + account_id or None, + publish_mode, + str(video), + str(video), + str(cover), + _iso(-60), + status, + error_code, + remote_video_id, + now, + now, + ), + ) + connection.commit() + return job_id + + +def _raw(job_id: str) -> dict: + with get_connection() as connection: + return dict(connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone()) + + +def test_unique_logged_account_keeps_legacy_job_and_creates_replacement(tmp_path): + account_id = _insert_account() + job_id = _insert_job(tmp_path) + worker = FakeWorker() + scheduler = PublishScheduler(worker_client=worker) + + scheduled = scheduler.publish_now(job_id) + assert scheduled["status"] == "scheduled" + replacement_id = scheduled["job_id"] + assert replacement_id != job_id + assert _raw(job_id)["status"] == "NEED_REVIEW" + assert _raw(job_id)["publish_mode"] == "opencli_publish" + assert _raw(replacement_id)["account_id"] == account_id + assert _raw(replacement_id)["publish_mode"] == "local_browser" + + scheduler.run_once() + assert _raw(job_id)["status"] == "NEED_REVIEW" + assert _raw(replacement_id)["status"] == "PUBLISHED" + assert len(worker.publish_calls) == 1 + + +def test_worker_offline_never_changes_status_or_calls_publish(tmp_path): + account_id = _insert_account() + job_id = _insert_job(tmp_path, publish_mode="local_browser", account_id=account_id) + worker = FakeWorker(available=False) + scheduler = PublishScheduler(worker_client=worker) + + with pytest.raises(SendReadinessBlocked) as caught: + scheduler.publish_now(job_id) + assert caught.value.readiness["action"] == "start_worker" + assert _raw(job_id)["status"] == "WAITING" + assert worker.publish_calls == [] + + with get_connection() as connection: + connection.execute("UPDATE publish_jobs SET status = 'SCHEDULED' WHERE id = ?", (job_id,)) + connection.commit() + skipped = scheduler.execute_job(job_id) + assert skipped["error_code"] == "publish_worker_unavailable" + assert _raw(job_id)["status"] == "SCHEDULED" + assert worker.publish_calls == [] + + +def test_safe_repair_keeps_original_and_creates_local_browser_replacement(tmp_path): + account_id = _insert_account() + source_id = _insert_job( + tmp_path, + status="NEED_REVIEW", + publish_mode="opencli_publish", + error_code="opencli_fallback_disabled", + ) + worker = FakeWorker() + scheduler = PublishScheduler(worker_client=worker) + + result = scheduler.repair_and_publish(source_id, visibility="private") + replacement_id = result["job_id"] + assert _raw(source_id)["status"] == "NEED_REVIEW" + replacement = _raw(replacement_id) + assert replacement["retry_of_job_id"] == source_id + assert replacement["publish_mode"] == "local_browser" + assert replacement["account_id"] == account_id + assert replacement["status"] == "SCHEDULED" + assert replacement["visibility"] == "private" + + repeated = scheduler.repair_and_publish(source_id) + assert repeated["status"] == "already_created" + assert repeated["job_id"] == replacement_id + + scheduler.run_once() + assert _raw(source_id)["status"] == "NEED_REVIEW" + assert _raw(replacement_id)["status"] == "PUBLISHED" + assert len(worker.publish_calls) == 1 + + +def test_uncertain_result_cannot_use_safe_repair(tmp_path): + _insert_account() + source_id = _insert_job( + tmp_path, + status="NEED_REVIEW", + publish_mode="opencli_publish", + error_code="publish_result_uncertain", + ) + with pytest.raises(ValueError, match="不能自动修复"): + PublishScheduler(worker_client=FakeWorker()).repair_and_publish(source_id) diff --git a/tests/test_publish_scheduler.py b/tests/test_publish_scheduler.py index 598cb51..0539b24 100644 --- a/tests/test_publish_scheduler.py +++ b/tests/test_publish_scheduler.py @@ -1,311 +1,526 @@ from __future__ import annotations import json -import os import subprocess import sys -from datetime import datetime, timedelta +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone from pathlib import Path from uuid import uuid4 +from zoneinfo import ZoneInfo import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError from app.core.config import settings from app.db.database import get_connection, init_db +from app.main import app +from app.models.task import PublishBatchScheduleUpdate, PublishJobCreate, PublishScheduleNextStartRequest from app.services.auto_publish_service import create_auto_publish_jobs +from app.services.publish_domain import TARGET_PLATFORMS +from app.services.publish_readiness import SendReadinessBlocked from app.services.publish_scheduler import PublishScheduler, build_batch_schedule_times -from app.services.publish_service import get_publish_job +from app.services import publish_service -TEST_PREFIX = "test-v140-" +PREFIX = "test-real-publish-" @pytest.fixture(autouse=True) -def publish_scheduler_db_cleanup(tmp_path): +def clean_publish_data(tmp_path): init_db() + original_export = settings.publish_scheduler_export_dir + original_default_mode = settings.publish_default_mode + original_stale = settings.publish_job_stale_minutes + original_opencli_fallback = settings.publish_enable_opencli_fallback + object.__setattr__(settings, "publish_scheduler_export_dir", tmp_path / "exports") + object.__setattr__(settings, "publish_default_mode", "opencli_publish") + object.__setattr__(settings, "publish_job_stale_minutes", 30) + object.__setattr__(settings, "publish_enable_opencli_fallback", True) _cleanup() - original_export_dir = settings.publish_scheduler_export_dir - original_default_platform = settings.publish_scheduler_default_platform - original_allow_without_review = settings.publish_scheduler_allow_publish_without_review - object.__setattr__(settings, "publish_scheduler_export_dir", tmp_path / "发布 packages") - object.__setattr__(settings, "publish_scheduler_default_platform", "manual_export") - object.__setattr__(settings, "publish_scheduler_allow_publish_without_review", False) yield _cleanup() - object.__setattr__(settings, "publish_scheduler_export_dir", original_export_dir) - object.__setattr__(settings, "publish_scheduler_default_platform", original_default_platform) - object.__setattr__(settings, "publish_scheduler_allow_publish_without_review", original_allow_without_review) + object.__setattr__(settings, "publish_scheduler_export_dir", original_export) + object.__setattr__(settings, "publish_default_mode", original_default_mode) + object.__setattr__(settings, "publish_job_stale_minutes", original_stale) + object.__setattr__(settings, "publish_enable_opencli_fallback", original_opencli_fallback) -def _cleanup() -> None: +def _cleanup(): with get_connection() as connection: - connection.execute("DELETE FROM publish_jobs") - connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{TEST_PREFIX}%",)) - connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{TEST_PREFIX}%",)) + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) connection.commit() -def _iso(delta_seconds: int = 0) -> str: - return (datetime.now().astimezone() + timedelta(seconds=delta_seconds)).isoformat(timespec="seconds") +def _utc(delta_seconds: int = 0) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=delta_seconds)).isoformat(timespec="seconds").replace("+00:00", "Z") -def _video(tmp_path: Path, name: str = "clip.mp4") -> Path: - path = tmp_path / "含 中文 空格" / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b"fake-video") - return path +def _future_beijing_time(hour: int, *, minute: int = 0, days: int = 2) -> datetime: + future_date = (datetime.now(ZoneInfo("Asia/Shanghai")) + timedelta(days=days)).date() + return datetime(future_date.year, future_date.month, future_date.day, hour, minute, tzinfo=ZoneInfo("Asia/Shanghai")) def _insert_job( tmp_path: Path, *, status: str = "SCHEDULED", + publish_mode: str = "manual_export", + platform: str = "douyin", scheduled_at: str | None = None, - video_path: str | None = None, - title: str = "测试标题", - caption: str = "测试文案", risk_flags: list[str] | None = None, + updated_at: str | None = None, ) -> str: - task_id = f"{TEST_PREFIX}{uuid4().hex[:8]}" - clip_id = f"{TEST_PREFIX}clip-{uuid4().hex[:8]}" - job_id = f"{TEST_PREFIX}job-{uuid4().hex[:8]}" - now = _iso() - video = video_path if video_path is not None else str(_video(tmp_path)) - risk_json = json.dumps(risk_flags or [], ensure_ascii=False) + task_id = f"{PREFIX}{uuid4().hex[:8]}" + clip_id = f"{PREFIX}clip-{uuid4().hex[:8]}" + job_id = f"{PREFIX}job-{uuid4().hex[:8]}" + video = tmp_path / f"{clip_id}.mp4" + video.write_bytes(b"fake-video") + now = _utc() with get_connection() as connection: connection.execute( - """ - INSERT INTO tasks (id, task_name, task_dir_name, status, created_at, updated_at) - VALUES (?, ?, ?, 'COMPLETED', ?, ?) - """, - (task_id, task_id, task_id, now, now), + "INSERT INTO tasks (id, task_name, task_dir_name, platform, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'COMPLETED', ?, ?)", + (task_id, task_id, task_id, platform, now, now), ) connection.execute( - """ - INSERT INTO output_clip ( - id, task_id, output_file_path, output_file_name, status, is_active, created_at, updated_at - ) - VALUES (?, ?, ?, 'clip.mp4', 'completed', 1, ?, ?) - """, - (clip_id, task_id, video, now, now), + "INSERT INTO output_clip (id, task_id, output_file_path, output_file_name, status, is_active, created_at, updated_at) VALUES (?, ?, ?, 'clip.mp4', 'completed', 1, ?, ?)", + (clip_id, task_id, str(video), now, now), ) connection.execute( """ INSERT INTO publish_jobs ( id, task_id, output_clip_id, clip_id, platform, publish_mode, video_source, video_file_path, video_path, title, description, caption, - tags, hashtags, cover_text, risk_flags, scheduled_at, status, + tags, hashtags, risk_flags, scheduled_at, schedule_timezone, status, created_at, updated_at - ) - VALUES (?, ?, ?, ?, 'manual_export', 'manual_export', - 'original', ?, ?, ?, ?, ?, '#测试', '#测试', '封面文案', ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, 'original', ?, ?, '测试标题', '测试正文', '测试正文', + '测试', '测试', ?, ?, 'Asia/Shanghai', ?, ?, ?) """, ( - job_id, - task_id, - clip_id, - clip_id, - video, - video, - title, - caption, - caption, - risk_json, - scheduled_at if scheduled_at is not None else _iso(-60), - status, - now, - now, + job_id, task_id, clip_id, clip_id, platform, publish_mode, + str(video), str(video), json.dumps(risk_flags or [], ensure_ascii=False), + scheduled_at if scheduled_at is not None else _utc(-60), status, now, updated_at or now, ), ) connection.commit() return job_id -def test_future_scheduled_job_is_not_published(tmp_path): - job_id = _insert_job(tmp_path, scheduled_at=_iso(3600)) - result = PublishScheduler(interval_seconds=1).run_once() - job = get_publish_job(job_id) - assert result["matched_count"] == 0 - assert job["status"] == "SCHEDULED" +def _raw(job_id: str) -> dict: + with get_connection() as connection: + return dict(connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone()) + + +def _mock_opencli_success(monkeypatch, calls: list[str]): + def execute(job_id, runner=None): + calls.append(job_id) + return {"status": "ok", "confirmed": True, "message": "mock submitted", "job": _raw(job_id)} + monkeypatch.setattr(publish_service, "execute_opencli_send_job", execute) + + +def test_platform_and_publish_mode_are_separate(): + assert TARGET_PLATFORMS == {"douyin": "抖音", "bilibili": "B站"} + with pytest.raises(ValidationError): + PublishJobCreate(task_id="task", output_clip_id="clip", platform="manual_export", title="标题") + + +def test_auto_pipeline_creates_only_metadata_target_platform(tmp_path): + job_id = _insert_job(tmp_path, status="CANCELLED") + seed = _raw(job_id) + cover_path = tmp_path / "auto-pipeline-cover.jpg" + cover_path.write_bytes(b"cover") + result = create_auto_publish_jobs( + {"id": seed["task_id"], "platform": "general"}, + [{ + "output_clip": {"id": seed["output_clip_id"], "output_file_path": seed["video_file_path"]}, + "cover": { + "cover_file_path": str(cover_path), + "cover_time_seconds": 15, + "cover_source": "ai_frame", + }, + "metadata": {"platform": "bilibili", "title": "自动标题", "caption": "自动正文", "hashtags": ["自动"], "risk_flags": []}, + "scheduled_at": "", + }], + ) + created = result["created"][0] + assert created["platform"] == "bilibili" + assert created["publish_mode"] == "opencli_publish" + assert created["status"] == "WAITING" -def test_due_job_exports_publish_package(tmp_path): - job_id = _insert_job(tmp_path, scheduled_at=_iso(-60)) - result = PublishScheduler(interval_seconds=1).run_once() - job = get_publish_job(job_id) - package_dir = Path(job["publish_result_payload"]["package_dir"]) - assert result["published_count"] == 1 - assert job["status"] == "PUBLISHED" - assert (package_dir / "clip.mp4").exists() - assert (package_dir / "title.txt").exists() - assert (package_dir / "caption.txt").exists() - assert (package_dir / "hashtags.txt").exists() - assert (package_dir / "cover_text.txt").exists() - assert (package_dir / "publish_plan.json").exists() - assert (package_dir / "metadata.json").exists() +def test_refresh_queue_does_not_turn_manual_export_into_platform(monkeypatch, tmp_path): + manual_id = _insert_job(tmp_path, status="WAITING", publish_mode="manual_export", platform="douyin") + monkeypatch.setattr(publish_service, "_generate_default_publish_cover", lambda *args, **kwargs: {}) + result = publish_service.refresh_send_queue(use_ai=False) + assert _raw(manual_id)["publish_mode"] == "manual_export" + assert all(job["platform"] in TARGET_PLATFORMS for job in result["created"]) + assert not any(job["platform"] in {"manual_export", "local_browser"} for job in result["created"]) -def test_missing_video_marks_failed(tmp_path): - job_id = _insert_job(tmp_path, video_path=str(tmp_path / "missing.mp4"), scheduled_at=_iso(-60)) - PublishScheduler(interval_seconds=1).run_once() - job = get_publish_job(job_id) - assert job["status"] == "FAILED" - assert "does not exist" in job["last_error"] +def test_shanghai_local_time_is_stored_as_utc(): + result = build_batch_schedule_times( + 1, start_at_local="2026-07-12T09:00", timezone_name="Asia/Shanghai", + interval_minutes=180, daily_start_time="09:00", daily_end_time="21:00", reject_past=False, + ) + assert result == ["2026-07-12T01:00:00+00:00"] -def test_need_review_is_not_auto_published(tmp_path): - job_id = _insert_job(tmp_path, status="NEED_REVIEW", risk_flags=["sensitive"], scheduled_at=_iso(-60)) - PublishScheduler(interval_seconds=1).run_once() - assert get_publish_job(job_id)["status"] == "NEED_REVIEW" +def test_daily_window_overflow_moves_to_next_local_day(): + result = build_batch_schedule_times( + 3, start_at_local="2026-07-12T20:00", timezone_name="Asia/Shanghai", + interval_minutes=180, daily_start_time="09:00", daily_end_time="21:00", reject_past=False, + ) + assert result == ["2026-07-12T12:00:00+00:00", "2026-07-13T01:00:00+00:00", "2026-07-13T04:00:00+00:00"] + + +def test_schedule_request_defaults_use_seven_to_midnight(): + batch = PublishBatchScheduleUpdate(job_ids=["job-1"]) + next_start = PublishScheduleNextStartRequest(job_ids=["job-1"], platform="douyin") + assert (batch.daily_start_time, batch.daily_end_time) == ("07:00", "00:00") + assert (next_start.daily_start_time, next_start.daily_end_time) == ("07:00", "00:00") + + +def test_next_schedule_start_uses_current_platform_and_excludes_selected_jobs(tmp_path): + selected_time = _future_beijing_time(23) + selected = _insert_job(tmp_path, status="WAITING", scheduled_at=selected_time.astimezone(timezone.utc).isoformat(timespec="seconds")) + latest_time = _future_beijing_time(19) + latest = _insert_job(tmp_path, status="WAITING", scheduled_at=latest_time.astimezone(timezone.utc).isoformat(timespec="seconds")) + _insert_job( + tmp_path, + status="SCHEDULED", + platform="bilibili", + scheduled_at=_future_beijing_time(23).astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + _insert_job( + tmp_path, + status="PUBLISHED", + scheduled_at=_future_beijing_time(23).astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + result = PublishScheduler().next_batch_schedule_start( + [selected], + platform="douyin", + timezone_name="Asia/Shanghai", + interval_minutes=180, + daily_start_time="07:00", + daily_end_time="00:00", + ) -def test_review_approval_without_schedule_returns_to_waiting(tmp_path): - job_id = _insert_job(tmp_path, status="NEED_REVIEW", risk_flags=["sensitive"], scheduled_at="") - result = PublishScheduler(interval_seconds=1).approve_review(job_id) - assert result["job"]["status"] == "WAITING" - assert result["job"]["scheduled_at"] == "" + assert result["status"] == "ok" + assert result["latest_job_id"] == latest + assert result["latest_scheduled_at_local_display"].endswith(" 19:00") + assert result["next_start_at_local_display"].endswith(" 22:00") + + +@pytest.mark.parametrize( + ("latest_hour", "expected_day_offset", "expected_hour"), + [(21, 1, 0), (22, 1, 7)], +) +def test_next_schedule_start_respects_cross_midnight_window( + tmp_path, + latest_hour, + expected_day_offset, + expected_hour, +): + selected = _insert_job(tmp_path, status="WAITING", scheduled_at="") + latest_time = _future_beijing_time(latest_hour) + _insert_job( + tmp_path, + status="SCHEDULED", + scheduled_at=latest_time.astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + result = PublishScheduler().next_batch_schedule_start( + [selected], + platform="douyin", + interval_minutes=180, + daily_start_time="07:00", + daily_end_time="00:00", + ) + expected = latest_time.date() + timedelta(days=expected_day_offset) + assert result["next_start_at_local_display"] == f"{expected:%Y-%m-%d} {expected_hour:02d}:00" -def test_cancelled_is_not_auto_published(tmp_path): - job_id = _insert_job(tmp_path, status="CANCELLED", scheduled_at=_iso(-60)) - PublishScheduler(interval_seconds=1).run_once() - assert get_publish_job(job_id)["status"] == "CANCELLED" +def test_next_schedule_start_returns_empty_without_other_future_schedule(tmp_path): + selected = _insert_job(tmp_path, status="WAITING", scheduled_at="") + result = PublishScheduler().next_batch_schedule_start([selected], platform="douyin") + assert result["status"] == "empty" + assert result["next_start_at_local"] == "" + assert "手动选择" in result["message"] -def test_failed_job_can_retry(tmp_path): - job_id = _insert_job(tmp_path, status="FAILED", scheduled_at=_iso(-60)) - result = PublishScheduler(interval_seconds=1).retry_failed(job_id) - assert result["status"] == "published" - assert get_publish_job(job_id)["status"] == "PUBLISHED" +def test_next_schedule_start_api_and_platform_isolation(tmp_path): + selected = _insert_job(tmp_path, status="WAITING", scheduled_at="") + latest_time = _future_beijing_time(19) + _insert_job( + tmp_path, + status="SCHEDULED", + scheduled_at=latest_time.astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + headers = {"Authorization": f"Bearer {settings.local_admin_token}"} if settings.local_admin_token else {} + payload = { + "job_ids": [selected], + "platform": "douyin", + "timezone": "Asia/Shanghai", + "interval_minutes": 180, + "daily_start_time": "07:00", + "daily_end_time": "00:00", + } + client = TestClient(app) + response = client.post("/api/publish/schedules/next-start", json=payload, headers=headers) + assert response.status_code == 200 + assert response.json()["next_start_at_local_display"].endswith(" 22:00") + assert response.json()["next_start_at_local"].endswith("T22:00") + assert "+" not in response.json()["next_start_at_local"] + + bilibili = _insert_job(tmp_path, status="WAITING", platform="bilibili", scheduled_at="") + response = client.post( + "/api/publish/schedules/next-start", + json={**payload, "job_ids": [bilibili]}, + headers=headers, + ) + assert response.status_code == 409 + assert "当前平台与所选任务不一致" in response.json()["detail"] + + +def test_scheduled_manual_export_job_can_run_now(tmp_path): + job_id = _insert_job(tmp_path, status="SCHEDULED", scheduled_at=_utc(3600)) + result = PublishScheduler().publish_now(job_id) + assert result["status"] == "scheduled" + PublishScheduler().run_once() + assert _raw(job_id)["status"] == "EXPORTED" + + +def test_due_legacy_opencli_job_moves_to_review_once_without_being_claimed(monkeypatch, tmp_path): + calls = [] + _mock_opencli_success(monkeypatch, calls) + job_id = _insert_job(tmp_path, publish_mode="opencli_publish") + result = PublishScheduler().execute_job(job_id) + assert calls == [] + assert result["error_code"] == "legacy_schedule_requires_confirmation" + assert _raw(job_id)["status"] == "NEED_REVIEW" + with get_connection() as connection: + event_count = connection.execute( + "SELECT COUNT(*) FROM publish_job_events WHERE job_id = ?", + (job_id,), + ).fetchone()[0] + repeated = PublishScheduler().execute_job(job_id) + assert repeated["status"] == "skipped" + with get_connection() as connection: + repeated_event_count = connection.execute( + "SELECT COUNT(*) FROM publish_job_events WHERE job_id = ?", + (job_id,), + ).fetchone()[0] + assert repeated_event_count == event_count -def test_published_job_is_not_republished(tmp_path): - job_id = _insert_job(tmp_path, status="PUBLISHED", scheduled_at=_iso(-60)) - with get_connection() as connection: - connection.execute("UPDATE publish_jobs SET attempt_count = 2 WHERE id = ?", (job_id,)) - connection.commit() - PublishScheduler(interval_seconds=1).run_once() - assert get_publish_job(job_id)["attempt_count"] == 2 +def test_publish_now_legacy_without_account_is_blocked_before_status_change(tmp_path): + job_id = _insert_job(tmp_path, publish_mode="opencli_publish") + with pytest.raises(SendReadinessBlocked) as caught: + PublishScheduler().publish_now(job_id) + assert caught.value.readiness["action"] == "create_account" + assert _raw(job_id)["status"] == "SCHEDULED" -def test_run_once_module_command(tmp_path): - db_path = tmp_path / "run_once.sqlite3" - env = { - **os.environ, - "DATABASE_PATH": str(db_path), - "DATA_DIR": str(tmp_path / "data"), - "TASKS_DIR": str(tmp_path / "tasks"), - "STORAGE_ROOT": str(tmp_path / "tasks"), - "PUBLISH_SCHEDULER_EXPORT_DIR": str(tmp_path / "exports"), - } - result = subprocess.run( - [sys.executable, "-m", "app.publish_scheduler", "run-once"], - cwd=settings.project_root, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=30, - ) - assert result.returncode == 0 - assert "matched_count" in result.stdout +def test_manual_export_success_is_exported(tmp_path): + job_id = _insert_job(tmp_path, publish_mode="manual_export") + result = PublishScheduler().run_once() + assert result["exported_count"] == 1 + assert _raw(job_id)["status"] == "EXPORTED" + assert not _raw(job_id)["published_at"] + + +def test_need_review_is_never_executed(monkeypatch, tmp_path): + calls = [] + _mock_opencli_success(monkeypatch, calls) + job_id = _insert_job(tmp_path, status="NEED_REVIEW", risk_flags=["敏感"]) + PublishScheduler().run_once() + assert calls == [] + assert _raw(job_id)["status"] == "NEED_REVIEW" + + +def test_two_schedulers_cannot_claim_same_job(tmp_path): + job_id = _insert_job(tmp_path) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: PublishScheduler().execute_job(job_id), range(2))) + assert sorted(item["status"] for item in results) == ["exported", "skipped"] + assert _raw(job_id)["status"] == "EXPORTED" -def test_windows_style_path_with_spaces_and_chinese_exports(tmp_path): - video = _video(tmp_path, "中文 空格 clip.mp4") - job_id = _insert_job(tmp_path, video_path=str(video), scheduled_at=_iso(-60)) - PublishScheduler(interval_seconds=1).run_once() - package_dir = Path(get_publish_job(job_id)["publish_result_payload"]["package_dir"]) - assert (package_dir / "clip.mp4").exists() +def test_only_stale_publishing_job_is_recovered(tmp_path): + stale = _insert_job(tmp_path, status="PUBLISHING", updated_at=_utc(-3600)) + fresh = _insert_job(tmp_path, status="PUBLISHING", updated_at=_utc(-60)) + recovered = PublishScheduler().recover_interrupted_jobs() + assert recovered == 1 + assert _raw(stale)["status"] == "NEED_REVIEW" + assert _raw(fresh)["status"] == "PUBLISHING" -def test_v130_auto_publish_job_is_scanned_by_v140_scheduler(tmp_path): - task_id = f"{TEST_PREFIX}auto-{uuid4().hex[:8]}" - clip_id = f"{TEST_PREFIX}out-{uuid4().hex[:8]}" - video = _video(tmp_path, "auto_clip.mp4") - now = _iso() +def test_finished_manual_review_execution_is_reconciled_without_stale_wait(tmp_path): + job_id = _insert_job(tmp_path, status="PUBLISHING", updated_at=_utc()) with get_connection() as connection: connection.execute( - """ - INSERT INTO tasks (id, task_name, task_dir_name, platform, status, created_at, updated_at) - VALUES (?, ?, ?, 'general', 'COMPLETED', ?, ?) - """, - (task_id, task_id, task_id, now, now), + "UPDATE publish_jobs SET execution_id = ?, execution_phase = 'manual_review_waiting' WHERE id = ?", + ("execution-manual-review", job_id), ) + connection.commit() + + class FinishedWorker: + @staticmethod + def execution(_execution_id): + return { + "phase": "manual_review", + "details": { + "outcome": "NEED_REVIEW", + "message": "上传状态需要人工确认", + "error_code": "video_upload_timeout", + "needs_manual_review": True, + }, + } + + recovered = PublishScheduler(worker_client=FinishedWorker()).recover_interrupted_jobs() + + assert recovered == 1 + job = _raw(job_id) + assert job["status"] == "NEED_REVIEW" + assert job["error_code"] == "video_upload_timeout" + + +def test_confirmed_success_execution_is_recovered_once_without_republishing(tmp_path): + job_id = _insert_job(tmp_path, status="PUBLISHING", updated_at=_utc()) + with get_connection() as connection: connection.execute( - """ - INSERT INTO output_clip ( - id, task_id, output_file_path, output_file_name, status, is_active, created_at, updated_at - ) - VALUES (?, ?, ?, 'auto_clip.mp4', 'completed', 1, ?, ?) - """, - (clip_id, task_id, str(video), now, now), + "UPDATE publish_jobs SET execution_id = ?, execution_phase = 'claimed' WHERE id = ?", + ("execution-confirmed-success", job_id), ) connection.commit() - create_auto_publish_jobs( - {"id": task_id, "platform": "general"}, - [ - { - "output_clip": {"id": clip_id, "output_file_path": str(video)}, - "metadata": { - "platform": "douyin", - "title": "自动任务标题", - "caption": "自动任务文案", - "hashtags": ["自动发布"], - "cover_text": "自动封面", - "risk_flags": [], - "source": "test", + class FinishedWorker: + calls = 0 + + @classmethod + def execution(cls, _execution_id): + cls.calls += 1 + return { + "phase": "confirmed_success", + "details": { + "outcome": "PUBLISHED", + "message": "投稿成功", + "needs_manual_review": False, }, - "scheduled_at": _iso(-60), } - ], + + publish_calls: list[str] = [] + scheduler = PublishScheduler( + worker_client=FinishedWorker(), + executor=lambda job_id, **_kwargs: publish_calls.append(job_id), ) - result = PublishScheduler(interval_seconds=1).run_once() - assert result["published_count"] == 1 - with get_connection() as connection: - row = connection.execute("SELECT status FROM publish_jobs WHERE task_id = ?", (task_id,)).fetchone() - assert row["status"] == "PUBLISHED" + assert scheduler.recover_interrupted_jobs() == 1 + assert scheduler.recover_interrupted_jobs() == 0 + assert _raw(job_id)["status"] == "PUBLISHED" + assert FinishedWorker.calls == 1 + assert publish_calls == [] -def test_batch_schedule_moves_overflow_to_next_daily_window(tmp_path): - first_job = _insert_job(tmp_path, status="WAITING", scheduled_at="") - second_job = _insert_job(tmp_path, status="WAITING", scheduled_at="") - third_job = _insert_job(tmp_path, status="WAITING", scheduled_at="") - result = PublishScheduler().update_batch_schedule( - [first_job, second_job, third_job], - action="apply", - start_at="2026-06-25T20:00:00+08:00", - interval_hours=3, +def test_recovery_schedule_keeps_18_jobs_in_order_on_two_hour_grid(): + scheduled = build_batch_schedule_times( + 18, + start_at_local="2026-07-29T21:00:00+08:00", + timezone_name="Asia/Shanghai", + interval_minutes=120, daily_start_time="09:00", daily_end_time="21:00", + reject_past=False, ) - - assert result["updated_count"] == 3 - assert get_publish_job(first_job)["scheduled_at"] == "2026-06-25T20:00:00+08:00" - assert get_publish_job(second_job)["scheduled_at"] == "2026-06-26T09:00:00+08:00" - assert get_publish_job(third_job)["scheduled_at"] == "2026-06-26T12:00:00+08:00" - assert get_publish_job(first_job)["status"] == "SCHEDULED" - - -def test_batch_schedule_can_be_cleared(tmp_path): - job_id = _insert_job(tmp_path, status="SCHEDULED", scheduled_at="2026-06-25T20:00:00+08:00") - - result = PublishScheduler().update_batch_schedule([job_id], action="clear") - - assert result["updated_count"] == 1 - assert get_publish_job(job_id)["scheduled_at"] == "" - assert get_publish_job(job_id)["status"] == "WAITING" + local = [ + datetime.fromisoformat(value).astimezone(ZoneInfo("Asia/Shanghai")) + for value in scheduled + ] + + assert len(local) == 18 + assert local[0].isoformat(timespec="minutes") == "2026-07-29T21:00+08:00" + assert local[-1].isoformat(timespec="minutes") == "2026-08-01T13:00+08:00" + assert [item.hour for item in local[:8]] == [21, 9, 11, 13, 15, 17, 19, 21] + assert all(item.minute == 0 and 9 <= item.hour <= 21 for item in local) + + +def test_preview_and_save_use_identical_schedule(tmp_path): + first = _insert_job(tmp_path, status="WAITING", scheduled_at="") + second = _insert_job(tmp_path, status="WAITING", scheduled_at="") + scheduler = PublishScheduler() + future_day = (datetime.now(ZoneInfo("Asia/Shanghai")) + timedelta(days=2)).strftime("%Y-%m-%d") + params = dict( + start_at_local=f"{future_day}T20:00", timezone_name="Asia/Shanghai", + interval_minutes=180, daily_start_time="09:00", daily_end_time="21:00", + ) + preview = scheduler.preview_batch_schedule([first, second], **params) + saved = scheduler.update_batch_schedule([first, second], action="apply", **params) + assert saved["schedule"] == preview["schedule"] + assert [_raw(first)["scheduled_at"], _raw(second)["scheduled_at"]] == [ + item["scheduled_at_utc"] for item in preview["schedule"] + ] + + +def test_schedule_preview_api_matches_save_api(tmp_path): + job_ids = [_insert_job(tmp_path, status="WAITING", scheduled_at="") for _ in range(10)] + future_day = (datetime.now(ZoneInfo("Asia/Shanghai")) + timedelta(days=2)).strftime("%Y-%m-%d") + following_day = ( + datetime.strptime(future_day, "%Y-%m-%d") + timedelta(days=1) + ).strftime("%Y-%m-%d") + payload = { + "job_ids": job_ids, "action": "apply", "start_at_local": f"{future_day}T06:00", + "timezone": "Asia/Shanghai", "interval_minutes": 180, + "daily_start_time": "06:00", "daily_end_time": "00:00", + } + headers = {"Authorization": f"Bearer {settings.local_admin_token}"} if settings.local_admin_token else {} + client = TestClient(app) + preview = client.post("/api/publish/schedules/preview", json=payload, headers=headers) + saved = client.patch("/api/publish/jobs/schedule-batch", json=payload, headers=headers) + assert preview.status_code == 200 + assert saved.status_code == 200 + assert preview.json()["schedule"] == saved.json()["schedule"] + assert [item["scheduled_at_local_display"] for item in preview.json()["schedule"]] == [ + f"{future_day} 06:00", + f"{future_day} 09:00", + f"{future_day} 12:00", + f"{future_day} 15:00", + f"{future_day} 18:00", + f"{future_day} 21:00", + f"{following_day} 00:00", + f"{following_day} 06:00", + f"{following_day} 09:00", + f"{following_day} 12:00", + ] + + +def test_frontend_uses_one_selection_semantic_and_no_schedule_reload(): + template = (settings.project_root / "app" / "templates" / "publish.html").read_text(encoding="utf-8") + script = (settings.project_root / "app" / "static" / "js" / "publish-center.js").read_text(encoding="utf-8") + assert "data-publish-select" in template + assert "data-publish-schedule-checkbox" not in template + assert "data-send-job-checkbox" not in template + assert "window.location.reload" not in script + assert "/api/publish/schedules/preview" in script + assert "/api/publish/schedules/next-start" in script + assert "data-schedule-feedback" in template + assert "data-use-latest-schedule" in template + assert "正在生成预览…" in script + assert 'scheduleForm?.addEventListener("input", () =>' in script -def test_batch_schedule_time_builder_rejects_invalid_daily_window(): - with pytest.raises(ValueError, match="结束时间必须晚于"): - build_batch_schedule_times( - 1, - start_at="2026-06-25T10:00:00+08:00", - interval_hours=3, - daily_start_time="21:00", - daily_end_time="09:00", - ) +def test_run_once_module_command(tmp_path): + db_path = tmp_path / "run_once.sqlite3" + env = { + **dict(__import__("os").environ), "DATABASE_PATH": str(db_path), + "DATA_DIR": str(tmp_path / "data"), "TASKS_DIR": str(tmp_path / "tasks"), + "STORAGE_ROOT": str(tmp_path / "tasks"), "PUBLISH_SCHEDULER_ENABLED": "false", + } + result = subprocess.run( + [sys.executable, "-m", "app.publish_scheduler", "run-once"], cwd=settings.project_root, + env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, + ) + assert result.returncode == 0 + assert "matched_count" in result.stdout diff --git a/tests/test_publish_scheduler_state_machine.py b/tests/test_publish_scheduler_state_machine.py new file mode 100644 index 0000000..c6a49f1 --- /dev/null +++ b/tests/test_publish_scheduler_state_machine.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.db import database as database_module +from app.db.database import ( + _backup_publish_database_before_data_migration, + _cancel_duplicate_active_publish_jobs, + get_connection, + init_db, +) +from app.services import publish_scheduler as scheduler_module +from app.services.publish_scheduler import PublishScheduler +from app.services.publishers.base import PublishOutcome, PublishResult + + +PREFIX = "test-state-machine-" + + +@pytest.fixture(autouse=True) +def clean_state_machine_rows(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _iso(seconds: int = 0) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(timespec="seconds") + + +def _job(tmp_path: Path, *, status: str = "SCHEDULED", scheduled_in: int = -60) -> str: + suffix = uuid4().hex[:10] + task_id = f"{PREFIX}{suffix}" + clip_id = f"{PREFIX}clip-{suffix}" + job_id = f"{PREFIX}job-{suffix}" + video = tmp_path / f"{suffix}.mp4" + video.write_bytes(b"fake video") + now = _iso() + with get_connection() as connection: + connection.execute( + "INSERT INTO tasks (id, task_name, task_dir_name, platform, status, created_at, updated_at) VALUES (?, ?, ?, 'douyin', 'COMPLETED', ?, ?)", + (task_id, task_id, task_id, now, now), + ) + connection.execute( + "INSERT INTO output_clip (id, task_id, output_file_path, output_file_name, status, is_active, created_at, updated_at) VALUES (?, ?, ?, 'clip.mp4', 'completed', 1, ?, ?)", + (clip_id, task_id, str(video), now, now), + ) + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, risk_flags, scheduled_at, schedule_timezone, timezone, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'douyin', 'manual_export', 'original', ?, ?, + '测试标题', '测试正文', '测试正文', '测试', '测试', ?, ?, + 'Asia/Shanghai', 'Asia/Shanghai', ?, ?, ?) + """, + (job_id, task_id, clip_id, clip_id, str(video), str(video), json.dumps([]), _iso(scheduled_in), status, now, now), + ) + connection.commit() + return job_id + + +def _raw(job_id: str) -> dict: + with get_connection() as connection: + return dict(connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone()) + + +def _executor(result: PublishResult, calls: list[str]): + def execute(job_id: str, **_): + calls.append(job_id) + return result.as_dict() + return execute + + +def test_publish_schema_migration_contains_worker_and_review_fields(): + with get_connection() as connection: + job_columns = {row["name"] for row in connection.execute("PRAGMA table_info(publish_jobs)")} + account_columns = {row["name"] for row in connection.execute("PRAGMA table_info(publish_accounts)")} + event_table = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'publish_job_events'" + ).fetchone() + assert { + "claimed_at", "started_at", "finished_at", "max_attempts", "worker_id", + "platform_url", "needs_manual_review", "timezone", "next_attempt_at", + "execution_id", "execution_phase", "retry_of_job_id", + }.issubset(job_columns) + assert {"login_status", "login_checked_at", "login_message", "last_login_at", "auth_type"}.issubset(account_columns) + assert event_table is not None + + +def test_duplicate_cleanup_preserves_failed_and_need_review_history(): + connection = sqlite3.connect(":memory:") + connection.row_factory = sqlite3.Row + connection.execute( + """ + CREATE TABLE publish_jobs ( + id TEXT PRIMARY KEY, output_clip_id TEXT, platform TEXT, publish_mode TEXT, + status TEXT, provider_response TEXT, created_at TEXT, updated_at TEXT, + error_code TEXT, error_message TEXT, last_error TEXT + ) + """ + ) + rows = [ + ("failed-history", "clip-1", "douyin", "local_browser", "FAILED", "2026-01-01T00:00:00Z"), + ("review-history", "clip-1", "douyin", "local_browser", "NEED_REVIEW", "2026-01-02T00:00:00Z"), + ] + connection.executemany( + "INSERT INTO publish_jobs (id, output_clip_id, platform, publish_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + [(*row, row[-1]) for row in rows], + ) + + _cancel_duplicate_active_publish_jobs(connection) + + statuses = dict(connection.execute("SELECT id, status FROM publish_jobs").fetchall()) + connection.close() + assert statuses == {"failed-history": "FAILED", "review-history": "NEED_REVIEW"} + + +def test_migration_backup_ignores_failed_and_need_review_retry_pair(monkeypatch, tmp_path): + database_path = tmp_path / "workflow.sqlite3" + connection = sqlite3.connect(database_path) + connection.row_factory = sqlite3.Row + connection.execute( + """ + CREATE TABLE publish_jobs ( + id TEXT PRIMARY KEY, output_clip_id TEXT, platform TEXT, publish_mode TEXT, + status TEXT + ) + """ + ) + connection.executemany( + """ + INSERT INTO publish_jobs (id, output_clip_id, platform, publish_mode, status) + VALUES (?, 'clip-1', 'douyin', 'local_browser', ?) + """, + [("failed-history", "FAILED"), ("review-retry", "NEED_REVIEW")], + ) + connection.commit() + monkeypatch.setattr( + database_module, + "settings", + SimpleNamespace(database_path=database_path, data_dir=tmp_path), + ) + + _backup_publish_database_before_data_migration(connection) + + connection.close() + assert not (tmp_path / "backups").exists() + + +def test_not_due_job_is_not_claimed(tmp_path): + calls: list[str] = [] + job_id = _job(tmp_path, scheduled_in=3600) + PublishScheduler(executor=_executor(PublishResult(PublishOutcome.PUBLISHED), calls)).run_once() + assert calls == [] + assert _raw(job_id)["status"] == "SCHEDULED" + + +def test_cancel_send_returns_job_to_preparation_and_clears_schedule(tmp_path): + job_id = _job(tmp_path, status="SCHEDULED", scheduled_in=3600) + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs + SET next_attempt_at = ?, claimed_at = ?, finished_at = ?, + execution_id = 'old-execution', execution_phase = 'received', + error_code = 'old-error', error_message = '旧错误', last_error = '旧错误', + needs_manual_review = 1 + WHERE id = ? + """, + (_iso(3600), _iso(), _iso(), job_id), + ) + connection.commit() + + result = PublishScheduler().cancel_job(job_id) + job = _raw(job_id) + + assert result["job"]["status"] == "WAITING" + assert "返回内容准备" in result["message"] + assert job["scheduled_at"] == "" + assert job["next_attempt_at"] is None + assert job["claimed_at"] is None + assert job["finished_at"] is None + assert job["execution_id"] is None + assert job["execution_phase"] == "" + assert job["error_code"] == "" + assert job["error_message"] == "" + assert job["needs_manual_review"] == 0 + with get_connection() as connection: + event = connection.execute( + "SELECT * FROM publish_job_events WHERE job_id = ? ORDER BY id DESC LIMIT 1", + (job_id,), + ).fetchone() + assert event["event_type"] == "returned_to_preparation" + assert event["from_status"] == "SCHEDULED" + assert event["to_status"] == "WAITING" + + +def test_skip_remains_terminal_cancelled(tmp_path): + job_id = _job(tmp_path, status="WAITING") + + PublishScheduler().skip_job(job_id) + + assert _raw(job_id)["status"] == "CANCELLED" + assert _raw(job_id)["error_message"] == "用户跳过任务" + + +def test_legacy_user_cancel_is_restored_on_database_init(tmp_path): + job_id = _job(tmp_path, status="CANCELLED", scheduled_in=3600) + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs + SET error_code = '', error_message = '用户取消任务', last_error = '用户取消任务' + WHERE id = ? + """, + (job_id,), + ) + connection.commit() + + init_db() + job = _raw(job_id) + + assert job["status"] == "WAITING" + assert job["scheduled_at"] == "" + assert job["error_message"] == "" + with get_connection() as connection: + event = connection.execute( + "SELECT event_type FROM publish_job_events WHERE job_id = ? ORDER BY id DESC LIMIT 1", + (job_id,), + ).fetchone() + assert event["event_type"] == "legacy_cancel_restored" + + +def test_retry_returns_clear_error_when_same_clip_has_active_replacement(tmp_path): + source_id = _job(tmp_path, status="FAILED") + scheduler = PublishScheduler() + replacement = scheduler.retry_failed(source_id, visibility="private") + + with pytest.raises(ValueError, match="已有任务"): + scheduler.retry_failed(source_id, visibility="private") + + assert _raw(source_id)["status"] == "FAILED" + assert _raw(replacement["job_id"])["status"] == "SCHEDULED" + + +@pytest.mark.parametrize( + ("outcome", "expected"), + [ + (PublishOutcome.PUBLISHED, "PUBLISHED"), + (PublishOutcome.FAILED, "FAILED"), + (PublishOutcome.NEED_REVIEW, "NEED_REVIEW"), + ], +) +def test_due_job_follows_publisher_outcome(tmp_path, outcome, expected): + calls: list[str] = [] + job_id = _job(tmp_path) + result = PublishResult( + outcome=outcome, + message="mock result", + remote_video_id="remote-1" if outcome == PublishOutcome.PUBLISHED else "", + platform_url="https://www.douyin.com/video/1" if outcome == PublishOutcome.PUBLISHED else "", + error_code="mock_error" if outcome != PublishOutcome.PUBLISHED else "", + needs_manual_review=outcome == PublishOutcome.NEED_REVIEW, + ) + PublishScheduler(executor=_executor(result, calls)).run_once() + row = _raw(job_id) + assert calls == [job_id] + assert row["status"] == expected + assert row["claimed_at"] + assert row["finished_at"] + + +def test_published_job_is_never_executed_again(tmp_path): + calls: list[str] = [] + job_id = _job(tmp_path, status="PUBLISHED") + result = PublishScheduler(executor=_executor(PublishResult(PublishOutcome.PUBLISHED), calls)).execute_job(job_id) + assert result["status"] == "skipped" + assert calls == [] + + +def test_two_schedulers_atomically_claim_only_once(tmp_path): + calls: list[str] = [] + job_id = _job(tmp_path) + executor = _executor(PublishResult(PublishOutcome.PUBLISHED), calls) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: PublishScheduler(executor=executor).execute_job(job_id), range(2))) + assert calls == [job_id] + assert sorted(item["status"] for item in results) == ["published", "skipped"] + + +def test_manual_retry_creates_new_task_and_keeps_failed_history(tmp_path): + old_id = _job(tmp_path, status="FAILED") + created = PublishScheduler().retry_failed(old_id, visibility="private") + assert _raw(old_id)["status"] == "FAILED" + assert created["job_id"] != old_id + assert _raw(created["job_id"])["retry_of_job_id"] == old_id + assert _raw(created["job_id"])["status"] == "SCHEDULED" + assert _raw(created["job_id"])["visibility"] == "private" + + +def test_run_forever_retries_after_transient_database_error(monkeypatch): + scheduler = PublishScheduler(interval_seconds=1) + attempts: list[int] = [] + + def flaky_run_once(): + attempts.append(len(attempts) + 1) + if len(attempts) == 1: + raise sqlite3.OperationalError("database temporarily unavailable") + scheduler._record_scan_success(datetime.now(timezone.utc)) + scheduler.stop() + return {"status": "ok"} + + monkeypatch.setattr(scheduler, "run_once", flaky_run_once) + asyncio.run(asyncio.wait_for(scheduler.run_forever(), timeout=3)) + + assert attempts == [1, 2] + assert scheduler_module._SCHEDULER_HEALTH["running"] is False + assert scheduler_module._SCHEDULER_HEALTH["consecutive_failures"] == 0 + assert scheduler_module._SCHEDULER_HEALTH["last_error_code"] == "" + + +def test_unexpected_job_error_does_not_block_later_due_jobs(monkeypatch): + scheduler = PublishScheduler() + calls: list[str] = [] + monkeypatch.setattr(scheduler, "recover_interrupted_jobs", lambda: 0) + monkeypatch.setattr( + scheduler, + "list_due_jobs", + lambda: [{"id": "broken-job"}, {"id": "later-job"}], + ) + + def execute(job_id: str): + calls.append(job_id) + if job_id == "broken-job": + raise ValueError("broken") + return {"status": "skipped", "job_id": job_id} + + monkeypatch.setattr(scheduler, "execute_job", execute) + monkeypatch.setattr( + scheduler, + "_mark_need_review", + lambda job_id, error_code, message: { + "status": "need_review", + "job_id": job_id, + "error_code": error_code, + "message": message, + }, + ) + + result = scheduler.run_once() + + assert calls == ["broken-job", "later-job"] + assert result["need_review_count"] == 1 + assert result["skipped_count"] == 1 + + +def test_terminal_result_rolls_back_when_job_state_changed(tmp_path): + job_id = _job(tmp_path, status="PUBLISHED") + with get_connection() as connection: + connection.execute( + "UPDATE publish_jobs SET provider_response = ? WHERE id = ?", + ('{"original": true}', job_id), + ) + connection.commit() + + result = PublishScheduler()._mark_published( + job_id, + PublishResult( + outcome=PublishOutcome.PUBLISHED, + message="新结果", + provider_response={"replacement": True}, + ), + ) + + assert result["status"] == "skipped" + assert _raw(job_id)["provider_response"] == '{"original": true}' diff --git a/tests/test_publish_task_grouping.py b/tests/test_publish_task_grouping.py new file mode 100644 index 0000000..0184718 --- /dev/null +++ b/tests/test_publish_task_grouping.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.db.database import get_connection, init_db +from app.main import app +from app.services import publish_service +from app.services.auto_publish_service import create_auto_publish_jobs + + +PREFIX = "test-publish-group-" + + +@pytest.fixture(autouse=True) +def clean_publish_group_data(): + init_db() + _cleanup() + yield + _cleanup() + + +def _cleanup() -> None: + with get_connection() as connection: + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _time(minutes: int = 0) -> str: + return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat(timespec="seconds") + + +def _insert_task(tmp_path: Path, name: str, *, created_at: str) -> tuple[str, Path]: + task_id = f"{PREFIX}{uuid4().hex[:8]}" + source_path = tmp_path / f"{name}-原视频.mp4" + source_path.write_bytes(b"source-video") + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks ( + id, task_name, task_dir_name, source_type, platform, original_video_path, + status, created_at, updated_at + ) VALUES (?, ?, ?, 'upload', 'general', ?, 'completed', ?, ?) + """, + (task_id, name, task_id, str(source_path), created_at, created_at), + ) + connection.commit() + return task_id, source_path + + +def _insert_clip_job( + tmp_path: Path, + task_id: str, + *, + platform: str, + status: str, + created_at: str, + publish_mode: str = "local_browser", + clip_id: str | None = None, +) -> tuple[str, str, Path]: + output_clip_id = clip_id or f"{PREFIX}clip-{uuid4().hex[:8]}" + job_id = f"{PREFIX}job-{uuid4().hex[:8]}" + clip_path = tmp_path / f"{output_clip_id}.mp4" + clip_path.write_bytes(b"cut-video") + with get_connection() as connection: + existing_clip = connection.execute("SELECT id FROM output_clip WHERE id = ?", (output_clip_id,)).fetchone() + if not existing_clip: + connection.execute( + """ + INSERT INTO output_clip ( + id, task_id, output_file_path, output_file_name, status, + is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'completed', 1, ?, ?) + """, + (output_clip_id, task_id, str(clip_path), clip_path.name, created_at, created_at), + ) + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, scheduled_at, schedule_timezone, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'original', ?, ?, ?, '测试正文', '测试正文', + '测试', '测试', ?, 'Asia/Shanghai', ?, ?, ?) + """, + ( + job_id, + task_id, + output_clip_id, + output_clip_id, + platform, + publish_mode, + str(clip_path), + str(clip_path), + f"{platform}测试片段", + _time(60) if status == "SCHEDULED" else "", + status, + created_at, + created_at, + ), + ) + connection.commit() + return job_id, output_clip_id, clip_path + + +def _raw_job(job_id: str) -> dict: + with get_connection() as connection: + row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + return dict(row) + + +def test_publish_jobs_are_grouped_by_newest_source_task(tmp_path: Path) -> None: + older_task, older_source = _insert_task(tmp_path, "较早任务", created_at=_time(-20)) + newer_task, newer_source = _insert_task(tmp_path, "最新任务", created_at=_time(-10)) + _insert_clip_job(tmp_path, older_task, platform="douyin", status="WAITING", created_at=_time(-19)) + _insert_clip_job(tmp_path, newer_task, platform="douyin", status="WAITING", created_at=_time(-9)) + _insert_clip_job(tmp_path, newer_task, platform="bilibili", status="WAITING", created_at=_time(-8)) + + jobs = [ + job + for job in publish_service.list_publish_jobs( + limit=None, + worker_state={"worker_available": True, "worker_message": ""}, + ) + if str(job.get("task_id") or "").startswith(PREFIX) + ] + groups = publish_service._build_publish_task_groups(jobs) + + assert [group["task_id"] for group in groups] == [newer_task, older_task] + assert groups[0]["task_name"] == "最新任务" + assert groups[0]["task_source_file_name"] == newer_source.name + assert groups[1]["task_source_file_name"] == older_source.name + assert [job["platform"] for job in groups[0]["jobs"]] == ["douyin", "bilibili"] + + +def test_publish_page_renders_task_identity_without_full_source_path(tmp_path: Path) -> None: + task_id, source_path = _insert_task(tmp_path, "页面归类任务", created_at=_time(-5)) + _insert_clip_job(tmp_path, task_id, platform="douyin", status="WAITING", created_at=_time(-4)) + + response = TestClient(app).get("/publish") + html = response.text + + assert response.status_code == 200 + assert 'data-publish-task-group' in html + assert "页面归类任务" in html + assert source_path.name in html + assert str(source_path) not in html + assert "移出内容准备" in html + + +def test_dismiss_keeps_files_and_other_platform_and_blocks_recreation(tmp_path: Path) -> None: + task_id, _ = _insert_task(tmp_path, "安全移出任务", created_at=_time(-5)) + douyin_job, clip_id, clip_path = _insert_clip_job( + tmp_path, + task_id, + platform="douyin", + status="SCHEDULED", + created_at=_time(-4), + ) + bilibili_job, _, _ = _insert_clip_job( + tmp_path, + task_id, + platform="bilibili", + status="WAITING", + created_at=_time(-3), + clip_id=clip_id, + ) + + dismissed = publish_service.dismiss_publish_job(douyin_job) + raw_douyin = _raw_job(douyin_job) + + assert dismissed["job"]["status"] == "CANCELLED" + assert dismissed["job"]["is_user_removed"] is True + assert raw_douyin["error_code"] == publish_service.USER_REMOVED_ERROR_CODE + assert raw_douyin["scheduled_at"] == "" + assert _raw_job(bilibili_job)["status"] == "WAITING" + assert clip_path.exists() + + refreshed = publish_service.refresh_send_queue(use_ai=False, platform="douyin") + assert refreshed["skipped_removed"] >= 1 + assert not any(job.get("output_clip_id") == clip_id for job in refreshed["created"]) + + auto_result = create_auto_publish_jobs( + {"id": task_id, "platform": "douyin"}, + [ + { + "output_clip": {"id": clip_id, "output_file_path": str(clip_path)}, + "metadata": { + "platform": "douyin", + "title": "不会重建", + "caption": "不会重建正文", + "hashtags": ["测试"], + "risk_flags": [], + }, + "scheduled_at": "", + } + ], + ) + assert auto_result["created_count"] == 0 + assert auto_result["skipped_count"] == 1 + + with get_connection() as connection: + events = connection.execute( + "SELECT event_type FROM publish_job_events WHERE job_id = ? ORDER BY id", + (douyin_job,), + ).fetchall() + assert [event["event_type"] for event in events] == ["removed_from_preparation"] + + +def test_restore_returns_to_waiting_and_rejects_active_duplicate(tmp_path: Path) -> None: + task_id, _ = _insert_task(tmp_path, "恢复任务", created_at=_time(-5)) + job_id, clip_id, _ = _insert_clip_job( + tmp_path, + task_id, + platform="douyin", + status="WAITING", + created_at=_time(-4), + ) + publish_service.dismiss_publish_job(job_id) + + restored = publish_service.restore_publish_job(job_id) + assert restored["job"]["status"] == "WAITING" + assert restored["job"]["is_user_removed"] is False + assert _raw_job(job_id)["error_code"] == "" + + publish_service.dismiss_publish_job(job_id) + _insert_clip_job( + tmp_path, + task_id, + platform="douyin", + status="WAITING", + created_at=_time(-2), + publish_mode="manual_export", + clip_id=clip_id, + ) + with pytest.raises(ValueError, match="已有有效发布内容"): + publish_service.restore_publish_job(job_id) + + +def test_dismiss_and_restore_routes_are_available() -> None: + route_paths = set(app.openapi()["paths"]) + assert "/api/publish/jobs/{job_id}/dismiss" in route_paths + assert "/api/publish/jobs/{job_id}/restore" in route_paths + assert settings.database_path.name == "test_workflow.sqlite3" diff --git a/tests/test_publish_task_linkage.py b/tests/test_publish_task_linkage.py new file mode 100644 index 0000000..350ad8a --- /dev/null +++ b/tests/test_publish_task_linkage.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest + +from app.db.database import get_connection, init_db +from app.main import app +from app.services import publish_service + + +PREFIX = "test-publish-link-" + + +@pytest.fixture(autouse=True) +def clean_publish_link_data(): + init_db() + _cleanup() + yield + _cleanup() + + +@pytest.fixture +def fake_cover(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + cover_path = tmp_path / "cover.jpg" + cover_path.write_bytes(b"cover") + + def generate_cover(_item: dict, _video_source: str = "original") -> dict: + return { + "cover_file_path": str(cover_path), + "cover_time_seconds": 1.5, + } + + monkeypatch.setattr(publish_service, "_generate_default_publish_cover", generate_cover) + return cover_path + + +def _cleanup() -> None: + with get_connection() as connection: + job_rows = connection.execute( + "SELECT id FROM publish_jobs WHERE task_id LIKE ?", + (f"{PREFIX}%",), + ).fetchall() + if job_rows: + placeholders = ",".join("?" for _ in job_rows) + connection.execute( + f"DELETE FROM publish_job_events WHERE job_id IN ({placeholders})", + [row["id"] for row in job_rows], + ) + connection.execute("DELETE FROM publish_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM subtitle_jobs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM output_clip WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _time(minutes: int = 0) -> str: + return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat(timespec="seconds") + + +def _insert_task(tmp_path: Path, name: str = "关联测试", platform: str = "general") -> str: + task_id = f"{PREFIX}{uuid4().hex[:8]}" + source_path = tmp_path / f"{task_id}-source.mp4" + source_path.write_bytes(b"source") + now = _time() + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks ( + id, task_name, task_dir_name, source_type, platform, + original_video_path, status, created_at, updated_at + ) VALUES (?, ?, ?, 'upload', ?, ?, 'completed', ?, ?) + """, + (task_id, name, task_id, platform, str(source_path), now, now), + ) + connection.commit() + return task_id + + +def _insert_candidate(task_id: str, suffix: str) -> str: + candidate_id = f"{PREFIX}candidate-{suffix}-{uuid4().hex[:6]}" + now = _time() + with get_connection() as connection: + connection.execute( + """ + INSERT INTO clip_candidates ( + id, task_id, title, start_time, end_time, duration_seconds, + summary, created_at, updated_at + ) VALUES (?, ?, ?, '00:00:01', '00:00:11', 10, ?, ?, ?) + """, + (candidate_id, task_id, f"片段 {suffix}", f"片段 {suffix} 摘要", now, now), + ) + connection.commit() + return candidate_id + + +def _insert_output( + tmp_path: Path, + task_id: str, + candidate_id: str, + suffix: str, + *, + active: bool, +) -> tuple[str, Path]: + output_id = f"{PREFIX}output-{suffix}-{uuid4().hex[:6]}" + output_path = tmp_path / f"{output_id}.mp4" + output_path.write_bytes(b"clip") + now = _time() + with get_connection() as connection: + connection.execute( + """ + INSERT INTO output_clip ( + id, task_id, clip_candidate_id, output_file_path, output_file_name, + status, is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'completed', ?, ?, ?) + """, + ( + output_id, + task_id, + candidate_id, + str(output_path), + output_path.name, + int(active), + now, + now, + ), + ) + connection.commit() + return output_id, output_path + + +def _insert_job( + task_id: str, + output_id: str, + platform: str, + status: str, + *, + title: str = "旧版标题", + scheduled_at: str = "", + error_code: str = "", + created_at: str | None = None, + publish_mode: str = "local_browser", +) -> str: + job_id = f"{PREFIX}job-{uuid4().hex[:8]}" + created_at = created_at or _time() + with get_connection() as connection: + output = connection.execute( + "SELECT output_file_path FROM output_clip WHERE id = ?", + (output_id,), + ).fetchone() + connection.execute( + """ + INSERT INTO publish_jobs ( + id, task_id, output_clip_id, clip_id, platform, publish_mode, + video_source, video_file_path, video_path, title, description, caption, + tags, hashtags, scheduled_at, schedule_timezone, status, error_code, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'original', ?, ?, ?, '旧简介', + '旧简介', '旧标签', '旧标签', ?, 'Asia/Shanghai', ?, ?, ?, ?) + """, + ( + job_id, + task_id, + output_id, + output_id, + platform, + publish_mode, + output["output_file_path"], + output["output_file_path"], + title, + scheduled_at, + status, + error_code, + created_at, + created_at, + ), + ) + connection.commit() + return job_id + + +def _job(job_id: str) -> dict: + with get_connection() as connection: + row = connection.execute("SELECT * FROM publish_jobs WHERE id = ?", (job_id,)).fetchone() + return dict(row) + + +def test_first_sync_creates_both_platforms_and_is_idempotent( + tmp_path: Path, + fake_cover: Path, +) -> None: + task_id = _insert_task(tmp_path) + for suffix in ("one", "two"): + candidate_id = _insert_candidate(task_id, suffix) + _insert_output(tmp_path, task_id, candidate_id, suffix, active=True) + + first = publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=False, + ) + second = publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=False, + ) + + assert first["created_count"] == 4 + assert first["link_state"]["linked_count"] == 4 + assert first["link_state"]["missing_count"] == 0 + assert second["created_count"] == 0 + assert second["skipped_count"] == 4 + with get_connection() as connection: + rows = connection.execute( + """ + SELECT platform, status, scheduled_at, video_source + FROM publish_jobs WHERE task_id = ? ORDER BY platform + """, + (task_id,), + ).fetchall() + assert len(rows) == 4 + assert {row["platform"] for row in rows} == {"douyin", "bilibili"} + assert {row["status"] for row in rows} == {"WAITING"} + assert all(not row["scheduled_at"] for row in rows) + assert {row["video_source"] for row in rows} == {"original"} + + +def test_recut_cancels_only_old_preparation_and_preserves_execution_evidence( + tmp_path: Path, + fake_cover: Path, +) -> None: + task_id = _insert_task(tmp_path) + candidate_id = _insert_candidate(task_id, "recut") + old_output_id, _ = _insert_output(tmp_path, task_id, candidate_id, "old", active=False) + new_output_id, _ = _insert_output(tmp_path, task_id, candidate_id, "new", active=True) + waiting_id = _insert_job(task_id, old_output_id, "douyin", "WAITING", title="要继承的标题") + scheduled_id = _insert_job( + task_id, + old_output_id, + "bilibili", + "SCHEDULED", + scheduled_at=_time(60), + ) + published_id = _insert_job( + task_id, + old_output_id, + "douyin", + "PUBLISHED", + created_at=_time(-20), + publish_mode="manual_export", + ) + review_id = _insert_job( + task_id, + old_output_id, + "bilibili", + "NEED_REVIEW", + created_at=_time(-20), + publish_mode="manual_export", + ) + failed_id = _insert_job( + task_id, + old_output_id, + "douyin", + "FAILED", + created_at=_time(-30), + publish_mode="opencli_publish", + ) + + result = publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=False, + ) + + assert result["created_count"] == 2 + assert result["superseded_count"] == 2 + assert _job(waiting_id)["status"] == "CANCELLED" + assert _job(waiting_id)["error_code"] == publish_service.SUPERSEDED_BY_RECUT_ERROR_CODE + assert _job(scheduled_id)["status"] == "CANCELLED" + assert _job(scheduled_id)["scheduled_at"] == "" + assert _job(published_id)["status"] == "PUBLISHED" + assert _job(review_id)["status"] == "NEED_REVIEW" + assert _job(failed_id)["status"] == "FAILED" + with get_connection() as connection: + new_jobs = connection.execute( + "SELECT * FROM publish_jobs WHERE output_clip_id = ? ORDER BY platform", + (new_output_id,), + ).fetchall() + events = connection.execute( + """ + SELECT event_type FROM publish_job_events + WHERE job_id IN (?, ?) ORDER BY id + """, + (waiting_id, scheduled_id), + ).fetchall() + assert len(new_jobs) == 2 + assert {row["status"] for row in new_jobs} == {"WAITING"} + assert all(not row["scheduled_at"] for row in new_jobs) + assert [row["event_type"] for row in events] == [ + "superseded_by_recut", + "superseded_by_recut", + ] + + +def test_explicit_sync_restores_user_removed_content( + tmp_path: Path, + fake_cover: Path, +) -> None: + task_id = _insert_task(tmp_path, platform="douyin") + candidate_id = _insert_candidate(task_id, "restore") + output_id, _ = _insert_output(tmp_path, task_id, candidate_id, "restore", active=True) + job_id = _insert_job(task_id, output_id, "douyin", "WAITING") + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', + scheduled_at = NULL, + error_code = ?, + error_message = '用户主动移出发送中心', + updated_at = ? + WHERE id = ? + """, + (publish_service.USER_REMOVED_ERROR_CODE, _time(), job_id), + ) + connection.commit() + + automatic = publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=False, + ) + explicit = publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=True, + ) + + assert automatic["created_count"] == 0 + assert automatic["restored_count"] == 0 + assert automatic["link_state"]["removed_count"] == 1 + assert explicit["restored_count"] == 1 + assert _job(job_id)["status"] == "WAITING" + assert _job(job_id)["scheduled_at"] == "" + + +def test_subtitle_sync_updates_only_unscheduled_video_sources( + tmp_path: Path, + fake_cover: Path, +) -> None: + task_id = _insert_task(tmp_path) + candidate_id = _insert_candidate(task_id, "subtitle") + output_id, _ = _insert_output(tmp_path, task_id, candidate_id, "subtitle", active=True) + subtitled_path = tmp_path / f"{output_id}-subtitled.mp4" + subtitled_path.write_bytes(b"subtitled") + now = _time() + with get_connection() as connection: + connection.execute( + """ + INSERT INTO subtitle_jobs ( + id, task_id, output_clip_id, status, output_file_path, + is_active, created_at, updated_at + ) VALUES (?, ?, ?, 'completed', ?, 1, ?, ?) + """, + ( + f"{PREFIX}subtitle-{uuid4().hex[:8]}", + task_id, + output_id, + str(subtitled_path), + now, + now, + ), + ) + connection.commit() + + publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=False, + ) + with get_connection() as connection: + connection.execute( + """ + UPDATE publish_jobs SET status = 'SCHEDULED', scheduled_at = ? + WHERE task_id = ? AND platform = 'bilibili' + """, + (_time(60), task_id), + ) + connection.commit() + + result = publish_service.sync_task_publish_jobs( + task_id, + prefer_subtitled=True, + restore_removed=True, + ) + with get_connection() as connection: + rows = connection.execute( + """ + SELECT platform, status, video_source, video_file_path + FROM publish_jobs WHERE task_id = ? ORDER BY platform + """, + (task_id,), + ).fetchall() + jobs = {row["platform"]: dict(row) for row in rows} + + assert result["updated_count"] == 1 + assert len(result["warnings"]) == 1 + assert jobs["douyin"]["video_source"] == "subtitled" + assert jobs["douyin"]["video_file_path"] == str(subtitled_path) + assert jobs["bilibili"]["status"] == "SCHEDULED" + assert jobs["bilibili"]["video_source"] == "original" + + +def test_task_sync_routes_are_available() -> None: + route_paths = set(app.openapi()["paths"]) + assert "/api/publish/tasks/{task_id}/link-state" in route_paths + assert "/api/publish/tasks/{task_id}/sync" in route_paths diff --git a/tests/test_publish_timezones.py b/tests/test_publish_timezones.py new file mode 100644 index 0000000..80130b5 --- /dev/null +++ b/tests/test_publish_timezones.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from app.services.publish_time import build_schedule_times, ensure_future, parse_datetime, to_utc_iso, utc_now + + +def test_naive_datetime_is_interpreted_as_beijing_time(): + parsed = parse_datetime("2026-07-16T09:00", "Asia/Shanghai") + assert parsed.isoformat() == "2026-07-16T09:00:00+08:00" + assert to_utc_iso(parsed) == "2026-07-16T01:00:00+00:00" + + +def test_aware_datetime_is_normalized_to_utc_with_offset(): + assert to_utc_iso("2026-07-16T09:00:00+08:00") == "2026-07-16T01:00:00+00:00" + + +def test_past_datetime_is_rejected(): + past = (utc_now() - timedelta(minutes=1)).isoformat() + with pytest.raises(ValueError, match="必须晚于当前时间"): + ensure_future(past, "Asia/Shanghai") + + +def test_batch_schedule_rolls_to_next_beijing_day(): + assert build_schedule_times( + 3, + start_at_local="2026-07-16T20:00", + timezone_name="Asia/Shanghai", + interval_minutes=180, + daily_start_time="09:00", + daily_end_time="21:00", + reject_past=False, + ) == [ + "2026-07-16T12:00:00+00:00", + "2026-07-17T01:00:00+00:00", + "2026-07-17T04:00:00+00:00", + ] + + +def test_cross_midnight_window_keeps_first_time_and_includes_midnight(): + assert build_schedule_times( + 10, + start_at_local="2026-07-28T06:00", + timezone_name="Asia/Shanghai", + interval_minutes=180, + daily_start_time="06:00", + daily_end_time="00:00", + reject_past=False, + ) == [ + "2026-07-27T22:00:00+00:00", + "2026-07-28T01:00:00+00:00", + "2026-07-28T04:00:00+00:00", + "2026-07-28T07:00:00+00:00", + "2026-07-28T10:00:00+00:00", + "2026-07-28T13:00:00+00:00", + "2026-07-28T16:00:00+00:00", + "2026-07-28T22:00:00+00:00", + "2026-07-29T01:00:00+00:00", + "2026-07-29T04:00:00+00:00", + ] + + +def test_first_schedule_time_is_not_rewritten_by_daily_window(): + assert build_schedule_times( + 2, + start_at_local="2026-07-16T22:00", + timezone_name="Asia/Shanghai", + interval_minutes=180, + daily_start_time="09:00", + daily_end_time="21:00", + reject_past=False, + ) == [ + "2026-07-16T14:00:00+00:00", + "2026-07-17T01:00:00+00:00", + ] + + +def test_equal_daily_window_times_mean_all_day(): + assert build_schedule_times( + 3, + start_at_local="2026-07-16T22:00", + timezone_name="Asia/Shanghai", + interval_minutes=180, + daily_start_time="00:00", + daily_end_time="00:00", + reject_past=False, + ) == [ + "2026-07-16T14:00:00+00:00", + "2026-07-16T17:00:00+00:00", + "2026-07-16T20:00:00+00:00", + ] diff --git a/tests/test_publish_worker_autostart.py b/tests/test_publish_worker_autostart.py new file mode 100644 index 0000000..c42b118 --- /dev/null +++ b/tests/test_publish_worker_autostart.py @@ -0,0 +1,54 @@ +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _read(relative_path: str) -> str: + return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_docker_watcher_targets_only_current_compose_project(): + watcher = _read("scripts/watch_docker_publish_worker.ps1") + + assert "$ContainerName = 'niuma-studio'" in watcher + assert "$ComposeProject = 'niuma-studio'" in watcher + assert "$ComposeService = 'workflow'" in watcher + assert "com.docker.compose.project.working_dir" in watcher + assert "$labelRoot -ieq $ProjectRoot" in watcher + assert "$StopGraceSeconds = [Math]::Max(5, $StopGraceSeconds)" in watcher + assert "start_publish_worker.ps1" in watcher + assert "Get-OwnedWorkerProcessIds" in watcher + + +def test_watcher_installation_is_reversible_and_migrates_only_owned_legacy_task(): + installer = _read("scripts/install_docker_publish_worker_watcher.ps1") + uninstaller = _read("scripts/uninstall_docker_publish_worker_watcher.ps1") + + assert "NiuMa Studio Docker Watcher" in installer + assert "New-ScheduledTaskTrigger -AtLogOn" in installer + assert "MultipleInstances IgnoreNew" in installer + assert "NiuMa Studio OpenCLI Host Bridge" in installer + assert "Test-TaskBelongsToProject" in installer + assert "Unregister-ScheduledTask" in uninstaller + assert "Database, task files, Chrome profiles, and logs were preserved" in uninstaller + + +def test_publish_center_no_longer_requests_manual_start_command(): + template = _read("app/templates/publish.html") + javascript = _read("app/static/js/publish-center.js") + worker_client = _read("app/services/publishers/worker_client.py") + + assert r".\scripts\start_niuma_studio.ps1" not in template + assert "发送服务会在 Docker 中的牛马片场项目运行后自动启动" in template + assert "随 Docker 项目自动启动" in javascript + assert r".\scripts\start_niuma_studio.ps1" not in worker_client + + +def test_windows_worker_has_no_sqlite_repository_dependency(): + worker = _read("scripts/publish_host_worker.py") + + assert "PublishRepository" not in worker + assert "get_connection" not in worker + assert "update_account_status" not in worker + assert "update_execution_phase" not in worker diff --git a/tests/test_publish_worker_client.py b/tests/test_publish_worker_client.py new file mode 100644 index 0000000..3b727c7 --- /dev/null +++ b/tests/test_publish_worker_client.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +import socket + +import pytest +from fastapi.testclient import TestClient + +from app.services.publishers.base import PublishOutcome, PublishWorkerUnavailable +from app.services.publishers.worker_client import PublishWorkerClient +from app.core.config import settings +from scripts.publish_host_worker import ExecutionJournal, _resolve_media_path, create_worker_app + + +class FakeResponse: + def __init__(self, payload: dict) -> None: + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def read(self) -> bytes: + return json.dumps(self.payload).encode("utf-8") + + +def test_worker_health_does_not_require_token(): + response = TestClient(create_worker_app(token="test-token")).get("/health") + assert response.status_code == 200 + assert response.json()["worker"] == "windows_chrome" + assert response.json()["token_configured"] is True + + +def test_protected_worker_health_requires_matching_token(): + client = TestClient(create_worker_app(token="test-token")) + assert client.get("/v1/health").status_code == 401 + assert client.get( + "/v1/health", + headers={"Authorization": "Bearer wrong-token"}, + ).status_code == 401 + response = client.get( + "/v1/health", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + assert response.json()["worker"] == "windows_chrome" + + +def test_protected_worker_endpoint_rejects_invalid_token(): + response = TestClient(create_worker_app(token="test-token")).post( + "/v1/accounts/check", + headers={"Authorization": "Bearer wrong-token"}, + json={"platform": "douyin", "account_id": "account-1"}, + ) + assert response.status_code == 401 + + +def test_worker_client_sends_bearer_token_and_converts_result(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["authorization"] = request.headers.get("Authorization") + captured["timeout"] = timeout + return FakeResponse({ + "outcome": "PUBLISHED", + "message": "成功", + "remote_video_id": "BV123", + "platform_url": "https://www.bilibili.com/video/BV123", + }) + + monkeypatch.setattr("app.services.publishers.worker_client.urlopen", fake_urlopen) + result = PublishWorkerClient("http://127.0.0.1:8765", "secret-token", 7).publish({"job_id": "job-1"}) + assert result.outcome == PublishOutcome.PUBLISHED + assert result.remote_video_id == "BV123" + assert captured == {"authorization": "Bearer secret-token", "timeout": 7} + + +def test_worker_timeout_is_marked_as_possibly_received(monkeypatch): + def timeout(*_, **__): + raise socket.timeout("timed out") + + monkeypatch.setattr("app.services.publishers.worker_client.urlopen", timeout) + with pytest.raises(PublishWorkerUnavailable) as caught: + PublishWorkerClient("http://127.0.0.1:8765", "token", 2).publish({"job_id": "job-1"}) + assert caught.value.request_may_have_been_received is True + + +def test_worker_offline_before_connection_is_safe_retry(monkeypatch): + def offline(*_, **__): + raise OSError("connection refused") + + monkeypatch.setattr("app.services.publishers.worker_client.urlopen", offline) + with pytest.raises(PublishWorkerUnavailable) as caught: + PublishWorkerClient("http://127.0.0.1:8765", "token", 2).health() + assert caught.value.request_may_have_been_received is False + assert "随 Docker 中的牛马片场项目自动启动" in caught.value.message + assert r".\scripts" not in caught.value.message + + +def test_worker_client_uses_protected_health_endpoint(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["authorization"] = request.headers.get("Authorization") + return FakeResponse({"status": "ok", "worker": "windows_chrome"}) + + monkeypatch.setattr("app.services.publishers.worker_client.urlopen", fake_urlopen) + result = PublishWorkerClient("http://127.0.0.1:8765", "secret-token", 2).health() + assert result["status"] == "ok" + assert captured == { + "url": "http://127.0.0.1:8765/v1/health", + "authorization": "Bearer secret-token", + } + + +def test_worker_maps_docker_tasks_path_to_windows_storage(tmp_path): + video = tmp_path / "project-a" / "05_clips" / "clip.mp4" + video.parent.mkdir(parents=True) + video.write_bytes(b"fake video") + original_tasks_dir = settings.tasks_dir + original_allowed_roots = settings.publish_worker_allowed_roots + object.__setattr__(settings, "tasks_dir", tmp_path) + object.__setattr__(settings, "publish_worker_allowed_roots", "") + try: + resolved = _resolve_media_path( + "/workspace/tasks/project-a/05_clips/clip.mp4", + required=True, + ) + assert resolved == str(video.resolve()) + finally: + object.__setattr__(settings, "tasks_dir", original_tasks_dir) + object.__setattr__(settings, "publish_worker_allowed_roots", original_allowed_roots) + + +def test_worker_execution_journal_persists_final_result_without_database(tmp_path): + original_state_dir = settings.publish_worker_state_dir + object.__setattr__(settings, "publish_worker_state_dir", tmp_path) + try: + journal = ExecutionJournal("execution-journal-test") + journal.update( + "confirmed_success", + {"outcome": "PUBLISHED", "message": "投稿成功", "needs_manual_review": False}, + ) + stored = journal.read() + finally: + object.__setattr__(settings, "publish_worker_state_dir", original_state_dir) + + assert stored["phase"] == "confirmed_success" + assert stored["details"]["outcome"] == "PUBLISHED" + assert stored["details"]["message"] == "投稿成功" diff --git a/tests/test_publisher_registry.py b/tests/test_publisher_registry.py new file mode 100644 index 0000000..35944da --- /dev/null +++ b/tests/test_publisher_registry.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +from app.core.config import settings +from app.services.publishers.base import PublishValidationError +from app.services.publishers.bilibili import BilibiliPublisher +from app.services.publishers.douyin import DouyinPublisher +from app.services.publishers.local_browser import LocalBrowserPublisher +from app.services.publishers.manual_export import ManualExportPublisher +from app.services.publishers.registry import ( + get_platform_publisher_class, + get_publisher, + registered_modes, + registered_platforms, +) + + +def test_registry_has_only_supported_real_platforms(): + assert registered_platforms() == ("bilibili", "douyin") + assert {"local_browser", "manual_export", "opencli_publish"}.issubset(registered_modes()) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [("douyin", DouyinPublisher), ("bilibili", BilibiliPublisher)], +) +def test_platform_registry_returns_platform_specific_publisher(platform, expected): + assert get_platform_publisher_class(platform) is expected + + +@pytest.mark.parametrize("platform", ["douyin", "bilibili"]) +def test_local_browser_mode_uses_one_orchestrator(platform): + publisher = get_publisher(platform, "local_browser") + assert isinstance(publisher, LocalBrowserPublisher) + assert publisher.platform == platform + + +def test_manual_export_is_explicit_mode(): + assert isinstance(get_publisher("douyin", "manual_export"), ManualExportPublisher) + + +def test_unknown_platform_never_falls_back_to_manual_export(): + with pytest.raises(PublishValidationError) as caught: + get_publisher("xiaohongshu", "local_browser") + assert caught.value.error_code == "unregistered_platform" + + +def test_opencli_compatibility_must_be_enabled_explicitly(): + original = settings.publish_enable_opencli_fallback + object.__setattr__(settings, "publish_enable_opencli_fallback", False) + try: + with pytest.raises(PublishValidationError) as caught: + get_publisher("douyin", "opencli_publish") + assert caught.value.error_code == "opencli_fallback_disabled" + finally: + object.__setattr__(settings, "publish_enable_opencli_fallback", original) diff --git a/tests/test_split_services.py b/tests/test_split_services.py index ead4ebb..70aa1ce 100644 --- a/tests/test_split_services.py +++ b/tests/test_split_services.py @@ -15,7 +15,7 @@ from app.services.task_log_service import append_task_log, read_task_log_tail from app.services.task_lifecycle_service import ( create_task_record, - soft_delete_task, + delete_task_permanently, update_task_candidate_clip_count, update_task_status, ) @@ -138,8 +138,8 @@ def test_update_task_status_nonexistent(self): result = update_task_status("nonexistent-id", TaskStatus.completed) assert result is None - def test_soft_delete_task(self): - """软删除任务标记 is_deleted=1""" + def test_permanent_delete_task(self): + """永久删除任务文件并保留隐藏数据库记录""" payload = TaskCreate( task_name="删除测试", source_type="upload", @@ -150,12 +150,12 @@ def test_soft_delete_task(self): ) create_task_record(payload, task_id="test-delete-001") - result = soft_delete_task("test-delete-001") - assert "已隐藏" in result["message"] + result = delete_task_permanently("test-delete-001") + assert result["status"] == "deleted" + assert "永久删除" in result["message"] - # 再次删除应提示无需重复操作 - result2 = soft_delete_task("test-delete-001") - assert "无需重复" in result2["message"] + result2 = delete_task_permanently("test-delete-001") + assert result2["status"] == "already_deleted" def test_update_candidate_clip_count_invalid(self): """候选片段数量超出范围应报错""" diff --git a/tests/test_task_defaults.py b/tests/test_task_defaults.py new file mode 100644 index 0000000..96d7e02 --- /dev/null +++ b/tests/test_task_defaults.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import re + +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.db.database import get_connection, init_db +from app.main import app +from app.models.task import TaskCreate +from app.routers import tasks as tasks_router +from app.services.task_lifecycle_service import create_task_record + + +PREFIX = "test-task-defaults-" + + +def _headers() -> dict[str, str]: + if not settings.local_admin_token: + return {} + return {"Authorization": f"Bearer {settings.local_admin_token}"} + + +def test_task_create_and_upload_api_use_ten_minutes_and_twelve_candidates(monkeypatch, tmp_path): + payload = TaskCreate(task_name="默认值模型测试") + assert payload.max_clip_duration == 10 + assert payload.candidate_clip_count == 12 + + captured: dict[str, TaskCreate] = {} + saved_video = tmp_path / "source.mp4" + saved_video.write_bytes(b"fake-video") + + monkeypatch.setattr( + tasks_router, + "allocate_task_dir_name", + lambda task_name, exclude_task_id=None: "test-default-upload", + ) + monkeypatch.setattr( + tasks_router, + "save_uploaded_video", + lambda task_id, filename, source, task_dir_name: saved_video, + ) + + def fake_create_task_record( + upload_payload: TaskCreate, + task_id: str | None = None, + task_dir_name: str | None = None, + ) -> dict: + captured["payload"] = upload_payload + return { + "id": task_id, + "task_name": upload_payload.task_name, + "detail_url": f"/tasks/{task_id}", + "message": "任务已创建并写入数据库。", + } + + monkeypatch.setattr(tasks_router.task_service, "create_task_record", fake_create_task_record) + + response = TestClient(app).post( + "/api/tasks/upload", + data={"task_name": "上传默认值测试", "platform": "general"}, + files={"video_file": ("source.mp4", b"fake-video", "video/mp4")}, + headers=_headers(), + ) + + assert response.status_code == 200 + assert captured["payload"].max_clip_duration == 10 + assert captured["payload"].candidate_clip_count == 12 + + +def test_new_task_page_selects_new_defaults(): + response = TestClient(app).get("/tasks/new", headers=_headers()) + + assert response.status_code == 200 + assert re.search(r'name="max_clip_duration"[^>]*value="10"', response.text) + assert re.search(r'', response.text) + + +def test_new_defaults_persist_without_rewriting_explicit_historical_values(monkeypatch): + init_db() + default_task_id = f"{PREFIX}new" + historical_task_id = f"{PREFIX}historical" + monkeypatch.setattr( + "app.services.task_lifecycle_service.create_task_directory", + lambda task_id, task_dir_name: None, + ) + + try: + create_task_record( + TaskCreate(task_name="新默认值"), + task_id=default_task_id, + task_dir_name=default_task_id, + ) + create_task_record( + TaskCreate( + task_name="历史显式值", + max_clip_duration=5, + candidate_clip_count=5, + ), + task_id=historical_task_id, + task_dir_name=historical_task_id, + ) + + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, max_clip_duration, candidate_clip_count + FROM tasks + WHERE id IN (?, ?) + """, + (default_task_id, historical_task_id), + ).fetchall() + values = {row["id"]: dict(row) for row in rows} + + assert values[default_task_id]["max_clip_duration"] == 10 + assert values[default_task_id]["candidate_clip_count"] == 12 + assert values[historical_task_id]["max_clip_duration"] == 5 + assert values[historical_task_id]["candidate_clip_count"] == 5 + finally: + with get_connection() as connection: + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() diff --git a/tests/test_task_query_service.py b/tests/test_task_query_service.py index 5bc32a3..ebdcaec 100644 --- a/tests/test_task_query_service.py +++ b/tests/test_task_query_service.py @@ -459,7 +459,10 @@ def test_system_status_fields_complete(self): context = get_system_status_context() required_fields = [ - "storage_root", "storage_exists", "database_path", "database_exists", + "storage_root", "storage_exists", "tasks_dir", "tasks_dir_exists", + "upload_temp_dir", "upload_temp_dir_exists", + "publish_export_dir", "publish_export_dir_exists", + "database_path", "database_exists", "ffmpeg_path", "ffmpeg_available", "ffprobe_path", "ffprobe_available", "task_count", "failed_count", "pending_count", "review_count", "completed_count", "recent_errors", "ai_config", "expected_server_url", diff --git a/tests/test_variety_comedy_selection.py b/tests/test_variety_comedy_selection.py new file mode 100644 index 0000000..a38dca5 --- /dev/null +++ b/tests/test_variety_comedy_selection.py @@ -0,0 +1,471 @@ +"""康熙笑点优先 V2 的核心选片、音频与启用规则测试。""" + +from __future__ import annotations + +from array import array +import json +from pathlib import Path +import wave + +from fastapi.testclient import TestClient +import pytest + +from app.core.config import settings +from app.db.database import get_connection, init_db +from app.main import app +from app.services.ai.ai_clip_analyzer import TranscriptRow +from app.services.ai.variety_comedy_analyzer import ( + ComedyAnalysisRequest, + analyze_variety_comedy, + build_comedy_windows, + dedupe_recall_moments, + dedupe_scored_candidates, + normalize_clip_bounds, + score_comedy_candidate, +) +from app.services.audio_reaction_service import analyze_audio_reaction +from app.services.clip_feedback_service import list_recent_feedback_context, save_clip_feedback +from app.services.pipeline_engine import PipelineEngine +from app.services.storage_service import get_artifact_paths +from app.models.task import ClipFeedbackCreate + + +PREFIX = "test-comedy-v2-" + + +@pytest.fixture(autouse=True) +def comedy_v2_db_cleanup(): + init_db() + _cleanup_rows() + yield + _cleanup_rows() + + +def _cleanup_rows() -> None: + with get_connection() as connection: + connection.execute("DELETE FROM clip_feedback WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM ai_analysis_runs WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM clip_candidates WHERE task_id LIKE ?", (f"{PREFIX}%",)) + connection.execute("DELETE FROM tasks WHERE id LIKE ?", (f"{PREFIX}%",)) + connection.commit() + + +def _row(start: int, end: int, text: str = "对话") -> TranscriptRow: + return TranscriptRow( + start_time=_time(start), + end_time=_time(end), + start_seconds=start, + end_seconds=end, + text=text, + ) + + +def _time(seconds: int) -> str: + return f"{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}" + + +def _insert_task(task_id: str, *, profile: str = "variety_comedy", final_target: int = 5) -> None: + now = "2026-08-01T10:00:00" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks ( + id, task_name, task_dir_name, max_clip_duration, candidate_clip_count, + selection_profile, final_clip_target, created_at, updated_at + ) VALUES (?, ?, ?, 10, 12, ?, ?, ?, ?) + """, + (task_id, task_id, task_id, profile, final_target, now, now), + ) + connection.commit() + + +def _insert_candidate( + task_id: str, + index: int, + *, + tier: str, + quality: float, + selected_by_default: bool, +) -> str: + clip_id = f"{task_id}-clip-{index}" + start = index * 180 + now = "2026-08-01T10:00:00" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO clip_candidates ( + id, task_id, clip_key, title, start_time, end_time, duration_seconds, + summary, highlight_reason, confidence_score, quality_tier, quality_score, + humor_score, completeness_score, selected_by_default, enabled, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 90, ?, ?, ?, ?, ?, 90, 90, ?, ?, ?, ?) + """, + ( + clip_id, + task_id, + f"clip_{index:03d}", + f"片段 {index}", + _time(start), + _time(start + 90), + "完整笑点摘要", + "有铺垫、反转和反应", + quality / 100, + tier, + quality, + 1 if selected_by_default else 0, + 1 if selected_by_default else 0, + now, + now, + ), + ) + connection.commit() + return clip_id + + +def _base_candidate(**overrides) -> dict: + candidate = { + "source_id": "w001_m01", + "title": "小 S 补刀后全场笑", + "start_time": "00:01:00", + "end_time": "00:02:20", + "duration_seconds": 80, + "key_moment_time": "00:01:35", + "topic_key": "嘉宾忘词", + "summary": "嘉宾先认真解释,随后自曝忘词,主持人补刀后全场笑。", + "highlight_reason": "严肃铺垫与忘词反转形成反差。", + "arc_structure": "铺垫→忘词反转→主持人补刀→全场反应", + "suggested_editing": "保留补刀后反应。", + "humor_score": 90, + "interaction_reaction_score": 88, + "completeness_score": 90, + "hook_score": 82, + "novelty_score": 80, + "title_score": 84, + "audio_evidence": {"available": True, "score": 92, "labels": ["笑点后音量突增"]}, + } + candidate.update(overrides) + return candidate + + +def test_remote_windows_are_five_minutes_with_sixty_second_overlap(): + rows = [_row(second, second + 10, f"第 {second // 10} 句") for second in range(0, 900, 10)] + windows = build_comedy_windows(rows, provider_name="remote") + + assert len(windows) >= 4 + assert all(window.end_seconds - window.start_seconds <= 300 for window in windows) + for previous, current in zip(windows, windows[1:], strict=False): + assert current.start_seconds < previous.end_seconds + assert previous.end_seconds - current.start_seconds >= 50 + + +def test_local_windows_are_three_minutes_with_overlap(): + rows = [_row(second, second + 10) for second in range(0, 600, 10)] + windows = build_comedy_windows(rows, provider_name="local") + + assert all(window.end_seconds - window.start_seconds <= 180 for window in windows) + assert all( + current.start_seconds < previous.end_seconds + for previous, current in zip(windows, windows[1:], strict=False) + ) + + +def test_cross_window_recall_keeps_only_one_copy_of_same_moment(): + moments = [ + {"source_id": "w1", "key_seconds": 295, "recall_score": 82, "topic_key": "同一笑点"}, + {"source_id": "w2", "key_seconds": 302, "recall_score": 94, "topic_key": "同一笑点"}, + ] + + result = dedupe_recall_moments(moments) + + assert len(result) == 1 + assert result[0]["source_id"] == "w2" + + +def test_clip_bounds_expand_to_complete_sixty_to_one_hundred_fifty_seconds(): + rows = [_row(second, second + 10) for second in range(0, 300, 10)] + + bounds = normalize_clip_bounds(92, 112, 102, rows) + + assert bounds is not None + start, end = bounds + assert 60 <= end - start <= 150 + assert start <= 102 < end + + +def test_sentence_boundary_snap_does_not_shrink_clip_below_sixty_seconds(): + rows = [_row(second, second + 7) for second in range(0, 280, 7)] + + bounds = normalize_clip_bounds(69, 126, 98, rows) + + assert bounds is not None + start, end = bounds + assert 60 <= end - start <= 150 + + +def test_global_dedupe_keeps_highest_quality_complete_version(): + best = {**_base_candidate(), "source_id": "best", "quality_score": 91} + short = { + **_base_candidate(), + "source_id": "short", + "start_time": "00:01:25", + "end_time": "00:02:10", + "quality_score": 81, + } + + result = dedupe_scored_candidates([short, best]) + + assert [item["source_id"] for item in result] == ["best"] + + +def test_program_score_applies_weights_and_a_grade_gates(): + result = score_comedy_candidate(_base_candidate(), {}) + + assert result["quality_tier"] == "A" + assert result["quality_score"] >= 78 + assert result["humor_score"] >= 75 + assert result["completeness_score"] >= 70 + + +def test_audio_only_adds_and_cannot_bypass_humor_gate(): + text_only = score_comedy_candidate( + _base_candidate(audio_evidence={"available": False, "score": 0}), + {}, + ) + weak_humor = score_comedy_candidate( + _base_candidate(humor_score=60, audio_evidence={"available": True, "score": 100}), + {}, + ) + weak_audio = score_comedy_candidate( + _base_candidate(audio_evidence={"available": True, "score": 5}), + {}, + ) + + assert weak_audio["quality_score"] == text_only["quality_score"] + assert weak_humor["quality_tier"] != "A" + + +def test_missing_audio_degrades_to_text_review(tmp_path: Path): + result = analyze_audio_reaction(tmp_path / "missing.wav", 0, 60, 30, []) + + assert result["available"] is False + assert result["score"] == 0 + assert "音频" in result["reason"] + + +def test_synthetic_audio_detects_loudness_burst_and_reaction_density(tmp_path: Path): + audio_path = tmp_path / "reaction.wav" + sample_rate = 16_000 + quiet = array("h", [300 if index % 2 else -300 for index in range(sample_rate * 2)]) + loud = array("h", [12_000 if index % 2 else -12_000 for index in range(sample_rate * 2)]) + with wave.open(str(audio_path), "wb") as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(sample_rate) + handle.writeframes((quiet + loud).tobytes()) + transcript_rows = [_row(0, 2, "他说完大家哈哈哈"), _row(2, 4, "真的太突然了")] + + result = analyze_audio_reaction(audio_path, 0, 4, 2, transcript_rows) + + assert result["available"] is True + assert result["score"] > 40 + assert result["reaction_loudness_ratio"] > 5 + assert result["laughter_token_count"] >= 1 + + +def test_synthetic_silence_is_available_but_does_not_fake_a_reaction(tmp_path: Path): + audio_path = tmp_path / "silence.wav" + sample_rate = 16_000 + with wave.open(str(audio_path), "wb") as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(sample_rate) + handle.writeframes(array("h", [0] * sample_rate * 3).tobytes()) + + result = analyze_audio_reaction(audio_path, 0, 3, 1.5, []) + + assert result["available"] is True + assert result["score"] == 0 + assert result["silence_ratio"] == 1 + + +class _FakeComedyProvider: + def generate_json(self, prompt: str, *, retry_instruction: str | None = None) -> str: + if "只做宽召回" in prompt: + return json.dumps( + { + "moments": [ + { + "key_time": "00:01:30", + "title": "忘词反转", + "topic_key": "嘉宾忘词", + "humor_reason": "认真铺垫后突然忘词,主持人补刀。", + "recall_score": 92, + } + ] + }, + ensure_ascii=False, + ) + if "综艺短视频剪辑导演" in prompt: + return json.dumps( + { + "clips": [ + { + "source_id": "w001_m01", + "title": "嘉宾认真半天却忘词", + "start_time": "00:00:50", + "end_time": "00:02:10", + "key_moment_time": "00:01:30", + "topic_key": "嘉宾忘词", + "summary": "嘉宾认真铺垫后忘词,小 S 接话补刀,众人笑完自然收尾。", + "highlight_reason": "认真与忘词形成反差。", + "arc_structure": "认真铺垫→忘词→补刀与笑声→解释收尾", + "suggested_editing": "保留补刀后的现场反应。", + "humor_score": 90, + "interaction_reaction_score": 90, + "completeness_score": 92, + "hook_score": 85, + "novelty_score": 82, + "title_score": 86, + } + ] + }, + ensure_ascii=False, + ) + return json.dumps( + { + "ranked_clips": [ + { + "source_id": "w001_m01", + "title": "嘉宾认真解释半天,最后一句把小 S 逗笑", + "topic_key": "嘉宾忘词", + "humor_score": 92, + "interaction_reaction_score": 91, + "completeness_score": 93, + "hook_score": 86, + "novelty_score": 84, + "title_score": 88, + "arc_structure": "铺垫→忘词反转→补刀笑声→自然收尾", + "why_selected": "反差明确,补刀和笑后解释都完整。", + "rejection_reason": "", + } + ] + }, + ensure_ascii=False, + ) + + +def test_three_stage_flow_allows_weak_episode_to_select_less_than_target(monkeypatch, tmp_path: Path): + transcript_path = tmp_path / "transcript.md" + lines = ["## 逐句时间戳原文", "", "| 开始 | 结束 | 原文 |", "|---|---|---|"] + lines.extend( + f"| {_time(second)} | {_time(second + 10)} | 第 {second // 10} 句对话 |" + for second in range(0, 240, 10) + ) + transcript_path.write_text("\n".join(lines), encoding="utf-8") + monkeypatch.setattr("app.services.ai.variety_comedy_analyzer.build_provider", lambda _: _FakeComedyProvider()) + monkeypatch.setattr( + "app.services.ai.variety_comedy_analyzer.analyze_audio_reaction", + lambda *args, **kwargs: {"available": True, "score": 95, "labels": ["模拟笑声反应"]}, + ) + monkeypatch.setattr("app.services.ai.variety_comedy_analyzer.list_recent_feedback_context", lambda *args, **kwargs: []) + + result = analyze_variety_comedy( + ComedyAnalysisRequest( + task_id=f"{PREFIX}flow", + transcript_path=transcript_path, + audio_path=tmp_path / "not-needed.wav", + candidate_pool_limit=12, + final_clip_target=5, + ai_preference="更喜欢主持人补刀", + provider_name="remote", + prompt_template="保留完整笑点,不要凑数。", + ) + ) + + assert len(result.clips) == 1 + assert result.clips[0].selected_by_default is True + assert result.clips[0].quality_tier == "A" + assert 60 <= result.clips[0].duration_seconds <= 150 + + +def test_auto_pipeline_only_enables_selected_a_grade_clips(): + task_id = f"{PREFIX}auto" + _insert_task(task_id, final_target=5) + get_artifact_paths(task_id)["analysis_path"].parent.mkdir(parents=True, exist_ok=True) + selected_ids = { + _insert_candidate(task_id, index, tier="A", quality=95 - index, selected_by_default=True) + for index in range(1, 5) + } + _insert_candidate(task_id, 5, tier="A", quality=99, selected_by_default=False) + _insert_candidate(task_id, 6, tier="B", quality=76, selected_by_default=True) + + result = PipelineEngine()._select_clips(task_id, {"config": {}}) + + assert result["target_count"] == 5 + assert result["selected_count"] == 4 + assert {item["clip_id"] for item in result["selected"]} == selected_ids + with get_connection() as connection: + enabled = { + row["id"] + for row in connection.execute( + "SELECT id FROM clip_candidates WHERE task_id = ? AND enabled = 1", + (task_id,), + ).fetchall() + } + assert enabled == selected_ids + + +def test_feedback_is_saved_and_becomes_future_taste_context(): + task_id = f"{PREFIX}feedback" + _insert_task(task_id) + clip_id = _insert_candidate(task_id, 1, tier="B", quality=72, selected_by_default=False) + + result = save_clip_feedback( + task_id, + clip_id, + ClipFeedbackCreate(decision="reject", reason_code="not_funny"), + ) + context = list_recent_feedback_context("variety_comedy") + + assert result["enabled"] is False + assert context[0]["reason_code"] == "not_funny" + assert context[0]["title_snapshot"] == "片段 1" + + +def test_database_adds_quality_fields_feedback_table_and_builtin_prompt(): + with get_connection() as connection: + task_columns = {row["name"] for row in connection.execute("PRAGMA table_info(tasks)")} + clip_columns = {row["name"] for row in connection.execute("PRAGMA table_info(clip_candidates)")} + feedback_table = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'clip_feedback'" + ).fetchone() + preset = connection.execute( + "SELECT name, prompt_text FROM ai_prompt_presets WHERE id = 'preset_004'" + ).fetchone() + + assert {"selection_profile", "final_clip_target"} <= task_columns + assert {"quality_tier", "quality_score", "audio_reaction_score", "quality_evidence_json"} <= clip_columns + assert feedback_table is not None + assert preset["name"] == "康熙笑点优先 V2" + assert "不要为凑数量" in preset["prompt_text"] + + +def test_selection_settings_api_and_review_page_expose_v2_controls(): + task_id = f"{PREFIX}api" + _insert_task(task_id, profile="general", final_target=5) + _insert_candidate(task_id, 1, tier="B", quality=72, selected_by_default=False) + headers = {"Authorization": f"Bearer {settings.local_admin_token}"} if settings.local_admin_token else {} + with TestClient(app) as client: + response = client.patch( + f"/api/tasks/{task_id}/selection-settings", + json={"selection_profile": "variety_comedy", "final_clip_target": 4}, + headers=headers, + ) + page = client.get(f"/tasks/{task_id}/clips", headers=headers) + + assert response.status_code == 200 + assert response.json()["task"]["selection_profile"] == "variety_comedy" + assert response.json()["task"]["final_clip_target"] == 4 + assert page.status_code == 200 + assert "不好笑" in page.text + assert "铺垫不足" in page.text