diff --git a/.env.example b/.env.example index 047a9e8..cd2c69c 100644 --- a/.env.example +++ b/.env.example @@ -57,19 +57,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_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..a3798df 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,190 @@ # Development Log +## 2026-08-02 片段审核同步发送中心修复 + +- 根因确认:片段审核页勾选“启用”后,旧“同步发送中心”只读取已经完成的激活切片;当候选片段已启用但尚未出现在当前切片批次中时,请求会返回“新增 0 条”,发送中心仍只能看到旧版本。 +- “生成切片”现在会先保存页面里的启用状态、标题、摘要和出入点,再开始切割,避免页面选择尚未落库时继续使用旧审核结果。 +- 片段审核页新增“保存并同步发送中心”完整流程:先保存审核选择,再核对当前启用候选与激活成片;选择或内容有变化、文件缺失、成片数量不一致时自动生成安全新版本并同步,完全一致时只做幂等同步,不重复切片。 +- 同步成功后自动进入 `/publish?task_id=...&tab=content`,展开并定位当前原始处理任务;旧版待准备/已排期记录仍按现有版本规则安全转入历史,不删除视频、文案或执行证据。 +- 真实任务 `6753ce9f3bfd` 已完成页面回归:3 条候选均启用、修复前只有 2 条成片和 2 条发送内容;修复后新增 3 条最新发送内容、2 条旧版安全转入历史、失败 0 条,目标“大田怕鸟怕到连杂志图都怕,小S补刀跳海”已出现在抖音内容准备列表,未排期且未触发真实投稿。 +- 新增审核同步服务和路由回归测试。隔离分支完整自动化测试 `381 passed`,Ruff 与 JavaScript 语法检查通过;仅保留 8 条现有依赖弃用警告。 + +## 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,32 @@ - 抖音发送链路新增发布结果确认步骤:点击发布后必须等到“发布成功 / 已提交审核 / 审核中 / 投稿成功”等平台提示,或在作品管理看到对应标题,才会把本地任务标记为已发布。 - 如果页面出现验证码、登录失效、发布失败、风控等提示,会返回 `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 和差异检查均通过。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 89f56d4..5888bac 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,155 @@ # Next Steps +## 2026-08-02 片段审核同步发送中心检查方法 + +1. 打开目标任务的“片段审核”,勾选想发布的候选片段;无需再依次点击“保存修改”“生成切片”和旧同步按钮,直接点击“保存并同步发送中心”。 +2. 页面会先保存当前勾选、标题、摘要和出入点;如果启用选择与最新成片不一致,会显示“正在保存并生成”,完成后自动打开发送中心并展开当前任务。 +3. 当前真实任务 `6753ce9f3bfd` 已同步成功,发送中心抖音“内容准备”应显示 3 条:大田怕鸟、渣渣爷爷名字、Circus 跳舞;每条均为待准备状态,可以分别点击“加入发布计划”。 +4. 如果当前审核内容和成片完全一致,重复点击只会执行幂等同步,不会重复生成;如果修改了启用状态、标题、摘要、出入点,或成片文件缺失,系统才会创建安全的新切片版本。 +5. 新同步内容不会自动排期或投稿。加入发布计划前仍需逐条核对账号、标题、简介、话题、封面、可见范围和具体北京时间;二维码、验证码、滑块与平台风控继续由人工处理。 + +## 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 +165,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 +824,34 @@ 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站真实投稿。 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..d1ef0df 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -161,10 +161,43 @@ 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", 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..387a2ee 100644 --- a/app/main.py +++ b/app/main.py @@ -56,7 +56,7 @@ async def lifespan(app: FastAPI): 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..f7844db 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) @@ -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) + 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="09:00", min_length=5, max_length=5) daily_end_time: str = Field(default="21: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]: @@ -194,7 +218,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 +252,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 +308,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 +343,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..71a05e0 100644 --- a/app/routers/publish.py +++ b/app/routers/publish.py @@ -7,17 +7,23 @@ PublishAccountCreate, PublishBatchJobCreate, PublishBatchScheduleUpdate, + PublishBatchTargetUpdate, PublishCoverCreate, PublishCoverFrameBatchCreate, + PublishHistoryRecordBatchUpdate, PublishJobContentUpdate, PublishJobCreate, PublishJobScheduleUpdate, + PublishJobTargetUpdate, + PublishMarkPublishedRequest, PublishPlatformConfigUpdate, + PublishRetryRequest, 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 +82,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 +186,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, 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.refresh_send_queue(use_ai=use_ai) + 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 +254,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 +296,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 +358,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 +369,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 +380,41 @@ 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 @@ -242,10 +427,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 +474,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..cd45160 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -5,12 +5,14 @@ from app.models.task import ( ClipCandidateBatchUpdate, + ClipFeedbackCreate, ClipCandidateUpdate, SubtitleStyleUpdate, TaskAIPreferenceUpdate, TaskAIPromptPresetUpdate, TaskCandidateClipCountUpdate, TaskCreate, + TaskSelectionSettingsUpdate, TaskStatus, TaskStatusUpdate, ) @@ -46,8 +48,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"), @@ -76,6 +80,8 @@ async def create_upload_task( 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, @@ -161,6 +167,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 +293,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 +323,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..50d0db8 --- /dev/null +++ b/app/services/database_backup_service.py @@ -0,0 +1,264 @@ +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 +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() diff --git a/app/services/pipeline_engine.py b/app/services/pipeline_engine.py index c23487a..3bdb0f9 100644 --- a/app/services/pipeline_engine.py +++ b/app/services/pipeline_engine.py @@ -12,6 +12,7 @@ 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.storage_service import ( create_task_directory, get_artifact_paths, @@ -223,7 +224,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 +241,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 +282,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 +304,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 +373,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 +385,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 +414,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 +429,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 "", []), } ) 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..98b6a4f 100644 --- a/app/services/publish_scheduler.py +++ b/app/services/publish_scheduler.py @@ -1,114 +1,131 @@ +"""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, + 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 +133,1125 @@ 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() - return {"status": "ok", "job": get_publish_job_raw(job_id)} + 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() + 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 preview_batch_schedule( + self, + job_ids: list[str], + *, + 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]: + 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} def update_batch_schedule( self, job_ids: list[str], *, + platform: str | None = None, action: str, - start_at: str = "", - interval_hours: int = 3, + start_at_local: str = "", + timezone_name: str = "Asia/Shanghai", + interval_minutes: int = 180, daily_start_time: str = "09:00", daily_end_time: str = "21:00", + confirmed_schedule: list[dict[str, str]] | None = None, ) -> 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("至少选择一条发布任务") + ids = self._validate_batch_jobs(job_ids, platform) if action not in {"apply", "clear"}: raise ValueError("不支持的排期操作") - - placeholders = ", ".join("?" for _ in normalized_ids) - with get_connection() as connection: - rows = connection.execute( - f"SELECT * FROM publish_jobs WHERE id IN ({placeholders})", - normalized_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, + 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_publishing(self, job_id: str) -> None: - now = now_iso() - payload = json.dumps({"publisher": "started", "started_at": now}, ensure_ascii=False) + 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 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 +1262,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 +1339,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 +1347,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..52d4298 --- /dev/null +++ b/app/services/publish_time.py @@ -0,0 +1,121 @@ +"""统一处理发布排期时间。 + +数据库统一保存带时区的 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 build_schedule_times( + count: int, + *, + start_at_local: str, + timezone_name: str | None = None, + interval_minutes: int = 180, + daily_start_time: str = "09:00", + daily_end_time: str = "21: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/task_lifecycle_service.py b/app/services/task_lifecycle_service.py index 6f1d447..7103e25 100644 --- a/app/services/task_lifecycle_service.py +++ b/app/services/task_lifecycle_service.py @@ -60,6 +60,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,6 +192,42 @@ def update_task_candidate_clip_count(task_id: str, candidate_clip_count: int) -> } +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 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 selection_profile = ?, final_clip_target = ?, updated_at = ? + WHERE id = ? + """, + (selection_profile, final_clip_target, now, task_id), + ) + connection.commit() + + profile_label = "综艺笑点优先" if selection_profile == "variety_comedy" else "通用模式" + append_task_log(task_id, f"已更新选片模式:{profile_label},最终启用目标:{final_clip_target} 条") + return { + "status": "ok", + "message": f"已保存{profile_label},最终启用目标为 {final_clip_target} 条。", + "task": get_task(task_id, include_video_probe=False), + } + + def soft_delete_task(task_id: str) -> dict: from app.services.task_service import _now_iso, get_task # noqa: F811 diff --git a/app/services/task_service.py b/app/services/task_service.py index fa2ff75..1376e78 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")), @@ -678,8 +706,30 @@ def update_clip_candidates_batch(task_id: str, payloads: list[ClipCandidateBatch validated.append((payload.id, _validate_clip_update(task, payload))) now = _now_iso() + changed_count = 0 with get_connection() as connection: for clip_id, data in validated: + current = connection.execute( + """ + SELECT title, start_time, end_time, duration_seconds, enabled, summary + FROM clip_candidates + WHERE id = ? AND task_id = ? AND is_deleted = 0 + """, + (clip_id, task_id), + ).fetchone() + if current is None: + raise ValueError(f"候选片段不存在:{clip_id}") + if any( + ( + str(current["title"] or "") != data["title"], + str(current["start_time"] or "") != data["start_time"], + str(current["end_time"] or "") != data["end_time"], + int(current["duration_seconds"] or 0) != int(data["duration_seconds"]), + int(current["enabled"] or 0) != int(data["enabled"]), + str(current["summary"] or "") != data["summary"], + ) + ): + changed_count += 1 cursor = connection.execute( """ UPDATE clip_candidates @@ -707,11 +757,90 @@ def update_clip_candidates_batch(task_id: str, payloads: list[ClipCandidateBatch _append_task_log(task_id, f"已批量保存 {len(validated)} 条候选片段审核修改") return { "message": f"已保存 {len(validated)} 条候选片段,任务状态仍保持 AI 结果待检查。", + "changed_count": changed_count, "task": get_task(task_id, include_video_probe=False), "clips": list_clip_candidates(task_id), } +def _active_outputs_match_enabled_candidates(task_id: str) -> bool: + with get_connection() as connection: + enabled_rows = connection.execute( + """ + SELECT id + FROM clip_candidates + WHERE task_id = ? AND enabled = 1 AND is_deleted = 0 + """, + (task_id,), + ).fetchall() + output_rows = connection.execute( + """ + SELECT clip_candidate_id, output_file_path + FROM output_clip + WHERE task_id = ? AND is_active = 1 AND status = 'completed' + """, + (task_id,), + ).fetchall() + + enabled_ids = {str(row["id"]) for row in enabled_rows} + if not enabled_ids or len(output_rows) != len(enabled_ids): + return False + + output_ids = {str(row["clip_candidate_id"] or "") for row in output_rows} + if output_ids != enabled_ids: + return False + + return all( + bool( + (resolved := resolve_video_file_path(str(row["output_file_path"] or ""))) + and resolved.exists() + and resolved.is_file() + ) + for row in output_rows + ) + + +def sync_reviewed_clips_to_publish_center( + task_id: str, + payloads: list[ClipCandidateBatchItem], +) -> dict: + save_result = update_clip_candidates_batch(task_id, payloads) + needs_regeneration = bool(save_result["changed_count"]) or not _active_outputs_match_enabled_candidates(task_id) + + if needs_regeneration: + cut_result = process_task_video_cuts(task_id) + publish_sync = cut_result.get("publish_sync") or { + "status": "partial", + "message": "最新切片已生成,但没有取得发送中心同步结果。", + "errors": ["发送中心同步结果缺失"], + } + action_message = "已保存当前审核选择,并重新生成最新切片。" + else: + from app.services.publish_service import sync_task_publish_jobs + + cut_result = None + publish_sync = sync_task_publish_jobs( + task_id, + prefer_subtitled=False, + restore_removed=True, + ) + action_message = "已保存当前审核选择,现有切片与选择一致,无需重复生成。" + + return { + "status": publish_sync.get("status") or "ok", + "message": f"{action_message}{publish_sync.get('message') or '发送中心同步完成。'}", + "regenerated": needs_regeneration, + "saved_count": len(payloads), + "changed_count": save_result["changed_count"], + "review_save": save_result, + "cut_result": cut_result, + "publish_sync": publish_sync, + "link_state": publish_sync.get("link_state") or {}, + "errors": publish_sync.get("errors") or [], + "warnings": publish_sync.get("warnings") or [], + } + + def get_clip_candidate(task_id: str, clip_id: str) -> dict: for clip in list_clip_candidates(task_id): if clip["id"] == clip_id: @@ -757,8 +886,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 +942,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..fc29a21 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,609 @@ 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-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..d593720 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", @@ -748,6 +787,19 @@ function collectClipReviewPayload() { })); } +async function persistClipReviewChanges() { + if (!clipReviewForm) throw new Error("当前页面没有可保存的候选片段"); + const taskId = clipReviewForm.dataset.taskId; + const response = await fetch(`/api/tasks/${taskId}/clips/batch-update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clips: collectClipReviewPayload() }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "保存失败"); + return data; +} + async function deleteClipCard(card, button) { if (!clipReviewForm || !card) return; const taskId = clipReviewForm.dataset.taskId; @@ -786,6 +838,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() @@ -1079,15 +1165,7 @@ if (saveClipsButton && clipReviewForm) { showClipReviewMessage("正在保存候选片段修改...", "info"); try { - const response = await fetch(`/api/tasks/${taskId}/clips/batch-update`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clips: collectClipReviewPayload() }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "保存失败"); - } + const data = await persistClipReviewChanges(); showClipReviewMessage(data.message || "保存成功。", "success"); } catch (error) { showClipReviewMessage(`保存失败:${error.message}`, "error"); @@ -1218,6 +1296,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); } @@ -1309,15 +1393,22 @@ if (generateClipsButton) { const originalText = generateClipsButton.textContent; generateClipsButton.disabled = true; generateClipsButton.textContent = "正在确认..."; - showClipReviewMessage("正在请求生成切片...", "info"); + showClipReviewMessage("正在保存当前审核选择...", "info"); try { + await persistClipReviewChanges(); + generateClipsButton.textContent = "正在生成..."; + showClipReviewMessage("审核选择已保存,正在生成最新切片...", "info"); const response = await fetch(generateClipsButton.dataset.endpoint, { method: "POST" }); const data = await response.json(); 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,6 +1418,84 @@ 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 syncReviewedClips = button.dataset.syncReviewedClips === "true"; + let data; + if (syncReviewedClips) { + const clips = collectClipReviewPayload(); + if (!clips.some((clip) => clip.enabled)) { + throw new Error("请至少启用一条候选片段后再同步发送中心"); + } + button.textContent = "正在保存并生成..."; + showClipReviewMessage("正在保存审核选择;如选择有变化,将自动生成最新切片并同步...", "info"); + data = await window.apiFetch( + `/api/tasks/${encodeURIComponent(taskId)}/clips/sync-publish`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clips }), + }, + ); + } else { + 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 || "发送中心同步完成。"); + } + if (syncReviewedClips && data.status !== "partial") { + const params = new URLSearchParams({ + task_id: taskId, + tab: "content", + publish_message: data.message || "发送中心同步完成。", + }); + window.location.assign(`/publish?${params.toString()}`); + } + } 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 || "这条任务"; @@ -1896,838 +2065,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..391f1f0 --- /dev/null +++ b/app/static/js/publish-center.js @@ -0,0 +1,1902 @@ +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 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 || "09:00"), + daily_end_time: String(scheduleForm?.elements.daily_end_time?.value || "21: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 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"); + 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); + + scheduleForm?.elements.interval_preset?.addEventListener("change", () => { + document.querySelector("[data-custom-interval]").hidden = scheduleForm.elements.interval_preset.value !== "custom"; + }); + + 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..309c3be 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..0cd86bb 100644 --- a/app/templates/new_task.html +++ b/app/templates/new_task.html @@ -49,22 +49,40 @@

基本信息

+ +
+
@@ -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..6927e45 100644 --- a/app/templates/publish.html +++ b/app/templates/publish.html @@ -2,314 +2,337 @@ {% 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 %} -