diff --git a/.env.example b/.env.example index cd2c69c..30ce131 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ FFMPEG_TIMEOUT=600 # 如果以后要换存储盘,可以同步修改 docker-compose.yml 里的 volumes 和这里的路径。 STORAGE_ROOT=E:\直播间切片工作流存储 TASKS_DIR=E:\直播间切片工作流存储 +# 浏览器上传超过 1MB 时使用的临时目录;必须放在大容量存储盘。 +UPLOAD_TEMP_DIR=E:\直播间切片工作流存储\_临时上传 AI_DEFAULT_PROVIDER=remote AI_REQUEST_TIMEOUT_SECONDS=120 @@ -61,14 +63,14 @@ AI_LOCAL_HEALTH_TIMEOUT_SECONDS=30 OPENCLI_LOCAL_BASE_URL=http://127.0.0.1:8001 OPENCLI_HOST_BRIDGE_URL=http://host.docker.internal:8765 -# v1.5.0 统一排期与 Windows Chrome 发布 Worker +# v2.0.0 统一排期与 Windows Chrome 发布 Worker APP_TIMEZONE=Asia/Shanghai PUBLISH_SCHEDULER_ENABLED=true PUBLISH_SCHEDULER_INTERVAL_SECONDS=5 PUBLISH_DEFAULT_MODE=local_browser PUBLISH_JOB_STALE_MINUTES=30 PUBLISH_SCHEDULER_MAX_RETRY_COUNT=3 -PUBLISH_SCHEDULER_EXPORT_DIR= +PUBLISH_SCHEDULER_EXPORT_DIR=E:\直播间切片工作流存储\_发布包 PUBLISH_SCHEDULER_ALLOW_PUBLISH_WITHOUT_REVIEW=false PUBLISH_ENABLE_OPENCLI_FALLBACK=false # 请使用随机长字符串;start_publish_worker.ps1 会在本地 .env 缺失时自动生成。 diff --git a/AGENTS.md b/AGENTS.md index 426a95d..08b944b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ - 项目英文名:NiuMa Studio - 项目中文说明:牛马片场,本地 AI 高光生产后台 - 项目目录:`C:\Users\10578\Documents\New project 2` -- 当前版本:1.5.0 -- 当前状态:Windows 本地后台处理链路与抖音/B站统一真实发布架构已实现;真实投稿仍遵守账号登录、平台验证、风控和人工确认边界 +- 当前版本:2.0.0 +- 当前状态:Windows 本地 AI 高光生产闭环已完成整合,素材、转写、AI 选片、审核、切片、内容准备、排期与抖音/B站统一真实发布链路已打通;真实投稿仍遵守账号登录、平台验证、风控和人工确认边界 ## 协作方式 @@ -27,7 +27,7 @@ - AI 分析:预留 OpenAI-compatible API 或本地大模型接口 - 语音转写:预留服务接口 -## 当前 1.5.0 范围 +## 当前 2.0.0 范围 - 新建视频处理任务,支持直播录像、综艺访谈和其他长视频素材。 - 上传本地视频或选择 NAS / 本地目录中的已有视频。 @@ -37,12 +37,14 @@ - 生成逐句时间戳原文,供 AI 分析、字幕和片段审核复用。 - AI 分析候选短视频片段,支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama。 - 后台查看并编辑候选片段。 -- 自动切割视频并输出短视频文件。 +- 保存审核选择后按需自动生成最新切片,并安全同步到发送中心。 +- 自动切割视频并输出短视频文件;全自动任务详情每 3 秒局部更新状态、进度、日志和可用操作。 - 保留字幕工作台,但全自动流程继续跳过字幕生成、烧录和叠加。 -- 发送中心分为内容准备、排期计划和执行记录,生成标题、简介、话题和候选封面帧。 +- 发送中心分为内容准备、排期计划和执行记录,生成标题、简介、话题和候选封面帧;支持任务分组、已排期标记、月历日期详情、跨午夜窗口和续接当前平台最晚排期。 - 立即发送与定时发送统一使用 SQLite Scheduler、Publisher Registry 和 Windows Chrome Worker。 - 抖音与 B站使用各自 Publisher;`manual_export` 仅显式导出本地发布包,`opencli` 仅作默认关闭的兼容模式。 -- 展示任务状态、处理进度和异常信息。 +- 统一使用 E 盘任务存储、临时上传目录和发布包目录;永久删除只清理项目托管产物,不删除外部唯一原片。 +- 展示任务状态、处理进度和异常信息,失败步骤可重试,发布结果不确定时进入人工复核。 ## 当前不做 diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 229053e..94e63d3 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,5 +1,49 @@ # Development Log +## 2026-08-03 v2.0.0 主分支收拢与文档定版 + +- 将当前工作分支相对 `master` 的素材存储、自动流水线、审核切片、发送中心、排期和真实发布成果统一作为 v2.0 本地生产闭环,不改变 FastAPI + SQLite + Windows Worker 的现有架构。 +- 项目版本、FastAPI API、Windows 发布 Worker 和界面侧栏统一更新为 `2.0.0` / `v2.0`;全自动流程结束提示同步改为当前发送中心与排期语义。 +- `README.md` 增加项目现状表、完整主流程、功能清单、运行形态和人工边界;明确“发布架构已实现”不等于“所有账号和平台页面已完成无人值守验证”。 +- 新手指南、架构、状态流、部署和路线图同步说明 E 盘存储、两类 AI 选片、任务实时进度、片段审核同步、排期月历、Windows Chrome Worker、发布终态与后续真实灰度重点。 +- 保留所有历史版本开发记录和既有验收说明;v2.0 后续仍优先做抖音 / B站单条真实灰度、长时间运行和数据备份验收,不扩大到多用户、云端 SaaS 或绕过平台验证。 +- 发布前验证已完成:Python 编译、Ruff、`app.js` / `publish-center.js` 语法和 Git 空白检查全部通过,完整自动化测试 `407 passed`;仅保留 9 条既有依赖弃用提醒,测试未连接真实平台或触发投稿。 + +## 2026-08-03 发送中心排期日历展开与时间升序 + +- “排期计划”月历中的有任务日期现在可点击或使用键盘 Enter / 空格打开,在月历下方完整展示当天当前平台的全部排期,并明确显示日期、平台和任务数量。 +- 当天详情按北京时间从早到晚展示时间、标题、账号与状态;点击任意一条只会滚动并高亮下方原任务,不复制操作按钮、不修改排期,也不触发投稿。 +- 月历前两条预览与下方任务清单统一按 `scheduled_at` 升序;已排期任务在前,未排期任务在后且保持原有内部顺序。应用、调整、清除或取消排期后会原地重排,无需刷新页面。 +- 切换平台或月份会关闭旧日期详情;选中日期已无排期时也会自动关闭。抖音和 B站继续严格隔离,日期仍按 `Asia/Shanghai` 北京时间计算。 +- 未新增 API、数据库字段或迁移,未修改调度和真实发布边界;新增浏览器回归覆盖完整日期展开、稳定排序、键盘访问、定位高亮、动态取消排期和窄屏无横向溢出。 +- 本机真实页面已只读验收:8 月 3 日完整显示 `08:00 → 11:00 → 14:00 → 17:00 → 20:00 → 23:00`,主清单继续衔接到 8 月 4 日;控制台无错误,未修改排期或触发投稿。专项测试 `37 passed`、完整测试 `407 passed`,Ruff、JavaScript 语法和 Git 空白检查均通过,仅保留 9 条现有依赖弃用警告。 + +## 2026-08-02 发送中心续接最晚排期 + +- “设置排期”抽屉新增“接在当前平台最晚排期后”:每次点击都实时读取 SQLite,不依赖页面中最多 200 条的缓存列表;抖音和 B站分别计算。 +- 新增 `POST /api/publish/schedules/next-start`。接口只查当前平台仍有效且带未来时间的 `WAITING / SCHEDULED / PUBLISHING` 记录,并排除本次正在调整的已选任务;已发布、失败、取消和另一平台记录不会占用本平台时间线。 +- 续排严格使用当前间隔,并复用跨午夜时段规则:19:00 加 3 小时为 22:00,21:00 加 3 小时为次日 00:00,22:00 加 3 小时超出窗口后顺延到次日 07:00。 +- 发送中心和全自动任务的新默认每日窗口统一为 `07:00 → 00:00`,仍可逐次修改;已有任务配置、已有排期和数据库结构均未改变。 +- 修复全自动 `daily_window` 对跨午夜窗口的旧循环判断,改为复用统一的允许时段计算,不会在 `07:00 → 00:00` 下反复滚动日期。 +- 新增服务层、API、默认值、平台隔离、选中任务排除、无排期、跨午夜、自动流程及浏览器回填测试;相关测试共 `49 passed`,完整测试 `397 passed`,Ruff、Python/JavaScript 语法和 Git 空白检查均通过,测试未触发真实投稿。 + +## 2026-08-02 片段审核同步发送中心修复 + +- 根因确认:片段审核页勾选“启用”后,旧“同步发送中心”只读取已经完成的激活切片;当候选片段已启用但尚未出现在当前切片批次中时,请求会返回“新增 0 条”,发送中心仍只能看到旧版本。 +- “生成切片”现在会先保存页面里的启用状态、标题、摘要和出入点,再开始切割,避免页面选择尚未落库时继续使用旧审核结果。 +- 片段审核页新增“保存并同步发送中心”完整流程:先保存审核选择,再核对当前启用候选与激活成片;选择或内容有变化、文件缺失、成片数量不一致时自动生成安全新版本并同步,完全一致时只做幂等同步,不重复切片。 +- 同步成功后自动进入 `/publish?task_id=...&tab=content`,展开并定位当前原始处理任务;旧版待准备/已排期记录仍按现有版本规则安全转入历史,不删除视频、文案或执行证据。 +- 真实任务 `6753ce9f3bfd` 已完成页面回归:3 条候选均启用、修复前只有 2 条成片和 2 条发送内容;修复后新增 3 条最新发送内容、2 条旧版安全转入历史、失败 0 条,目标“大田怕鸟怕到连杂志图都怕,小S补刀跳海”已出现在抖音内容准备列表,未排期且未触发真实投稿。 +- 新增审核同步服务和路由回归测试。完整自动化测试 `383 passed`,Ruff 与 JavaScript 语法检查通过;仅保留 8 条现有依赖弃用警告。 + +## 2026-08-02 内容准备已排期标记优化 + +- 发送中心“内容准备”中的 `SCHEDULED` 卡片新增蓝色“已排期 + 北京时间”标签,并使用轻蓝渐变底色、蓝色边框、左侧强调线和轻阴影,让已经安排过的任务无需切换页签即可辨认。 +- 已排期卡片底部操作由“加入发布计划”改为“调整排期”;未排期卡片继续保持原白色样式与原按钮文案。 +- 标记直接复用发布任务现有状态和排期时间,不新增数据库字段,不修改历史数据;新增排期、清除排期或取消发送后,标签、卡片颜色和按钮文案会在当前页面原地同步。 +- 新增模板首屏渲染与前端原地更新回归测试。完整测试 `383 passed`,Ruff、JavaScript 语法、Git 空白检查和浏览器控制台错误检查均通过;仅有 8 条现有依赖弃用提醒。 +- 本机 `127.0.0.1:8001/publish` 已按约 1500×1100 桌面视口验收:“姜母鸭汤酒味重到小S喝懵了”显示“已排期 2026-08-02 13:00”,普通未排期卡片保持原样;验收未修改排期、账号或平台数据,也未触发真实投稿。 + ## 2026-08-02 发送中心封面计数平台隔离修复 - 根因确认:发送中心当前停留在抖音时,封面按钮仍统计了页面中隐藏的 B站任务;现场数据库中的 8 条空封面路径全部属于 B站,抖音当前缺失数实际为 0。 @@ -902,3 +946,21 @@ - “移出内容准备”仍是用户主动隐藏记录,“跳过任务”仍是终止当前任务;二者继续使用 `CANCELLED`,不会被普通取消逻辑误恢复。 - 兼容历史数据:数据库初始化时,只把旧版错误标记为“用户取消任务”的最后一条记录安全恢复到 `WAITING`;若同一切片和平台已经有活跃替代任务,则不会制造重复任务。 - 新增状态机、历史数据恢复和浏览器交互回归测试;专项测试 `19 passed`、完整测试 `336 passed`,Python 编译、两个 JavaScript 文件语法检查、Ruff 和差异检查均通过。 + +## 2026-08-02 E 盘统一视频存储与任务永久删除 + +- 新增 `UPLOAD_TEMP_DIR`,并在应用启动时把当前进程的 `TEMP`、`TMP` 和 Python 临时目录指向 E 盘;大于 1 MB 的浏览器上传不再先写入 C 盘系统临时目录。 +- 手动发布包默认目录从项目 `outputs/publish_packages` 调整到 `E:\直播间切片工作流存储\_发布包`;任务原片、音频、切片、字幕和封面继续统一使用 `TASKS_DIR`。 +- 任务列表“移入回收站”改为“永久删除”:只删除系统托管目录,外部 NAS / E 盘原片保留,数据库历史隐藏保留;运行中的处理和发布任务禁止删除。 +- 新增 `scripts/purge_deleted_task_media.py`,默认只预览;`--apply` 会先创建 SQLite 元数据备份,再清理已隐藏任务的 E 盘目录、发布包和精确匹配的旧版 C 盘任务目录。 +- 新增大文件 multipart 临时目录、失败上传回滚、外部原片保护、路径越界、运行中拦截、删除失败回滚、幂等删除和旧任务清理测试。 +- 已对真实数据先预演再执行清理:15 条已隐藏任务共删除 16 个托管目录,释放 `6,213,311,934` 字节(约 6.21 GB);4 条有效任务目录清理前后均完整,外部测试原片保留,清理后再次预演为 0 个残留目录。 +- 清理前 SQLite 元数据备份完整性检查为 `ok`;最终完整测试 `395 passed`,Python 编译、Ruff、JavaScript 语法和差异检查均通过。 + +## 2026-08-02 任务详情进度局部自动刷新 + +- 新增 `GET /api/tasks/{task_id}/live-status` 轻量接口,统一返回任务状态、进度、10 步时间线、日志、候选/输出数量和可用操作,不执行 FFprobe。 +- 全自动任务详情页打开后立即读取一次状态,处理中每 3 秒局部更新状态卡、顶部状态、基础计数和右侧运行日志;页面切回前台时会立即补一次更新。 +- 完成或失败后停止轮询,并原地显示发送中心、片段检查、重试或继续入口;重试/继续全自动流程也不再触发整页刷新。 +- 网络短暂异常时保留当前页面数据并继续重试,不会清空用户正在编辑的 AI Prompt 或改变滚动位置。 +- 新增处理中、失败、完成、404 和前端无整页刷新测试;专项测试 `21 passed`、完整测试 `407 passed`,JavaScript 与 Python 语法检查通过。浏览器实测从 AI 分析中切换到失败状态时,状态卡、日志和重试按钮在 3 秒内更新,未保存输入保持不变。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 179255f..337d9f5 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,5 +1,48 @@ # Next Steps +## v2.0.0 发布后优先验收 + +1. 打开 Docker Desktop 并运行 `niuma-studio`,浏览器访问 `http://127.0.0.1:8001`;确认左侧显示“v2.0 本地高光生产版”,系统状态与发送中心显示 Windows Worker 正常。 +2. 用一条短测试视频开启全自动任务,确认任务详情的 10 步进度、日志、候选数和输出数每 3 秒局部更新;流程完成后能进入片段审核和发送中心。 +3. 在片段审核修改一条候选的启用状态或时间,点击“保存并同步发送中心”;确认必要时生成新切片,发送中心只使用当前激活版本,旧执行证据仍留在历史。 +4. 在内容准备核对抖音 / B站的视频、标题、简介、话题、封面、账号和可见范围;设置一个跨午夜测试排期,确认月历、日期详情和任务清单按北京时间升序。 +5. 抖音和 B站分别只选一条低风险测试内容做真实灰度;必须在 Chrome 独立窗口人工完成登录与平台验证。遇到验证码、风控或结果不确定时先核对创作者中心,不要直接重试。 +6. 连续运行一段时间后检查 Scheduler、Docker Watcher、Windows Worker 和发布事件日志,并验证 SQLite 备份与 E 盘剩余空间。 + +## 2026-08-03 排期日历展开与排序检查方法 + +1. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新,然后进入“排期计划”。 +2. 同一天有 3 条以上排期时,月历仍会预览前两条并显示“另有 N 条”;点击日期格后,月历下方应展开当天全部排期,时间从早到晚排列。 +3. 当天详情会显示任务标题、账号和状态;点击某一条只会滚动并蓝色高亮下方原任务,不会改变排期或立即发送。 +4. 下方任务清单应先列出全部已排期任务,并按北京时间从最早到最晚排列;未排期任务统一放在已排期任务后面。 +5. 调整、清除或取消排期后,月历、当天详情和主清单应立即更新。切换抖音/B站或月份时,之前展开的日期会关闭,两个平台不会混排。 +6. 日期格也可以按 `Tab` 聚焦后使用 Enter 或空格打开。验证本功能不需要点击“立即发送”,请勿改动即将真实投稿的排期。 + +## 2026-08-02 续接最晚排期使用与检查 + +1. 打开 `http://127.0.0.1:8001/publish` 并按 `Ctrl + F5`,进入“内容准备”或“排期计划”,勾选同一平台需要安排的内容后点击“设置排期”。 +2. 抽屉中的每日开始、结束时间应默认显示 `07:00` 和 `00:00`;这是新默认值,不会改动以前已经保存的排期,也可以为本次排期手动修改。 +3. 先选择发布间隔,再点击“接在当前平台最晚排期后”。页面应显示“当前最晚”和“本次第 1 条”时间;例如当前最晚 19:00、间隔 3 小时,本次应为 22:00。 +4. 如果加上间隔后落在 00:00 至 07:00 的不可发布区间,系统会自动顺延到 07:00;正好得到 00:00 时仍允许排期。 +5. 当前平台没有其他未来排期时,页面会提示手动选择,不会覆盖第 1 条时间。调整时间、间隔或每日窗口后,必须重新点击“预览排期”再确认应用。 +6. 抖音和 B站分别续排;切换平台后需重新勾选。验证此功能只需要预览,不必点击立即发送,也不会绕过账号登录、验证码或平台风控。 + +## 2026-08-02 片段审核同步发送中心检查方法 + +1. 打开目标任务的“片段审核”,勾选想发布的候选片段;无需再依次点击“保存修改”“生成切片”和旧同步按钮,直接点击“保存并同步发送中心”。 +2. 页面会先保存当前勾选、标题、摘要和出入点;如果启用选择与最新成片不一致,会显示“正在保存并生成”,完成后自动打开发送中心并展开当前任务。 +3. 当前真实任务 `6753ce9f3bfd` 已同步成功,发送中心抖音“内容准备”应显示 3 条:大田怕鸟、渣渣爷爷名字、Circus 跳舞;每条均为待准备状态,可以分别点击“加入发布计划”。 +4. 如果当前审核内容和成片完全一致,重复点击只会执行幂等同步,不会重复生成;如果修改了启用状态、标题、摘要、出入点,或成片文件缺失,系统才会创建安全的新切片版本。 +5. 新同步内容不会自动排期或投稿。加入发布计划前仍需逐条核对账号、标题、简介、话题、封面、可见范围和具体北京时间;二维码、验证码、滑块与平台风控继续由人工处理。 + +## 2026-08-02 内容准备已排期标记检查方法 + +1. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新,然后停留在“内容准备”。 +2. 已排期卡片右上角应显示蓝色“已排期 + 具体北京时间”,整张卡片同时出现浅蓝底、蓝色边框和左侧强调线,底部按钮显示“调整排期”。 +3. 未排期卡片不显示该标签,仍保持普通白色卡片和“加入发布计划”按钮;这两种状态应能快速区分。 +4. 新增、调整或清除排期后,卡片标记应在当前页面直接更新,不需要手动刷新。验证时只操作测试任务,不要改动即将真实发送的排期。 +5. 本次只优化状态提示,不改变调度器、账号、素材或真实投稿流程;真实投稿前仍需逐条核对账号、文案、封面、可见范围和排期。 + ## 2026-08-02 发送中心封面计数修复后的检查方法 1. 打开 `http://127.0.0.1:8001/publish` 并刷新页面;当前抖音没有缺失封面时,应显示禁用的“补充抖音缺失封面”,不再出现错误的“(8)”。 @@ -157,7 +200,7 @@ 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. 该段是 v1.4.0 历史测试记录。当时默认 `manual_export`;v1.5.0 当前默认 `local_browser`,到点后会调用 Windows Worker 真实投稿。手动扫描命令仍是 `.\.venv\Scripts\python.exe -m app.publish_scheduler run-once`。 +4. 该段是 v1.4.0 历史测试记录。当时默认 `manual_export`;v2.0.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`。 @@ -847,3 +890,21 @@ 4. 之前由旧版“取消任务”产生、错误信息为“用户取消任务”的记录,会在数据库初始化时自动恢复;如果同一切片和平台已经存在新的活跃任务,则保留新的任务且不重复恢复。 5. 继续确认“移出内容准备”仍会隐藏任务,并可在执行记录中恢复;“跳过任务”仍保持终止状态,不会自动回到准备区。 6. 本次验收不要点击“立即发送”,不会触发抖音或 B站真实投稿。 + +## 2026-08-02 E 盘存储与永久删除验收 + +1. 重启本地后台,打开“系统状态”,确认“视频临时与导出目录”显示“E 盘就绪”,路径分别是 `_临时上传` 和 `_发布包`。 +2. 新建一个测试任务并上传视频,确认任务原片和后续切片只出现在 `E:\直播间切片工作流存储\任务名` 下;C 盘项目目录不应出现新的生产视频副本。 +3. 在任务列表点击“永久删除”,确认提示明确说明无法恢复且外部原片保留;删除后对应 E 盘任务目录和发布包应消失。 +4. 对 NAS 或 E 盘其他目录的引用任务执行删除时,只删除任务生成物,外部唯一原片必须仍然存在。 +5. 转写、切片或真实发布进行中时,删除应被拒绝并显示原因;等待任务结束后再删除。 +6. `scripts/purge_deleted_task_media.py` 默认只输出清单,只有显式带 `--apply` 才会永久清理,并在 `data/backups` 留下 SQLite 元数据备份。 + +## 2026-08-02 任务进度自动刷新验收 + +1. 重启本地后台,打开“新建任务”,保持“全自动流程”开启并创建任务。 +2. 创建成功后停留在任务详情页,不需要按 `F5`;“状态概览”和右侧“运行日志”应每 3 秒自动更新。 +3. 观察蓝色当前节点依次向后移动,已经完成的节点变绿;如果某一步失败,对应节点变黄,并自动出现“重试全自动流程”。 +4. 流程完成后,进度应显示 100%,顶部自动出现“前往发送中心”,有候选片段时同时出现“检查候选片段”。 +5. 可以在 AI Prompt 文本框临时输入几个字后继续等待,确认状态更新时文字没有消失、页面也没有跳回顶部。 +6. 如果网络短暂中断,状态卡会显示“自动更新暂时中断,正在重试”;恢复后不需要手动刷新。 diff --git a/README.md b/README.md index 312434b..9635b9d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,51 @@ # 牛马片场 / NiuMa Studio -牛马片场是一个运行在 Windows 本地的 AI 高光生产后台,用来把直播录像、综艺访谈、长视频素材整理成可转写、可分析、可审核、可切割、可加字幕、可进入发送中心的短视频生产任务。 +牛马片场是一个运行在 Windows 本地的单用户 AI 高光生产后台,用来把直播录像、综艺访谈和其他长视频整理成可审核、可排期、可发送的短视频内容。 -当前版本:`1.5.0`。 +当前版本:`2.0.0`(界面简称 `v2.0`)。 -v1.5.0 将抖音和 B站的立即发送、定时发送统一到 `PublishScheduler → Registry → LocalBrowserPublisher → Windows Worker → 平台 Publisher`。FastAPI 或 Docker 负责排期,Windows Worker 使用系统 Chrome 的独立账号目录执行真实投稿。只有读取到平台作品 ID、稿件 ID 或明确成功链接才进入 `PUBLISHED`;登录失效、验证码、风控和结果不确定进入 `NEED_REVIEW`,不会自动重复上传。 +v2.0 把此前分散在多个开发分支里的素材管理、全自动处理、审核切片、内容准备、排期和真实发布能力收拢为一条稳定主线。FastAPI / Docker 负责业务与排期,Windows Worker 使用系统 Chrome 的独立账号目录执行抖音或 B站投稿。只有取得平台作品 ID、稿件 ID、作品链接或其他明确成功证据才进入 `PUBLISHED`;登录失效、验证码、风控和结果不确定统一进入 `NEED_REVIEW`,不会自动重复上传。 -## 定时发送快速说明 +## v2.0 项目现状 + +| 范围 | 当前状态 | 说明 | +| --- | --- | --- | +| 本地生产闭环 | 已实现 | 素材入库、转写、AI 选片、人工审核、切片、文案、封面、排期和执行记录已经打通 | +| 抖音 / B站发布架构 | 已实现 | 立即发送与定时发送共用 Scheduler、Registry、Windows Worker 和平台 Publisher | +| 真实账号投稿 | 需逐账号灰度确认 | 平台页面、登录态和风控会变化,必须先用单条低风险素材人工验收 | +| 运行形态 | Windows 本地单用户 | FastAPI + SQLite + 本地文件系统,不面向公网,不是多用户 SaaS | +| 自动化边界 | 保守执行 | 不绕过二维码、短信、验证码、滑块、登录失效、平台风控或人工确认 | + +完整主流程: + +```text +上传本地视频 / 选择 NAS 或本地已有视频 +→ E 盘任务目录与逐句时间戳转写 +→ DeepSeek / OpenAI-compatible 或 Ollama 分析高光 +→ 人工审核候选片段 +→ 保存选择并按需生成最新切片 +→ 自动准备标题、简介、话题和封面帧 +→ 发送中心核对内容与账号 +→ 立即发送或按北京时间排期 +→ Windows Chrome Worker 投稿抖音 / B站 +→ 成功、失败或人工复核记录 +``` + +## 当前功能 + +- **素材与存储**:支持浏览器上传、本地路径和 NAS 路径;生产视频、音频、转写、切片与发布包统一保存在配置的任务存储目录,默认使用 `E:\直播间切片工作流存储`。 +- **任务处理**:每条素材建立独立任务目录;全自动模式依次执行素材准备、转写、AI 分析、自动选片、视频切割、文案和发布任务创建。 +- **进度与恢复**:全自动任务详情每 3 秒局部更新状态、10 步进度、运行日志、候选数和输出数,不会刷新整页;失败后可从对应步骤重试或续跑。 +- **转写**:支持火山引擎远程转写和本地 faster-whisper,输出可复用的逐句时间戳原文。 +- **AI 选片**:支持远程 OpenAI-compatible / DeepSeek 与本地 Ollama;提供“通用内容价值”和“综艺笑点优先”模式,长内容可分段分析、去重和排序。 +- **审核与切片**:候选片段可启用、禁用并编辑标题、摘要和出入点;“保存并同步发送中心”会在选择变化或文件缺失时生成安全的新切片版本,一致时只做幂等同步。 +- **字幕**:保留独立字幕工作台和 ASS / FFmpeg 成片能力;v2.0 全自动主流程继续跳过字幕生成、烧录和叠加,避免把字幕步骤强绑到自动发布。 +- **内容准备**:按原始任务分组管理抖音 / B站内容,支持 AI 补齐标题简介、平台话题、候选封面帧、账号和可见范围;已排期卡片有明确状态与北京时间标记。 +- **排期计划**:支持批量预览、跨午夜每日窗口、续接当前平台最晚排期、月历日期详情、按时间升序、取消发送并返回内容准备。 +- **真实发送**:抖音和 B站使用各自 Publisher;默认 `local_browser`,`manual_export` 只做显式本地发布包导出,旧 `opencli_publish` 默认关闭。 +- **数据安全**:软删除与永久删除分开;永久删除只清理项目托管产物,外部唯一原片受保护,运行中的转写、切片或发送任务不能被删除。 + +## 发布与排期要点 - `platform` 只表示目标平台:`douyin` / `bilibili`。 - `publish_mode` 表示执行方式:默认 `local_browser`;`manual_export` 只能显式选择;旧 `opencli_publish` 只有设置 `PUBLISH_ENABLE_OPENCLI_FALLBACK=true` 才能执行。 @@ -48,17 +87,17 @@ v1.5.0 将抖音和 B站的立即发送、定时发送统一到 `PublishSchedule - 用户确认后,将预览返回的精确时间列表作为 `confirmed_schedule` 调用 `PATCH /api/publish/jobs/schedule-batch`,后端逐条校验后写库。 - 清除排期时提交 `action=clear`;普通任务回到 `WAITING`,`FAILED` 保持失败,`NEED_REVIEW` 保持复核状态。 -## 当前状态 +## 技术现状 -- 后端:FastAPI 可启动,当前 API 版本为 `1.5.0`。 +- 后端:FastAPI 可启动,当前 API 版本为 `2.0.0`。 - 前端:HTML + CSS + JavaScript + Jinja2 后台页面,已完成 Apple 风格全页面美化。 - 数据库:SQLite,保存任务、候选片段、输出片段、字幕任务、发送任务和 AI 配置等信息。 - 视频处理:已接入 FFmpeg / FFprobe,用于音频提取、切片、封面帧和字幕成片。 - 转写:支持火山引擎远程转写和本地 faster-whisper。 - AI 分析:支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama;长视频会按小段分析再合并候选片段。 - 发送中心:分为内容准备、排期计划、执行记录;抖音 / B站真实发布由统一 Scheduler 和 Windows Chrome Worker 执行。 -- 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会保存账号密码、cookie 或真实 API Key。 -- 配置安全:真实 `.env` 已被 Git 忽略,不会提交真实 API Key。 +- 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会要求保存平台账号密码。 +- 配置安全:真实 `.env`、数据库、视频、日志、浏览器 Profile、Cookie 和 storage state 均被 Git 忽略;平台登录态只保留在本机专属浏览器目录。 - 品牌说明:当前页面主名为“牛马片场”,英文代号为 `NiuMa Studio`,Docker 技术名为 `niuma-studio`。 ## 新手启动方式 @@ -116,13 +155,13 @@ http://127.0.0.1:8001 --- -## v1.5.0 统一真实发布 +## v2.0 统一真实发布 - 应用启动时会自动启动 `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`。 +- 可选的 `manual_export` 会把发布包导出到 `PUBLISH_SCHEDULER_EXPORT_DIR`,默认是 `E:\直播间切片工作流存储\_发布包`;发布包包含视频、标题、简介、话题、封面文字、发布计划和元数据,成功状态为 `EXPORTED`。 - 手动执行一次扫描: ```powershell diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 183fb12..d0df518 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # 第三方参考与许可证说明 -牛马片场 v1.5.0 的发布模块在设计阶段参考了以下开源项目和官方文档。项目没有整仓复制这些外部代码,平台 Publisher、Registry、调度器和 Windows Worker 均按牛马片场现有 FastAPI / SQLite 架构独立实现。 +牛马片场 v2.0.0 的发布模块在设计阶段参考了以下开源项目和官方文档。项目没有整仓复制这些外部代码,平台 Publisher、Registry、调度器和 Windows Worker 均按牛马片场现有 FastAPI / SQLite 架构独立实现。 ## social-auto-upload-web-ui diff --git a/VERSION b/VERSION index bc80560..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.0 +2.0.0 diff --git a/app/core/config.py b/app/core/config.py index d1ef0df..383d705 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -60,6 +60,10 @@ class Settings: data_dir: Path = _env_path("DATA_DIR", PROJECT_ROOT / "data") storage_root: Path = _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT) tasks_dir: Path = _env_path("TASKS_DIR", _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT)) + upload_temp_dir: Path = _env_path( + "UPLOAD_TEMP_DIR", + _env_path("TASKS_DIR", _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT)) / "_临时上传", + ) database_path: Path = _env_path( "DATABASE_PATH", _env_path("DATA_DIR", PROJECT_ROOT / "data") / "workflow.sqlite3", @@ -200,7 +204,7 @@ class Settings: publish_worker_allowed_roots: str = _env("PUBLISH_WORKER_ALLOWED_ROOTS", "") publish_scheduler_export_dir: Path = _env_path( "PUBLISH_SCHEDULER_EXPORT_DIR", - PROJECT_ROOT / "outputs" / "publish_packages", + _env_path("STORAGE_ROOT", EXTERNAL_STORAGE_ROOT) / "_发布包", ) publish_scheduler_allow_publish_without_review: bool = _env_bool( "PUBLISH_SCHEDULER_ALLOW_PUBLISH_WITHOUT_REVIEW", diff --git a/app/main.py b/app/main.py index 387a2ee..22c4233 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ from contextlib import asynccontextmanager +import os +import tempfile from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse, JSONResponse @@ -8,6 +10,7 @@ from app.db.database import init_db from app.routers import ai_prompts, files, media, pages, publish, settings as settings_router, tasks from app.services.publish_scheduler import start_scheduler_background +from app.services.storage_service import configure_runtime_media_storage # /media 和 /static 的 Origin 白名单 @@ -43,6 +46,9 @@ def _build_allow_origin_header(origin: str) -> str: @asynccontextmanager async def lifespan(app: FastAPI): + previous_temp = tempfile.tempdir + previous_temp_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + app.state.media_storage = configure_runtime_media_storage() init_db() scheduler = await start_scheduler_background() app.state.publish_scheduler = scheduler @@ -51,12 +57,18 @@ async def lifespan(app: FastAPI): finally: if scheduler: scheduler.stop() + tempfile.tempdir = previous_temp + for name, value in previous_temp_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value app = FastAPI( title=settings.app_name, description=settings.app_description, - version="1.5.0", + version="2.0.0", lifespan=lifespan, ) diff --git a/app/models/task.py b/app/models/task.py index f7844db..0ec6f43 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -55,8 +55,8 @@ class TaskCreate(BaseModel): auto_schedule_mode: Literal["default", "immediate", "interval", "daily_window"] = "default" auto_schedule_start_at: Optional[str] = Field(default="", max_length=80) auto_schedule_interval_hours: int = Field(default=3, ge=1, le=168) - auto_schedule_daily_start_time: str = Field(default="09:00", max_length=5) - auto_schedule_daily_end_time: str = Field(default="21:00", max_length=5) + auto_schedule_daily_start_time: str = Field(default="07:00", max_length=5) + auto_schedule_daily_end_time: str = Field(default="00:00", max_length=5) auto_metadata_use_ai: bool = False @validator("auto_clip_count") @@ -194,8 +194,8 @@ class PublishBatchScheduleUpdate(BaseModel): 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) + daily_start_time: str = Field(default="07:00", min_length=5, max_length=5) + daily_end_time: str = Field(default="00:00", min_length=5, max_length=5) confirmed_schedule: list[dict[str, str]] = Field(default_factory=list) @validator("job_ids") @@ -206,6 +206,22 @@ def validate_schedule_job_ids(cls, value: list[str]) -> list[str]: return normalized +class PublishScheduleNextStartRequest(BaseModel): + job_ids: list[str] = Field(default_factory=list) + platform: Literal["douyin", "bilibili"] + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=80) + interval_minutes: int = Field(default=180, ge=1, le=10080) + daily_start_time: str = Field(default="07:00", min_length=5, max_length=5) + daily_end_time: str = Field(default="00:00", min_length=5, max_length=5) + + @validator("job_ids") + def validate_next_start_job_ids(cls, value: list[str]) -> list[str]: + normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + if not normalized: + raise ValueError("至少选择一条发布任务") + return normalized + + class PublishJobContentUpdate(BaseModel): title: str = Field(..., min_length=1, max_length=120) caption: str = Field(..., min_length=1, max_length=2000) diff --git a/app/routers/publish.py b/app/routers/publish.py index 71a05e0..b23573c 100644 --- a/app/routers/publish.py +++ b/app/routers/publish.py @@ -18,6 +18,7 @@ PublishMarkPublishedRequest, PublishPlatformConfigUpdate, PublishRetryRequest, + PublishScheduleNextStartRequest, PublishSendJobUpdate, ) from app.services import publish_service @@ -419,6 +420,23 @@ async def preview_publish_jobs_schedule(payload: PublishBatchScheduleUpdate) -> raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/schedules/next-start") +async def get_publish_jobs_next_schedule_start(payload: PublishScheduleNextStartRequest) -> dict: + try: + return PublishScheduler().next_batch_schedule_start( + payload.job_ids, + platform=payload.platform, + timezone_name=payload.timezone, + interval_minutes=payload.interval_minutes, + daily_start_time=payload.daily_start_time, + daily_end_time=payload.daily_end_time, + ) + except PublishPlatformIsolationBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.patch("/jobs/{job_id}/content") async def update_publish_job_content(job_id: str, payload: PublishJobContentUpdate) -> dict: try: diff --git a/app/routers/tasks.py b/app/routers/tasks.py index eb2f4f1..163277e 100644 --- a/app/routers/tasks.py +++ b/app/routers/tasks.py @@ -19,7 +19,13 @@ from app.services import task_service from app.services.ai_prompt_preset_service import update_task_ai_prompt_preset from app.services.pipeline_engine import start_auto_pipeline -from app.services.storage_service import allocate_task_dir_name, save_uploaded_video +from app.services.storage_service import ( + allocate_task_dir_name, + remove_failed_task_directory, + save_uploaded_video, + StorageSafetyError, +) +from app.services.task_lifecycle_service import TaskDeletionConflictError from app.services import job_service from app.services import job_worker @@ -60,47 +66,58 @@ async def create_upload_task( auto_schedule_mode: str = Form("default"), auto_schedule_start_at: str | None = Form(""), auto_schedule_interval_hours: int = Form(3), - auto_schedule_daily_start_time: str = Form("09:00"), - auto_schedule_daily_end_time: str = Form("21:00"), + auto_schedule_daily_start_time: str = Form("07:00"), + auto_schedule_daily_end_time: str = Form("00:00"), auto_metadata_use_ai: bool = Form(False), video_file: UploadFile = File(...), ) -> dict: task_id = uuid4().hex[:12] task_dir_name = allocate_task_dir_name(task_name, exclude_task_id=task_id) - saved_path = save_uploaded_video( - task_id, - video_file.filename or "source_video.mp4", - video_file.file, - task_dir_name=task_dir_name, - ) - payload = TaskCreate( - task_name=task_name, - source_type="upload", - platform=platform, - original_video_path=str(saved_path), - max_clip_duration=max_clip_duration, - candidate_clip_count=candidate_clip_count, - selection_profile=selection_profile, - final_clip_target=final_clip_target, - ai_preference=ai_preference, - auto_mode=auto_mode, - auto_clip_count=auto_clip_count, - auto_min_clip_seconds=auto_min_clip_seconds, - auto_max_clip_seconds=auto_max_clip_seconds, - auto_schedule_mode=auto_schedule_mode, - auto_schedule_start_at=auto_schedule_start_at, - auto_schedule_interval_hours=auto_schedule_interval_hours, - auto_schedule_daily_start_time=auto_schedule_daily_start_time, - auto_schedule_daily_end_time=auto_schedule_daily_end_time, - auto_metadata_use_ai=auto_metadata_use_ai, - ) + task_record_created = False try: + saved_path = await run_in_threadpool( + save_uploaded_video, + task_id, + video_file.filename or "source_video.mp4", + video_file.file, + task_dir_name, + ) + payload = TaskCreate( + task_name=task_name, + source_type="upload", + platform=platform, + original_video_path=str(saved_path), + max_clip_duration=max_clip_duration, + candidate_clip_count=candidate_clip_count, + selection_profile=selection_profile, + final_clip_target=final_clip_target, + ai_preference=ai_preference, + auto_mode=auto_mode, + auto_clip_count=auto_clip_count, + auto_min_clip_seconds=auto_min_clip_seconds, + auto_max_clip_seconds=auto_max_clip_seconds, + auto_schedule_mode=auto_schedule_mode, + auto_schedule_start_at=auto_schedule_start_at, + auto_schedule_interval_hours=auto_schedule_interval_hours, + auto_schedule_daily_start_time=auto_schedule_daily_start_time, + auto_schedule_daily_end_time=auto_schedule_daily_end_time, + auto_metadata_use_ai=auto_metadata_use_ai, + ) result = task_service.create_task_record(payload, task_id=task_id, task_dir_name=task_dir_name) + task_record_created = True if payload.auto_mode: result["auto_pipeline"] = start_auto_pipeline(task_id, background_tasks=background_tasks) return result except ValueError as exc: + if not task_record_created: + await run_in_threadpool(remove_failed_task_directory, task_id, task_dir_name) raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + if not task_record_created: + await run_in_threadpool(remove_failed_task_directory, task_id, task_dir_name) + raise + finally: + await video_file.close() @router.get("/{task_id}") @@ -111,6 +128,14 @@ async def get_task_detail(task_id: str) -> dict: return task +@router.get("/{task_id}/live-status") +async def get_task_live_status(task_id: str) -> dict: + try: + return task_service.get_task_live_status(task_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.get("/{task_id}/transcript-status") async def get_transcript_status(task_id: str) -> dict: try: @@ -131,8 +156,14 @@ async def get_ai_analysis_status(task_id: str) -> dict: async def delete_task(task_id: str) -> dict: try: return task_service.soft_delete_task(task_id) + except TaskDeletionConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except StorageSafetyError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.patch("/{task_id}/status") @@ -293,6 +324,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, diff --git a/app/services/database_backup_service.py b/app/services/database_backup_service.py index 50d0db8..f868c51 100644 --- a/app/services/database_backup_service.py +++ b/app/services/database_backup_service.py @@ -17,6 +17,7 @@ PUBLISH_MIGRATION_JOURNAL_GLOB = f"{PUBLISH_MIGRATION_BACKUP_GLOB}-journal" PUBLISH_MIGRATION_BACKUP_COOLDOWN = timedelta(hours=24) PUBLISH_MIGRATION_BACKUP_KEEP_DAYS = 14 +MEDIA_CLEANUP_BACKUP_PREFIX = "workflow-before-media-cleanup-" BACKUP_TIMEZONE = ZoneInfo("Asia/Shanghai") @@ -262,3 +263,53 @@ def create_publish_migration_backup( backup_connection.close() if source_connection is not None: source_connection.close() + + +def create_media_cleanup_backup( + database_path: Path, + backup_dir: Path, + *, + now: datetime | None = None, +) -> Path: + """永久删除任务媒体前,原子创建一份仅包含 SQLite 元数据的备份。""" + database_path = database_path.resolve() + backup_dir = backup_dir.resolve() + now = now.astimezone(BACKUP_TIMEZONE) if now else datetime.now(BACKUP_TIMEZONE) + backup_dir.mkdir(parents=True, exist_ok=True) + + timestamp = now.strftime("%Y%m%d-%H%M%S-%f") + final_path = backup_dir / ( + f"{MEDIA_CLEANUP_BACKUP_PREFIX}{timestamp}-{os.getpid()}-{uuid4().hex[:8]}.sqlite3" + ) + temporary_path = final_path.with_name(f"{final_path.name}.tmp-{uuid4().hex}") + source_connection: sqlite3.Connection | None = None + backup_connection: sqlite3.Connection | None = None + try: + source_connection = sqlite3.connect( + f"{database_path.as_uri()}?mode=ro", + uri=True, + timeout=10, + ) + backup_connection = sqlite3.connect(str(temporary_path), timeout=10) + source_connection.backup(backup_connection) + backup_connection.close() + backup_connection = None + source_connection.close() + source_connection = None + + integrity = sqlite_quick_check(temporary_path) + if integrity != "ok": + raise BackupSafetyError(f"媒体清理前备份完整性检查失败:{integrity}") + os.replace(temporary_path, final_path) + return final_path + except Exception as exc: + if temporary_path.exists(): + temporary_path.unlink() + if isinstance(exc, BackupSafetyError): + raise + raise BackupSafetyError(f"创建媒体清理前备份失败:{exc}") from exc + finally: + if backup_connection is not None: + backup_connection.close() + if source_connection is not None: + source_connection.close() diff --git a/app/services/job_service.py b/app/services/job_service.py index 859d8d9..b5455ef 100644 --- a/app/services/job_service.py +++ b/app/services/job_service.py @@ -23,12 +23,14 @@ JOB_STATUS_RUNNING = "running" JOB_STATUS_COMPLETED = "completed" JOB_STATUS_FAILED = "failed" +JOB_STATUS_CANCELLED = "cancelled" JOB_STATUS_LABELS = { JOB_STATUS_QUEUED: "排队中", JOB_STATUS_RUNNING: "运行中", JOB_STATUS_COMPLETED: "已完成", JOB_STATUS_FAILED: "失败", + JOB_STATUS_CANCELLED: "已取消", } JOB_TYPE_LABELS = { diff --git a/app/services/job_worker.py b/app/services/job_worker.py index 2909cde..074ff5c 100644 --- a/app/services/job_worker.py +++ b/app/services/job_worker.py @@ -17,6 +17,8 @@ def execute_job(job_id: str) -> dict: job = job_service.get_job(job_id) if not job: raise ValueError(f"job 不存在:{job_id}") + if job.get("status") == job_service.JOB_STATUS_CANCELLED: + return job job_type = job.get("job_type") task_id = job.get("task_id") diff --git a/app/services/pipeline_engine.py b/app/services/pipeline_engine.py index 3bdb0f9..7632f1f 100644 --- a/app/services/pipeline_engine.py +++ b/app/services/pipeline_engine.py @@ -13,6 +13,7 @@ from app.services.auto_publish_service import create_auto_publish_jobs, platforms_for_task from app.services.metadata_generator import MetadataGenerator from app.services.publish_service import generate_publish_cover_for_item +from app.services.publish_time import next_allowed_schedule_time from app.services.storage_service import ( create_task_directory, get_artifact_paths, @@ -63,8 +64,8 @@ "auto_schedule_mode": "default", "auto_schedule_start_at": "", "auto_schedule_interval_hours": 3, - "auto_schedule_daily_start_time": "09:00", - "auto_schedule_daily_end_time": "21:00", + "auto_schedule_daily_start_time": "07:00", + "auto_schedule_daily_end_time": "00:00", "auto_metadata_use_ai": False, } @@ -143,6 +144,8 @@ def _get_task(self, task_id: str) -> dict: task = task_service.get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") + if task.get("is_deleted"): + raise ValueError("任务已永久删除,已停止后续自动处理") return task def _load_auto_config(self, task: dict) -> dict: @@ -470,8 +473,8 @@ def _write_task_summary(self, task_id: str, status: str, error: str) -> dict: "publish_job_count": publish_job_count, "need_review_clips": need_review, "failures": failures, - "next_step": "打开发布中心人工确认发布任务;真正定时发送将在 v1.4.0 实现。", - "subtitle_note": "v1.3.0 全自动模式已跳过加字幕、字幕样式渲染和字幕烧录。", + "next_step": "打开发送中心核对内容、账号与北京时间;确认后可立即发送或设置排期。", + "subtitle_note": "v2.0 全自动模式继续跳过加字幕、字幕样式渲染和字幕烧录。", } summary_path = paths["analysis_path"].parent / "task_summary.json" summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") @@ -553,20 +556,27 @@ def build_schedule_times(count: int, config: dict, now: datetime | None = None) return [(start + index * interval).isoformat(timespec="seconds") for index in range(count)] if mode == "daily_window": - window_start = _parse_clock(str(config.get("auto_schedule_daily_start_time") or "09:00"), time(9, 0)) - window_end = _parse_clock(str(config.get("auto_schedule_daily_end_time") or "21:00"), time(21, 0)) + window_start = _parse_clock( + str(config.get("auto_schedule_daily_start_time") or "07:00"), + time(7, 0), + ).isoformat(timespec="minutes") + window_end = _parse_clock( + str(config.get("auto_schedule_daily_end_time") or "00:00"), + time(0, 0), + ).isoformat(timespec="minutes") scheduled = [] - cursor = start + cursor = next_allowed_schedule_time( + start, + daily_start_time=window_start, + daily_end_time=window_end, + ) while len(scheduled) < count: - day_start = datetime.combine(cursor.date(), window_start).replace(tzinfo=cursor.tzinfo) - day_end = datetime.combine(cursor.date(), window_end).replace(tzinfo=cursor.tzinfo) - if cursor < day_start: - cursor = day_start - if cursor > day_end: - cursor = datetime.combine(cursor.date() + timedelta(days=1), window_start).replace(tzinfo=cursor.tzinfo) - continue scheduled.append(cursor.isoformat(timespec="seconds")) - cursor = cursor + timedelta(hours=interval_hours) + cursor = next_allowed_schedule_time( + cursor + timedelta(hours=interval_hours), + daily_start_time=window_start, + daily_end_time=window_end, + ) return scheduled return [(start + index * timedelta(hours=3)).isoformat(timespec="seconds") for index in range(count)] diff --git a/app/services/publish_scheduler.py b/app/services/publish_scheduler.py index 98b6a4f..6e02529 100644 --- a/app/services/publish_scheduler.py +++ b/app/services/publish_scheduler.py @@ -29,6 +29,7 @@ build_schedule_times, ensure_future, local_display, + next_allowed_schedule_time, parse_datetime, to_utc_iso, utc_now, @@ -735,6 +736,74 @@ def preview_batch_schedule( ) return {"status": "ok", "timezone": timezone_name, "schedule": schedule} + def next_batch_schedule_start( + self, + job_ids: list[str], + *, + platform: str, + timezone_name: str = "Asia/Shanghai", + interval_minutes: int = 180, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", + ) -> dict[str, Any]: + ids = self._validate_batch_jobs(job_ids, platform) + placeholders = ",".join("?" for _ in ids) + with get_connection() as connection: + rows = connection.execute( + f""" + SELECT id, status, scheduled_at + FROM publish_jobs + WHERE platform = ? + AND status IN ('WAITING', 'SCHEDULED', 'PUBLISHING') + AND TRIM(COALESCE(scheduled_at, '')) <> '' + AND id NOT IN ({placeholders}) + """, + [platform, *ids], + ).fetchall() + + now = utc_now() + candidates: list[tuple[datetime, str]] = [] + for row in rows: + try: + scheduled = parse_datetime(row["scheduled_at"]).astimezone(timezone.utc) + except ValueError: + continue + if scheduled > now: + candidates.append((scheduled, str(row["id"]))) + + if not candidates: + return { + "status": "empty", + "timezone": timezone_name, + "message": "当前平台暂无其他未来排期,请手动选择第 1 条发布时间。", + "latest_job_id": "", + "latest_scheduled_at_utc": "", + "latest_scheduled_at_local_display": "", + "next_start_at_utc": "", + "next_start_at_local": "", + "next_start_at_local_display": "", + } + + latest, latest_job_id = max(candidates, key=lambda item: item[0]) + zone = app_zone(timezone_name) + candidate = latest.astimezone(zone) + timedelta(minutes=interval_minutes) + next_start = next_allowed_schedule_time( + candidate, + daily_start_time=daily_start_time, + daily_end_time=daily_end_time, + ) + return { + "status": "ok", + "timezone": timezone_name, + "message": "已接在当前平台最晚排期后。", + "latest_job_id": latest_job_id, + "latest_scheduled_at_utc": to_utc_iso(latest), + "latest_scheduled_at_local_display": local_display(latest, timezone_name), + "next_start_at_utc": to_utc_iso(next_start), + "next_start_at_local": next_start.strftime("%Y-%m-%dT%H:%M"), + "next_start_at_local_display": local_display(next_start, timezone_name), + } + def update_batch_schedule( self, job_ids: list[str], @@ -744,8 +813,8 @@ def update_batch_schedule( 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", + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", confirmed_schedule: list[dict[str, str]] | None = None, ) -> dict[str, Any]: ids = self._validate_batch_jobs(job_ids, platform) diff --git a/app/services/publish_time.py b/app/services/publish_time.py index 52d4298..3da9274 100644 --- a/app/services/publish_time.py +++ b/app/services/publish_time.py @@ -92,14 +92,26 @@ def _next_allowed_schedule_time(cursor: datetime, window_start: time, window_end return datetime.combine(cursor.date(), window_start, tzinfo=cursor.tzinfo) +def next_allowed_schedule_time( + cursor: datetime, + *, + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", +) -> datetime: + """把一个候选时间顺延到每日允许发布时段。""" + window_start = parse_clock(daily_start_time, "每日开始时间") + window_end = parse_clock(daily_end_time, "每日结束时间") + return _next_allowed_schedule_time(cursor, window_start, window_end) + + def build_schedule_times( count: int, *, start_at_local: str, timezone_name: str | None = None, interval_minutes: int = 180, - daily_start_time: str = "09:00", - daily_end_time: str = "21:00", + daily_start_time: str = "07:00", + daily_end_time: str = "00:00", reject_past: bool = True, ) -> list[str]: if count <= 0: diff --git a/app/services/storage_service.py b/app/services/storage_service.py index aff4d1b..56065aa 100644 --- a/app/services/storage_service.py +++ b/app/services/storage_service.py @@ -1,7 +1,10 @@ +from dataclasses import dataclass from pathlib import Path, PureWindowsPath +import os import re import sqlite3 import shutil +import tempfile from typing import BinaryIO from uuid import uuid4 @@ -26,6 +29,62 @@ _PATH_TRAVERSAL_MARKERS = ("..", "~") +class StorageSafetyError(RuntimeError): + """存储路径不安全或不满足清理条件。""" + + +@dataclass(frozen=True) +class ManagedMediaTarget: + label: str + path: Path + + +@dataclass(frozen=True) +class TaskMediaCleanupPlan: + task_id: str + targets: tuple[ManagedMediaTarget, ...] + external_source_path: Path | None + + @property + def existing_targets(self) -> tuple[ManagedMediaTarget, ...]: + return tuple(target for target in self.targets if target.path.exists()) + + +@dataclass(frozen=True) +class TaskMediaCleanupResult: + deleted_paths: tuple[str, ...] + freed_bytes: int + external_source_preserved: bool + + +def _ensure_writable_directory(path: Path, label: str) -> Path: + try: + path.mkdir(parents=True, exist_ok=True) + probe_path = path / f".niuma-write-test-{uuid4().hex}" + probe_path.write_bytes(b"ok") + probe_path.unlink() + except OSError as exc: + raise RuntimeError(f"{label}不可用或不可写:{path};原因:{exc}") from exc + return path.resolve() + + +def configure_runtime_media_storage() -> dict[str, str]: + """准备大文件目录,并把当前应用进程的临时目录固定到存储盘。""" + tasks_dir = _ensure_writable_directory(settings.tasks_dir, "任务存储目录") + upload_temp_dir = _ensure_writable_directory(settings.upload_temp_dir, "上传临时目录") + export_dir = _ensure_writable_directory(settings.publish_scheduler_export_dir, "发布包目录") + + temp_value = str(upload_temp_dir) + os.environ["TEMP"] = temp_value + os.environ["TMP"] = temp_value + tempfile.tempdir = temp_value + return { + "tasks_dir": str(tasks_dir), + "upload_temp_dir": temp_value, + "publish_export_dir": str(export_dir), + } + + def _collect_allowed_roots() -> list[Path]: """收集所有允许访问的文件系统根目录。""" roots: list[Path] = [] @@ -107,6 +166,11 @@ def ensure_storage_root() -> Path: return settings.storage_root +def ensure_tasks_root() -> Path: + settings.tasks_dir.mkdir(parents=True, exist_ok=True) + return settings.tasks_dir + + def _storage_relative_parts(task_dir_name: str) -> tuple[str, ...]: return tuple(part for part in PureWindowsPath(task_dir_name).parts if part not in {"", "."}) @@ -159,14 +223,14 @@ def allocate_task_dir_name( base_name = sanitize_task_dir_name(task_name, fallback=exclude_task_id or "untitled") parent_parts = _storage_relative_parts(parent_dir_name or "") existing_names = _get_existing_task_dir_names(exclude_task_id=exclude_task_id) - root = ensure_storage_root().joinpath(*parent_parts) + root = ensure_tasks_root().joinpath(*parent_parts) root.mkdir(parents=True, exist_ok=True) for index in range(1, 1000): candidate_name = base_name if index == 1 else f"{base_name} ({index})" candidate_parts = (*parent_parts, candidate_name) relative_name = str(PureWindowsPath(*candidate_parts)) - candidate_path = ensure_storage_root().joinpath(*candidate_parts) + candidate_path = ensure_tasks_root().joinpath(*candidate_parts) if relative_name.lower() not in existing_names and not candidate_path.exists(): return relative_name @@ -308,10 +372,9 @@ def get_source_video_path(task: dict) -> Path | None: def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task_dir_name: str | None = None) -> Path: - create_task_directory(task_id, task_dir_name) - # 扩展名校验 _validate_upload_extension(filename) + create_task_directory(task_id, task_dir_name) safe_name = Path(filename or "source_video").name if not Path(safe_name).suffix: @@ -321,25 +384,176 @@ def save_uploaded_video(task_id: str, filename: str, file_object: BinaryIO, task # 流式写入 + 大小限制检查 max_size = settings.max_upload_size_bytes written = 0 - with output_path.open("wb") as target: - while True: - chunk = file_object.read(1024 * 1024) # 1MB chunks - if not chunk: - break - written += len(chunk) - if written > max_size: - # 删除已写入的部分 - try: - output_path.unlink() - except OSError: - pass - max_gb = max_size / (1024 * 1024 * 1024) - raise ValueError(f"上传文件超过大小限制({max_gb:.1f} GB)") - target.write(chunk) + try: + with output_path.open("wb") as target: + while True: + chunk = file_object.read(1024 * 1024) # 1MB chunks + if not chunk: + break + written += len(chunk) + if written > max_size: + max_gb = max_size / (1024 * 1024 * 1024) + raise ValueError(f"上传文件超过大小限制({max_gb:.1f} GB)") + target.write(chunk) + except Exception: + try: + output_path.unlink(missing_ok=True) + except OSError: + pass + raise return output_path +def remove_failed_task_directory(task_id: str, task_dir_name: str) -> None: + """仅清理本次尚未写入数据库的新任务目录。""" + if _fetch_task_dir_name(task_id): + return + task_dir = get_task_directory(task_id, task_dir_name) + tasks_root = settings.tasks_dir.resolve() + resolved = task_dir.resolve(strict=False) + try: + within_root = resolved.is_relative_to(tasks_root) + except AttributeError: # pragma: no cover - Python 3.8 兼容 + within_root = str(resolved).lower().startswith(str(tasks_root).lower() + os.sep) + if resolved == tasks_root or not within_root or task_dir.is_symlink(): + raise StorageSafetyError(f"拒绝清理不安全的任务目录:{task_dir}") + if task_dir.exists(): + shutil.rmtree(task_dir) + + +def _safe_relative_parts(value: str, label: str) -> tuple[str, ...]: + windows_path = PureWindowsPath(str(value or "").strip()) + parts = tuple(part for part in windows_path.parts if part not in {"", "."}) + if ( + not parts + or windows_path.is_absolute() + or windows_path.drive + or any(part in _PATH_TRAVERSAL_MARKERS for part in parts) + ): + raise StorageSafetyError(f"{label}包含不安全路径:{value}") + return parts + + +def _safe_managed_child(root: Path, parts: tuple[str, ...], label: str) -> Path: + resolved_root = root.resolve(strict=False) + candidate = root.joinpath(*parts) + resolved_candidate = candidate.resolve(strict=False) + try: + within_root = resolved_candidate.is_relative_to(resolved_root) + except AttributeError: # pragma: no cover - Python 3.8 兼容 + within_root = str(resolved_candidate).lower().startswith(str(resolved_root).lower() + os.sep) + if resolved_candidate == resolved_root or not within_root or candidate.is_symlink(): + raise StorageSafetyError(f"拒绝删除不安全的{label}:{candidate}") + return candidate + + +def _deduplicate_targets(targets: list[ManagedMediaTarget]) -> tuple[ManagedMediaTarget, ...]: + unique: list[ManagedMediaTarget] = [] + seen: set[str] = set() + for target in targets: + key = str(target.path.resolve(strict=False)).lower() + if key in seen: + continue + seen.add(key) + unique.append(target) + return tuple(unique) + + +def _path_is_within(path: Path, parent: Path) -> bool: + try: + return path.resolve(strict=False).is_relative_to(parent.resolve(strict=False)) + except (AttributeError, OSError, ValueError): + path_value = str(path.resolve(strict=False)).lower() + parent_value = str(parent.resolve(strict=False)).lower() + return path_value.startswith(parent_value + os.sep) + + +def build_task_media_cleanup_plan(task: dict, *, include_legacy: bool = True) -> TaskMediaCleanupPlan: + task_id = str(task.get("id") or "").strip() + task_id_parts = _safe_relative_parts(task_id, "任务 ID") + if len(task_id_parts) != 1: + raise StorageSafetyError(f"任务 ID 必须是单层目录名:{task_id}") + + task_dir_name = str(task.get("task_dir_name") or task_id) + task_parts = _safe_relative_parts(task_dir_name, "任务目录名") + task_dir = _safe_managed_child(settings.tasks_dir, task_parts, "任务目录") + targets = [ManagedMediaTarget("E 盘任务目录", task_dir)] + + export_dir = _safe_managed_child( + settings.publish_scheduler_export_dir, + task_id_parts, + "发布包目录", + ) + targets.append(ManagedMediaTarget("E 盘发布包目录", export_dir)) + + if include_legacy: + legacy_root = settings.project_root / "tasks" + legacy_values = [task_id] + if len(task_parts) == 1 and task_dir_name.lower() != task_id.lower(): + legacy_values.append(task_dir_name) + for legacy_value in legacy_values: + legacy_parts = _safe_relative_parts(legacy_value, "旧版任务目录名") + legacy_path = _safe_managed_child(legacy_root, legacy_parts, "旧版 C 盘任务目录") + targets.append(ManagedMediaTarget("旧版 C 盘任务目录", legacy_path)) + + managed_targets = _deduplicate_targets(targets) + source_path = get_source_video_path(task) + external_source_path = None + if source_path and source_path.exists(): + if not any(_path_is_within(source_path, target.path) for target in managed_targets): + external_source_path = source_path + + return TaskMediaCleanupPlan( + task_id=task_id, + targets=managed_targets, + external_source_path=external_source_path, + ) + + +def _directory_size_bytes(path: Path) -> int: + total = 0 + for child in path.rglob("*"): + try: + if child.is_file() and not child.is_symlink(): + total += child.stat().st_size + except OSError: + continue + return total + + +def task_media_cleanup_plan_size(plan: TaskMediaCleanupPlan) -> int: + return sum( + _directory_size_bytes(target.path) + for target in plan.existing_targets + if target.path.is_dir() and not target.path.is_symlink() + ) + + +def apply_task_media_cleanup_plan(plan: TaskMediaCleanupPlan) -> TaskMediaCleanupResult: + deleted_paths: list[str] = [] + freed_bytes = 0 + for target in plan.targets: + path = target.path + if not path.exists(): + continue + if path.is_symlink() or not path.is_dir(): + raise StorageSafetyError(f"拒绝删除异常的{target.label}:{path}") + size = _directory_size_bytes(path) + try: + shutil.rmtree(path) + except OSError as exc: + raise RuntimeError(f"删除{target.label}失败:{path};原因:{exc}") from exc + freed_bytes += size + deleted_paths.append(str(path)) + + return TaskMediaCleanupResult( + deleted_paths=tuple(deleted_paths), + freed_bytes=freed_bytes, + external_source_preserved=plan.external_source_path is not None, + ) + + def move_task_directory_to_trash(task_id: str, task_name: str, task_dir_name: str | None = None) -> tuple[str, Path]: current_dir_name = resolve_task_dir_name(task_id, task_dir_name) source_dir = get_task_directory(task_id, current_dir_name) diff --git a/app/services/task_lifecycle_service.py b/app/services/task_lifecycle_service.py index 7103e25..34a2323 100644 --- a/app/services/task_lifecycle_service.py +++ b/app/services/task_lifecycle_service.py @@ -8,10 +8,37 @@ from app.db.database import get_connection from app.models.task import TaskCreate, TaskStatus -from app.services.storage_service import allocate_task_dir_name, create_task_directory, validate_source_video_path +from app.services.storage_service import ( + apply_task_media_cleanup_plan, + allocate_task_dir_name, + build_task_media_cleanup_plan, + create_task_directory, + validate_source_video_path, +) from app.services.task_log_service import append_task_log +class TaskDeletionConflictError(RuntimeError): + """任务仍在执行,暂时不能删除其媒体文件。""" + + +ACTIVE_TASK_STATUSES = { + TaskStatus.CREATED.value, + TaskStatus.PREPARING_SOURCE.value, + TaskStatus.TRANSCRIBING.value, + TaskStatus.AI_ANALYZING.value, + TaskStatus.CLIP_SELECTING.value, + TaskStatus.VIDEO_CUTTING.value, + TaskStatus.METADATA_GENERATING.value, + TaskStatus.SCHEDULE_CREATING.value, + TaskStatus.PUBLISH_JOB_CREATING.value, + TaskStatus.audio_extracting.value, + TaskStatus.transcribing.value, + TaskStatus.ai_analyzing.value, + TaskStatus.cutting.value, +} + + def create_task_record(payload: TaskCreate, task_id: str | None = None, task_dir_name: str | None = None) -> dict: from app.services.task_service import _now_iso, get_status_label, STATUS_PROGRESS # noqa: F811 @@ -228,34 +255,109 @@ def update_task_selection_settings( } -def soft_delete_task(task_id: str) -> dict: +def delete_task_permanently(task_id: str) -> dict: from app.services.task_service import _now_iso, get_task # noqa: F811 task = get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") - if task.get("is_deleted"): - return { - "message": "任务已隐藏,无需重复操作。", - "task_id": task_id, - "task_dir": task["task_dir"], - } - + cleanup_plan = build_task_media_cleanup_plan(task) + existing_target_count = len(cleanup_plan.existing_targets) now = _now_iso() with get_connection() as connection: - connection.execute( - """ - UPDATE tasks - SET is_deleted = 1, deleted_at = ?, updated_at = ? - WHERE id = ? - """, - (now, now, task_id), - ) - connection.commit() - - append_task_log(task_id, "任务已从列表隐藏,文件未删除") + try: + connection.execute("BEGIN IMMEDIATE") + current = connection.execute( + "SELECT status, COALESCE(is_deleted, 0) AS is_deleted FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not current: + raise ValueError("任务不存在") + + if not current["is_deleted"] and str(current["status"] or "") in ACTIVE_TASK_STATUSES: + raise TaskDeletionConflictError("任务正在处理,请等待处理结束后再永久删除。") + + conflicting_task = connection.execute( + """ + SELECT id + FROM tasks + WHERE id != ? AND COALESCE(is_deleted, 0) = 0 + AND LOWER(COALESCE(task_dir_name, id)) = LOWER(?) + LIMIT 1 + """, + (task_id, str(task.get("task_dir_name") or task_id)), + ).fetchone() + if conflicting_task: + raise TaskDeletionConflictError( + "该目录仍被另一条有效任务使用,已拒绝删除以避免误删。" + ) + + running_job = connection.execute( + "SELECT id FROM workflow_jobs WHERE task_id = ? AND status = 'running' LIMIT 1", + (task_id,), + ).fetchone() + if running_job: + raise TaskDeletionConflictError("任务仍有后台切片工作正在运行,请等待结束后再删除。") + + publishing_job = connection.execute( + "SELECT id FROM publish_jobs WHERE task_id = ? AND status = 'PUBLISHING' LIMIT 1", + (task_id,), + ).fetchone() + if publishing_job: + raise TaskDeletionConflictError("任务正在向平台发送视频,请等待发送结束后再删除。") + + cleanup_result = apply_task_media_cleanup_plan(cleanup_plan) + connection.execute( + """ + UPDATE workflow_jobs + SET status = 'cancelled', progress = 100, + message = '任务已永久删除,排队任务已取消', + error_message = '任务已永久删除', finished_at = ?, updated_at = ? + WHERE task_id = ? AND status = 'queued' + """, + (now, now, task_id), + ) + connection.execute( + """ + UPDATE publish_jobs + SET status = 'CANCELLED', scheduled_at = '', next_attempt_at = NULL, + error_code = 'task_deleted', error_message = '任务已永久删除', + last_error = '任务已永久删除', history_hidden = 1, + finished_at = ?, updated_at = ? + WHERE task_id = ? + AND status NOT IN ('PUBLISHED', 'EXPORTED', 'NEED_REVIEW', 'CANCELLED') + """, + (now, now, task_id), + ) + connection.execute( + """ + UPDATE tasks + SET is_deleted = 1, deleted_at = COALESCE(deleted_at, ?), updated_at = ? + WHERE id = ? + """, + (now, now, task_id), + ) + connection.commit() + except Exception: + connection.rollback() + raise + + status = "already_deleted" if task.get("is_deleted") and existing_target_count == 0 else "deleted" + freed_mb = cleanup_result.freed_bytes / (1024 * 1024) + if status == "already_deleted": + message = "任务已经永久删除,当前没有残留的任务视频文件。" + else: + message = f"任务已永久删除,共释放约 {freed_mb:.1f} MB;数据库历史记录已隐藏保留。" return { - "message": "任务已隐藏,原视频、切片和任务目录都已保留。", + "status": status, "task_id": task_id, - "task_dir": task["task_dir"], + "freed_bytes": cleanup_result.freed_bytes, + "external_source_preserved": cleanup_result.external_source_preserved, + "deleted_paths": list(cleanup_result.deleted_paths), + "message": message, } + + +def soft_delete_task(task_id: str) -> dict: + """兼容旧调用名称;实际执行永久媒体删除并保留隐藏数据库记录。""" + return delete_task_permanently(task_id) diff --git a/app/services/task_query_service.py b/app/services/task_query_service.py index d1f93ff..9cb420d 100644 --- a/app/services/task_query_service.py +++ b/app/services/task_query_service.py @@ -407,6 +407,12 @@ def get_system_status_context() -> dict: return { "storage_root": str(settings.storage_root), "storage_exists": settings.storage_root.exists(), + "tasks_dir": str(settings.tasks_dir), + "tasks_dir_exists": settings.tasks_dir.exists(), + "upload_temp_dir": str(settings.upload_temp_dir), + "upload_temp_dir_exists": settings.upload_temp_dir.exists(), + "publish_export_dir": str(settings.publish_scheduler_export_dir), + "publish_export_dir_exists": settings.publish_scheduler_export_dir.exists(), "database_path": str(settings.database_path), "database_exists": settings.database_path.exists(), "ffmpeg_path": ffmpeg_path or "未找到", diff --git a/app/services/task_service.py b/app/services/task_service.py index 217a0be..9a6e193 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -185,6 +185,38 @@ TaskStatus.failed.value: 0, } +AUTO_PIPELINE_RUNNING_STATUSES = { + TaskStatus.CREATED.value, + TaskStatus.PREPARING_SOURCE.value, + TaskStatus.TRANSCRIBING.value, + TaskStatus.AI_ANALYZING.value, + TaskStatus.CLIP_SELECTING.value, + TaskStatus.VIDEO_CUTTING.value, + TaskStatus.METADATA_GENERATING.value, + TaskStatus.SCHEDULE_CREATING.value, + TaskStatus.PUBLISH_JOB_CREATING.value, + TaskStatus.transcribing.value, + TaskStatus.ai_analyzing.value, + TaskStatus.cutting.value, +} + +AUTO_PIPELINE_FAILED_STATUSES = { + TaskStatus.FAILED_PREPARING_SOURCE.value, + TaskStatus.FAILED_TRANSCRIBING.value, + TaskStatus.FAILED_AI_ANALYZING.value, + TaskStatus.FAILED_CLIP_SELECTING.value, + TaskStatus.FAILED_VIDEO_CUTTING.value, + TaskStatus.FAILED_METADATA_GENERATING.value, + TaskStatus.FAILED_SCHEDULE_CREATING.value, + TaskStatus.FAILED_PUBLISH_JOB_CREATING.value, +} + +AUTO_PIPELINE_RESUMABLE_STATUSES = { + TaskStatus.pending_review.value, + TaskStatus.completed_with_errors.value, + TaskStatus.failed.value, +} + PLATFORM_LABELS = { "douyin": "抖音", "bilibili": "B站", @@ -234,6 +266,10 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.FAILED_METADATA_GENERATING.value: 7, TaskStatus.FAILED_SCHEDULE_CREATING.value: 8, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value: 9, + TaskStatus.pending_review.value: 5, + TaskStatus.completed.value: 10, + TaskStatus.completed_with_errors.value: 10, + TaskStatus.failed.value: 1, } failed_statuses = { TaskStatus.FAILED_PREPARING_SOURCE.value, @@ -244,8 +280,14 @@ def get_task_workflow_steps(task: dict) -> list[dict[str, str]]: TaskStatus.FAILED_METADATA_GENERATING.value, TaskStatus.FAILED_SCHEDULE_CREATING.value, TaskStatus.FAILED_PUBLISH_JOB_CREATING.value, + TaskStatus.completed_with_errors.value, + TaskStatus.failed.value, + } + completed_statuses = { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, } - completed_statuses = {TaskStatus.READY_TO_PUBLISH.value, TaskStatus.COMPLETED.value} current_index = status_step_index.get(status, 1) steps = [] for index, name in enumerate(AUTO_WORKFLOW_STEPS, start=1): @@ -523,6 +565,62 @@ def get_task(task_id: str, include_video_probe: bool = True) -> dict | None: return _row_to_task(row, include_video_probe=include_video_probe) if row else None +def get_task_live_status(task_id: str) -> dict: + task = get_task(task_id, include_video_probe=False) + if not task: + raise ValueError("任务不存在") + + status = task["status"] + auto_mode = bool(task.get("auto_mode")) + is_running = auto_mode and status in AUTO_PIPELINE_RUNNING_STATUSES + should_poll = is_running or (auto_mode and status == TaskStatus.READY_TO_PUBLISH.value) + candidate_count = count_clip_candidates(task_id) + output_clip_count = int(task.get("output_clip_count") or 0) + + primary_action = "none" + if auto_mode: + if status in AUTO_PIPELINE_FAILED_STATUSES: + primary_action = "retry" + elif status in { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, + }: + primary_action = "publish" + elif status in AUTO_PIPELINE_RESUMABLE_STATUSES: + primary_action = "resume" + elif is_running: + primary_action = "processing" + + return { + "task_id": task_id, + "status": status, + "status_label": task["status_label"], + "progress": int(task.get("progress") or 0), + "updated_at": task["updated_at"], + "error_message": task.get("error_message") or "", + "is_running": is_running, + "should_poll": should_poll, + "workflow_steps": get_task_workflow_steps(task), + "log_lines": _read_task_log_tail(task_id), + "counts": { + "candidates": candidate_count, + "outputs": output_clip_count, + }, + "actions": { + "primary": primary_action, + "review": candidate_count > 0, + "publish": output_clip_count > 0 + and status + in { + TaskStatus.READY_TO_PUBLISH.value, + TaskStatus.COMPLETED.value, + TaskStatus.completed.value, + }, + }, + } + + def list_clip_candidates(task_id: str) -> list[dict]: ai_source_label = get_task_ai_source_label(task_id) with get_connection() as connection: @@ -706,8 +804,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 @@ -735,11 +855,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: diff --git a/app/static/css/styles.css b/app/static/css/styles.css index fc29a21..9244ac8 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1173,6 +1173,28 @@ fieldset input.visually-hidden-file { .metro-legend .current { background: var(--blue); } .metro-legend .warning { background: var(--amber); } +.live-status-note { + margin: 12px 0 0; + color: var(--muted); + font-size: 12px; +} + +.live-status-note[data-state="active"] { + color: var(--blue); +} + +.live-status-note[data-state="completed"] { + color: var(--green); +} + +.live-status-note[data-state="error"] { + color: var(--amber); +} + +[data-live-task-actions] [hidden] { + display: none; +} + .detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(340px, 0.72fr); @@ -5078,6 +5100,11 @@ td a, .schedule-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; } .schedule-drawer form { display: grid; gap: 16px; } .schedule-drawer form > label, .schedule-window-grid label { display: grid; gap: 7px; } +.schedule-start-field, .schedule-start-field > label { display: grid; gap: 7px; } +.schedule-after-latest-button { justify-self: start; margin-top: 2px; } +.schedule-latest-note { margin: 2px 0 0; line-height: 1.55; } +.schedule-latest-note.tone-red { color: var(--red); } +.schedule-latest-note.tone-blue { color: var(--blue); } .schedule-window-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .schedule-window-help { margin: 8px 0 0; line-height: 1.55; } .schedule-feedback { margin: 0; } @@ -5099,7 +5126,7 @@ td a, .publish-history-row { grid-template-columns: 1fr 1fr; } } -/* v1.5 发送中心:内容准备、排期计划、执行记录 */ +/* v2.0 发送中心:内容准备、排期计划、执行记录 */ .publish-content-list, .publish-plan-list, .publish-execution-list { @@ -5167,6 +5194,13 @@ td a, border: 1px solid rgba(126, 151, 187, 0.18); border-radius: 18px; background: rgba(255, 255, 255, 0.84); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.publish-content-card.is-scheduled { + border-color: rgba(31, 111, 255, 0.32); + background: linear-gradient(135deg, rgba(244, 249, 255, 0.98), rgba(255, 255, 255, 0.90)); + box-shadow: inset 3px 0 0 rgba(31, 111, 255, 0.72), 0 12px 30px rgba(31, 111, 255, 0.08); } .publish-content-card[hidden], @@ -5195,6 +5229,35 @@ td a, flex-wrap: wrap; gap: 10px; } +.publish-schedule-badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 4px 10px; + border: 1px solid rgba(31, 111, 255, 0.18); + border-radius: 999px; + background: linear-gradient(180deg, rgba(236, 244, 255, 0.98), rgba(225, 238, 255, 0.94)); + color: #1559be; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); + font-size: 0.78rem; + white-space: nowrap; +} +.publish-schedule-badge[hidden] { display: none; } +.publish-schedule-badge strong { overflow: visible; color: var(--blue); } +.publish-schedule-badge time { + padding-left: 6px; + border-left: 1px solid rgba(31, 111, 255, 0.18); + color: #476a9e; + font-variant-numeric: tabular-nums; +} +.publish-schedule-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--blue); + box-shadow: 0 0 0 3px rgba(31, 111, 255, 0.12); +} .text-button.danger { color: var(--red); } .publish-content-grid, @@ -5309,14 +5372,31 @@ td a, .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 { 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); transition: background 160ms ease, box-shadow 160ms ease; } .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); } +.publish-calendar-day.is-has-jobs { cursor: pointer; } +.publish-calendar-day.is-has-jobs:hover { background: rgba(242, 248, 255, 0.96); } +.publish-calendar-day.is-has-jobs:focus-visible { position: relative; z-index: 1; outline: 3px solid rgba(38, 118, 255, 0.28); outline-offset: -3px; } +.publish-calendar-day.is-selected { position: relative; z-index: 1; background: rgba(235, 245, 255, 0.98); box-shadow: inset 0 0 0 2px var(--blue); } .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-chip { display: block; width: 100%; margin-top: 4px; padding: 5px 6px; overflow: hidden; border-radius: 7px; color: #1459b8; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 0.7rem; background: #e8f2ff; } +.publish-calendar-day.is-has-jobs:hover .calendar-job-chip { background: #d9eaff; } .calendar-job-more { display: block; margin-top: 5px; color: var(--muted); font-size: 0.68rem; } +.publish-calendar-day-detail { display: grid; gap: 12px; margin-top: 16px; padding: 16px; border: 1px solid rgba(38, 118, 255, 0.2); border-radius: 14px; background: linear-gradient(145deg, rgba(240, 247, 255, 0.96), rgba(255, 255, 255, 0.92)); box-shadow: 0 14px 34px rgba(38, 118, 255, 0.08); } +.publish-calendar-day-detail[hidden] { display: none; } +.publish-calendar-day-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.publish-calendar-day-detail h4 { margin: 2px 0 4px; font-size: 1rem; } +.publish-calendar-day-detail small { color: var(--muted); } +.publish-calendar-day-list { display: grid; gap: 8px; } +.publish-calendar-day-item { display: grid; grid-template-columns: 68px minmax(0, 1fr) auto; gap: 12px; align-items: center; width: 100%; padding: 11px 12px; border: 1px solid rgba(126, 151, 187, 0.16); border-radius: 11px; color: var(--text); text-align: left; background: rgba(255, 255, 255, 0.92); cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; } +.publish-calendar-day-item:hover { border-color: rgba(38, 118, 255, 0.32); box-shadow: 0 8px 20px rgba(38, 118, 255, 0.08); transform: translateY(-1px); } +.publish-calendar-day-item:focus-visible { outline: 3px solid rgba(38, 118, 255, 0.24); outline-offset: 2px; } +.publish-calendar-day-item time { color: var(--blue); font-weight: 750; font-variant-numeric: tabular-nums; } +.publish-calendar-day-item > span:nth-child(2) { display: grid; gap: 3px; min-width: 0; } +.publish-calendar-day-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-calendar-day-action { color: var(--blue); font-size: 0.78rem; white-space: nowrap; } .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; } @@ -5542,6 +5622,9 @@ td a, .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-calendar-day-detail > header { align-items: stretch; } + .publish-calendar-day-item { grid-template-columns: 56px minmax(0, 1fr); } + .publish-calendar-day-action { display: none; } .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%; } diff --git a/app/static/js/app.js b/app/static/js/app.js index f491924..d3fbbb7 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -125,6 +125,11 @@ async function handleProcessAction(button) { } return; } + if (button.dataset.endpoint.includes("/process/auto-")) { + if (result) result.textContent = data.message || "全自动流程已启动,状态会在当前页面自动更新。"; + startTaskLiveStatusPolling(true); + return; + } window.location.reload(); } catch (error) { if (result) result.textContent = `处理失败:${error.message}`; @@ -339,7 +344,19 @@ const aiAnalysisProgressPercent = document.querySelector("#ai-analysis-progress- const aiAnalysisProgressBar = document.querySelector("#ai-analysis-progress-bar"); const runtimeLogState = document.querySelector("#runtime-log-state"); const runtimeLogLines = document.querySelector("#runtime-log-lines"); +const autoPipelineMonitor = document.querySelector("[data-auto-pipeline-monitor]"); +const taskLiveOverview = document.querySelector("[data-task-live-overview]"); +const taskLiveProgressBar = document.querySelector("[data-task-live-progress-bar]"); +const taskLiveProgressNumber = document.querySelector("[data-task-live-progress-number]"); +const taskLiveNote = document.querySelector("[data-task-live-note]"); +const taskLiveUpdatedAt = document.querySelector("[data-task-live-updated-at]"); +const taskLiveCandidateCount = document.querySelector("[data-task-live-candidate-count]"); +const taskLiveOutputCount = document.querySelector("[data-task-live-output-count]"); +const taskLiveActions = document.querySelector("[data-live-task-actions]"); let aiStatusPollingTimer = null; +let taskLiveStatusTimer = null; +let taskLiveForcedPollingUntil = 0; +const TASK_LIVE_STATUS_INTERVAL_MS = 3000; function summarizeErrorMessage(message, maxLength = 220) { const text = String(message || "").replace(/\s+/g, " ").trim(); @@ -787,6 +804,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; @@ -1152,15 +1182,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"); @@ -1388,9 +1410,12 @@ 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) { @@ -1438,10 +1463,29 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { button.disabled = true; button.textContent = "正在同步..."; try { - const data = await window.apiFetch( - `/api/publish/tasks/${encodeURIComponent(taskId)}/sync?prefer_subtitled=${preferSubtitled ? "true" : "false"}`, - { method: "POST" }, - ); + const 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 || ""}`; @@ -1450,6 +1494,14 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { 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"); @@ -1464,23 +1516,24 @@ document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { document.querySelectorAll(".js-hide-task").forEach((button) => { button.addEventListener("click", async () => { const taskTitle = button.dataset.taskTitle || "这条任务"; - const confirmed = window.confirm(`确认把“${taskTitle}”移入 E 盘回收站吗?\n\n这会从列表隐藏任务,并把对应项目文件夹移动到 E:\\直播间切片工作流存储\\_回收站,不会删除原视频、切片文件和任务目录。`); + const confirmed = window.confirm(`确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\nNAS 或任务目录外的原始视频不会被删除。`); if (!confirmed) return; const originalText = button.textContent; button.disabled = true; - button.textContent = "移动中..."; + button.textContent = "删除中..."; try { const response = await fetch(`/api/tasks/${button.dataset.taskId}`, { method: "DELETE" }); const data = await response.json(); if (!response.ok) { - throw new Error(data.detail || "移入回收站失败"); + throw new Error(data.detail || "永久删除失败"); } - window.alert(data.message || "任务已移入回收站。"); + const externalNotice = data.external_source_preserved ? "\n\n任务目录外的原始视频已保留。" : ""; + window.alert(`${data.message || "任务已永久删除。"}${externalNotice}`); window.location.reload(); } catch (error) { - window.alert(`移入回收站失败:${error.message}`); + window.alert(`永久删除失败:${error.message}`); } finally { button.disabled = false; button.textContent = originalText; @@ -1545,15 +1598,144 @@ function renderRuntimeLog(status) { completed: "已完成", failed: "失败", }; - runtimeLogState.textContent = labelMap[status.status] || status.task_status_label || "已刷新"; + runtimeLogState.textContent = status.status_label || labelMap[status.status] || status.task_status_label || "已刷新"; runtimeLogState.dataset.status = status.status || "idle"; } if (!runtimeLogLines) return; const lines = Array.isArray(status.log_lines) ? status.log_lines : []; - runtimeLogLines.textContent = lines.length ? lines.join("\n") : "暂无运行日志。点击 AI 分析后,这里会自动刷新。"; + runtimeLogLines.textContent = lines.length ? lines.join("\n") : "暂无运行日志。任务开始后,这里会自动刷新。"; runtimeLogLines.scrollTop = runtimeLogLines.scrollHeight; } +function formatTaskLiveRefreshTime() { + return new Date().toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +function renderTaskLiveActions(data) { + if (!taskLiveActions) return; + const actions = data.actions || {}; + let primaryAction = actions.primary || "none"; + if (primaryAction === "publish" && !actions.publish) { + primaryAction = "none"; + } + + taskLiveActions.querySelectorAll("[data-live-primary-action]").forEach((node) => { + node.hidden = node.dataset.livePrimaryAction !== primaryAction; + }); + + const reviewAction = taskLiveActions.querySelector("[data-live-review-action]"); + if (reviewAction) reviewAction.hidden = !actions.review; + const syncAction = taskLiveActions.querySelector("[data-live-sync-action]"); + if (syncAction) syncAction.hidden = Number(data.counts?.outputs || 0) <= 0; +} + +function renderTaskLiveStatus(data) { + const progress = Math.max(0, Math.min(100, Number(data.progress || 0))); + document.querySelectorAll("[data-task-live-status-label]").forEach((node) => { + node.textContent = data.status_label || data.status || "状态未知"; + }); + const headerStatus = document.querySelector("[data-task-live-header-status]"); + if (headerStatus) headerStatus.textContent = data.status_label || data.status || "状态未知"; + if (taskLiveProgressBar) taskLiveProgressBar.style.width = `${progress}%`; + if (taskLiveProgressNumber) taskLiveProgressNumber.textContent = `${progress}%`; + if (taskLiveUpdatedAt) taskLiveUpdatedAt.textContent = data.updated_at || "未知"; + + const candidateCount = Number(data.counts?.candidates || 0); + const outputCount = Number(data.counts?.outputs || 0); + if (taskLiveCandidateCount) taskLiveCandidateCount.textContent = `${candidateCount} 条`; + if (taskLiveOutputCount) taskLiveOutputCount.textContent = `${outputCount} 条`; + if (aiCandidateCountPill) aiCandidateCountPill.textContent = `${candidateCount} 条候选`; + + const allowedStepStates = new Set(["done", "current", "pending", "warning"]); + (Array.isArray(data.workflow_steps) ? data.workflow_steps : []).forEach((step) => { + const node = taskLiveOverview?.querySelector(`[data-task-live-step="${step.index}"]`); + if (!node) return; + node.classList.remove("done", "current", "pending", "warning"); + node.classList.add(allowedStepStates.has(step.state) ? step.state : "pending"); + const number = node.querySelector("span"); + const label = node.querySelector("strong"); + if (number) number.textContent = step.index; + if (label) label.textContent = step.name; + }); + + renderRuntimeLog(data); + renderTaskLiveActions(data); + if (autoPipelineMonitor) { + autoPipelineMonitor.dataset.status = data.status || ""; + autoPipelineMonitor.dataset.running = data.should_poll ? "true" : "false"; + } + if (taskLiveNote) { + if (data.error_message) { + taskLiveNote.dataset.state = "error"; + taskLiveNote.textContent = `流程已暂停:${summarizeErrorMessage(data.error_message)}`; + } else if (data.should_poll) { + taskLiveNote.dataset.state = "active"; + taskLiveNote.textContent = `自动刷新中 · ${formatTaskLiveRefreshTime()} 已获取最新状态`; + } else { + taskLiveNote.dataset.state = "completed"; + taskLiveNote.textContent = `状态已更新 · ${formatTaskLiveRefreshTime()}`; + } + } +} + +function scheduleTaskLiveStatusPolling() { + if (!autoPipelineMonitor || document.hidden) return; + if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = window.setTimeout(() => { + pollTaskLiveStatus().catch(() => {}); + }, TASK_LIVE_STATUS_INTERVAL_MS); +} + +async function pollTaskLiveStatus() { + if (!autoPipelineMonitor) return null; + const taskId = autoPipelineMonitor.dataset.taskId; + if (!taskId) return null; + if (taskLiveStatusTimer) { + window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = null; + } + + try { + const data = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/live-status`); + renderTaskLiveStatus(data); + if (data.should_poll) taskLiveForcedPollingUntil = 0; + if (data.should_poll || Date.now() < taskLiveForcedPollingUntil) { + scheduleTaskLiveStatusPolling(); + } + return data; + } catch (error) { + if (taskLiveNote) { + taskLiveNote.dataset.state = "error"; + taskLiveNote.textContent = `自动更新暂时中断,正在重试:${summarizeErrorMessage(error.message)}`; + } + scheduleTaskLiveStatusPolling(); + return null; + } +} + +function startTaskLiveStatusPolling(forceRestart = false) { + if (!autoPipelineMonitor) return; + if (forceRestart) taskLiveForcedPollingUntil = Date.now() + 10000; + pollTaskLiveStatus().catch(() => {}); +} + +if (autoPipelineMonitor) { + startTaskLiveStatusPolling(); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = null; + return; + } + startTaskLiveStatusPolling(); + }); +} + async function pollAiAnalysisStatus(keepPolling = false) { if (!aiAnalysisForm) return null; const taskId = aiAnalysisForm.dataset.taskId; diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js index 391f1f0..df159b5 100644 --- a/app/static/js/publish-center.js +++ b/app/static/js/publish-center.js @@ -13,6 +13,8 @@ if (publishCenterRoot) { const drawerCount = document.querySelector("[data-drawer-count]"); const scheduleForm = document.querySelector("[data-schedule-form]"); const previewList = document.querySelector("[data-schedule-preview]"); + const latestScheduleButton = document.querySelector("[data-use-latest-schedule]"); + const latestScheduleNote = document.querySelector("[data-latest-schedule-note]"); const previewScheduleButton = document.querySelector("[data-preview-schedule]"); const scheduleFeedbackNode = document.querySelector("[data-schedule-feedback]"); const confirmScheduleButton = document.querySelector("[data-confirm-schedule]"); @@ -36,6 +38,11 @@ if (publishCenterRoot) { const historyNextButton = document.querySelector("[data-history-next]"); const calendarNode = document.querySelector("[data-schedule-calendar]"); const calendarTitle = document.querySelector("[data-calendar-title]"); + const calendarDayDetail = document.querySelector("[data-calendar-day-detail]"); + const calendarDayTitle = document.querySelector("[data-calendar-day-title]"); + const calendarDaySummary = document.querySelector("[data-calendar-day-summary]"); + const calendarDayList = document.querySelector("[data-calendar-day-list]"); + const scheduleListNode = document.querySelector(".publish-plan-list"); const scheduleEmpty = document.querySelector("[data-schedule-empty]"); const contentEmpty = document.querySelector("[data-content-empty]"); const platformListTitle = document.querySelector("[data-platform-list-title]"); @@ -45,6 +52,7 @@ if (publishCenterRoot) { let latestPreviewItems = []; let activePlatform = "douyin"; let calendarMonth = currentBeijingMonth(); + let selectedCalendarDate = ""; let historyMonth = currentBeijingMonth(); let historySelectedDate = ""; let historyDeletedView = false; @@ -58,6 +66,8 @@ if (publishCenterRoot) { let historyRefreshQueuedRecords = false; const selectedHistoryJobIds = new Set(); let scheduleRefreshFrame = 0; + let scheduleRowOrderSequence = 0; + const scheduleRowOrders = new WeakMap(); let workerAvailable = schedulerHealthNode?.dataset.workerAvailable === "true"; let workerMessage = document.querySelector("[data-worker-message]")?.textContent?.split(" · ")[0] || "Windows 发布 Worker 未连接"; @@ -143,6 +153,128 @@ if (publishCenterRoot) { return Array.from(document.querySelectorAll('[data-publish-row][data-section="schedule"]')); } + function scheduleRowOrder(row) { + if (!scheduleRowOrders.has(row)) { + scheduleRowOrders.set(row, scheduleRowOrderSequence); + scheduleRowOrderSequence += 1; + } + return scheduleRowOrders.get(row); + } + + function scheduleTimestamp(row) { + if (String(row?.dataset.status || "").toUpperCase() !== "SCHEDULED") return Number.POSITIVE_INFINITY; + const timestamp = Date.parse(row.querySelector("[data-row-schedule]")?.dataset.utc || ""); + return Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY; + } + + function sortScheduleRows() { + if (!scheduleListNode) return; + const rows = scheduleRows(); + rows.forEach(scheduleRowOrder); + rows.sort((first, second) => { + const firstTimestamp = scheduleTimestamp(first); + const secondTimestamp = scheduleTimestamp(second); + const firstScheduled = Number.isFinite(firstTimestamp); + const secondScheduled = Number.isFinite(secondTimestamp); + if (firstScheduled !== secondScheduled) return firstScheduled ? -1 : 1; + if (firstScheduled && firstTimestamp !== secondTimestamp) return firstTimestamp - secondTimestamp; + return scheduleRowOrder(first) - scheduleRowOrder(second); + }); + rows.forEach((row) => scheduleListNode.appendChild(row)); + } + + function scheduledJobsByDate() { + const jobsByDate = new Map(); + scheduleRows().forEach((row) => { + if ( + row.dataset.platform !== activePlatform + || row.dataset.status !== "SCHEDULED" + || row.dataset.outputActive === "false" + ) return; + const utcValue = row.querySelector("[data-row-schedule]")?.dataset.utc || ""; + const timestamp = Date.parse(utcValue); + const key = beijingDateKey(utcValue); + if (!key || !Number.isFinite(timestamp)) return; + if (!jobsByDate.has(key)) jobsByDate.set(key, []); + jobsByDate.get(key).push({ + id: row.dataset.jobId, + title: row.querySelector("[data-row-title]")?.textContent?.trim() || "未命名任务", + account: row.querySelector("[data-row-account]")?.textContent?.trim() || "未选择账号", + status: row.querySelector("[data-row-status]")?.textContent?.trim() || "已排期", + time: formatBeijingTimestamp(utcValue).slice(11), + timestamp, + order: scheduleRowOrder(row), + }); + }); + jobsByDate.forEach((jobs) => { + jobs.sort((first, second) => first.timestamp - second.timestamp || first.order - second.order); + }); + return jobsByDate; + } + + function calendarDateLabel(value) { + const [year, month, day] = String(value || "").split("-").map(Number); + return year && month && day ? `${year} 年 ${month} 月 ${day} 日` : "当天排期"; + } + + function closeCalendarDayDetail() { + selectedCalendarDate = ""; + if (calendarDayDetail) calendarDayDetail.hidden = true; + if (calendarDayList) calendarDayList.innerHTML = ""; + calendarNode?.querySelectorAll("[data-calendar-date]").forEach((cell) => { + cell.classList.remove("is-selected"); + cell.setAttribute("aria-selected", "false"); + }); + } + + function renderCalendarDayDetail(jobsByDate) { + if (!calendarDayDetail || !calendarDayList || !selectedCalendarDate) { + if (calendarDayDetail) calendarDayDetail.hidden = true; + return; + } + const jobs = jobsByDate.get(selectedCalendarDate) || []; + if (!jobs.length) { + closeCalendarDayDetail(); + return; + } + if (calendarDayTitle) calendarDayTitle.textContent = calendarDateLabel(selectedCalendarDate); + if (calendarDaySummary) { + calendarDaySummary.textContent = `${platformLabel()} · ${jobs.length} 条排期 · 按北京时间从早到晚`; + } + calendarDayList.innerHTML = ""; + jobs.forEach((job) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "publish-calendar-day-item"; + button.dataset.calendarDetailJob = job.id; + button.setAttribute("aria-label", `${job.time} ${job.title},定位到任务`); + const time = document.createElement("time"); + time.textContent = job.time; + const identity = document.createElement("span"); + const title = document.createElement("strong"); + title.textContent = job.title; + const meta = document.createElement("small"); + meta.textContent = `${job.account} · ${job.status}`; + identity.append(title, meta); + const action = document.createElement("span"); + action.className = "publish-calendar-day-action"; + action.textContent = "定位到任务"; + button.append(time, identity, action); + calendarDayList.appendChild(button); + }); + calendarDayDetail.hidden = false; + } + + function focusScheduleRow(jobId) { + const row = document.querySelector( + `[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(jobId || "")}"]`, + ); + 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); + } + function setTaskGroupExpanded(group, expanded) { if (!group) return; group.dataset.expanded = expanded ? "true" : "false"; @@ -256,19 +388,7 @@ if (publishCenterRoot) { 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), - }); - }); + const jobsByDate = scheduledJobsByDate(); calendarNode.innerHTML = ""; for (let index = 0; index < 42; index += 1) { const date = new Date(gridStart.getTime() + index * 86400000); @@ -283,15 +403,21 @@ if (publishCenterRoot) { cell.dataset.date = key; cell.classList.toggle("is-outside", cellMonth !== month); cell.classList.toggle("is-today", key === todayKey); + cell.classList.toggle("is-has-jobs", jobs.length > 0); + cell.classList.toggle("is-selected", jobs.length > 0 && key === selectedCalendarDate); + cell.setAttribute("aria-selected", jobs.length > 0 && key === selectedCalendarDate ? "true" : "false"); + if (jobs.length) { + cell.dataset.calendarDate = key; + cell.tabIndex = 0; + cell.setAttribute("aria-label", `${calendarDateLabel(key)},${jobs.length} 条排期,点击查看全部`); + } 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"; + const chip = document.createElement("span"); 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); @@ -304,6 +430,7 @@ if (publishCenterRoot) { } calendarNode.appendChild(cell); } + renderCalendarDayDetail(jobsByDate); } function historyMonthKey() { @@ -693,6 +820,7 @@ if (publishCenterRoot) { } function refreshScheduleViews() { + sortScheduleRows(); renderPlatformSchedule(); renderCalendar(); } @@ -912,6 +1040,7 @@ if (publishCenterRoot) { activePlatform = next; if (changed) { selectedJobIds.clear(); + closeCalendarDayDetail(); historySelectedDate = ""; historyPage = 1; clearHistorySelection(); @@ -990,6 +1119,19 @@ if (publishCenterRoot) { readyNode.classList.toggle("tone-green", Boolean(job.content_complete)); readyNode.classList.toggle("tone-amber", !job.content_complete); } + const contentScheduleBadge = row.querySelector("[data-content-schedule]"); + if (contentScheduleBadge) { + const isScheduled = status === "SCHEDULED"; + const utcValue = job.scheduled_at_utc || job.scheduled_at || ""; + row.classList.toggle("is-scheduled", isScheduled); + contentScheduleBadge.hidden = !isScheduled; + const contentScheduleTime = contentScheduleBadge.querySelector("[data-content-schedule-time]"); + if (contentScheduleTime) { + contentScheduleTime.textContent = job.scheduled_at_display || (utcValue ? formatBeijingTimestamp(utcValue) : ""); + } + const addToPlanButton = row.querySelector("[data-add-to-plan]"); + if (addToPlanButton) addToPlanButton.textContent = isScheduled ? "调整排期" : "加入发布计划"; + } 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]"); @@ -1028,8 +1170,8 @@ if (publishCenterRoot) { 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"), + daily_start_time: String(scheduleForm?.elements.daily_start_time?.value || "07:00"), + daily_end_time: String(scheduleForm?.elements.daily_end_time?.value || "00:00"), }; } @@ -1045,6 +1187,14 @@ if (publishCenterRoot) { scheduleFeedbackNode.classList.toggle("tone-blue", tone !== "error"); } + function showLatestScheduleNote(message = "", tone = "info") { + if (!latestScheduleNote) return; + latestScheduleNote.hidden = !message; + latestScheduleNote.textContent = message; + latestScheduleNote.classList.toggle("tone-red", tone === "error"); + latestScheduleNote.classList.toggle("tone-blue", tone !== "error"); + } + function invalidatePreview() { latestPreviewSignature = ""; latestPreviewItems = []; @@ -1061,6 +1211,7 @@ if (publishCenterRoot) { drawer.hidden = false; drawerBackdrop.hidden = false; document.body.classList.add("has-schedule-drawer"); + showLatestScheduleNote(); updateSelectionUi(); } @@ -1239,25 +1390,37 @@ if (publishCenterRoot) { 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 }; + closeCalendarDayDetail(); 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 }; + closeCalendarDayDetail(); renderCalendar(); }); document.querySelector("[data-calendar-today]")?.addEventListener("click", () => { calendarMonth = currentBeijingMonth(); + closeCalendarDayDetail(); 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); + const cell = event.target.closest("[data-calendar-date]"); + if (!cell) return; + selectedCalendarDate = cell.dataset.calendarDate || ""; + renderCalendar(); + calendarDayDetail?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + calendarNode?.addEventListener("keydown", (event) => { + const cell = event.target.closest("[data-calendar-date]"); + if (!cell || !["Enter", " "].includes(event.key)) return; + event.preventDefault(); + cell.click(); + }); + document.querySelector("[data-calendar-day-close]")?.addEventListener("click", closeCalendarDayDetail); + calendarDayList?.addEventListener("click", (event) => { + const item = event.target.closest("[data-calendar-detail-job]"); + if (item) focusScheduleRow(item.dataset.calendarDetailJob); }); historyFilter?.addEventListener("change", () => { historyPage = 1; @@ -1805,12 +1968,51 @@ if (publishCenterRoot) { } }); - scheduleForm?.addEventListener("input", invalidatePreview); + scheduleForm?.addEventListener("input", () => { + invalidatePreview(); + showLatestScheduleNote(); + }); scheduleForm?.elements.interval_preset?.addEventListener("change", () => { document.querySelector("[data-custom-interval]").hidden = scheduleForm.elements.interval_preset.value !== "custom"; }); + latestScheduleButton?.addEventListener("click", async () => { + const payload = schedulePayload("apply"); + const request = { + job_ids: payload.job_ids, + platform: payload.platform, + timezone: payload.timezone, + interval_minutes: payload.interval_minutes, + daily_start_time: payload.daily_start_time, + daily_end_time: payload.daily_end_time, + }; + latestScheduleButton.disabled = true; + latestScheduleButton.textContent = "正在查询当前最晚排期…"; + showLatestScheduleNote(); + showScheduleFeedback("正在读取当前平台的最新排期,请稍候。"); + try { + const data = await window.apiFetch("/api/publish/schedules/next-start", { + method: "POST", + body: JSON.stringify(request), + }); + invalidatePreview(); + if (data.status === "empty") { + showLatestScheduleNote(data.message || "当前平台暂无其他未来排期,请手动选择时间。"); + return; + } + scheduleForm.elements.start_at_local.value = data.next_start_at_local; + showLatestScheduleNote( + `当前最晚:${data.latest_scheduled_at_local_display};本次第 1 条:${data.next_start_at_local_display}`, + ); + } catch (error) { + showScheduleFeedback(`读取最晚排期失败:${error.message}`, "error"); + } finally { + latestScheduleButton.disabled = false; + latestScheduleButton.textContent = "接在当前平台最晚排期后"; + } + }); + previewScheduleButton?.addEventListener("click", async () => { const payload = schedulePayload("apply"); if (!payload.start_at_local || beijingInputToTimestamp(payload.start_at_local) <= Date.now()) { diff --git a/app/templates/base.html b/app/templates/base.html index 6cb2097..1562f15 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -9,7 +9,7 @@ - + {% block extra_head %}{% endblock %} @@ -34,8 +34,8 @@ 系统状态 @@ -61,7 +61,7 @@ - + {% block extra_scripts %}{% endblock %} diff --git a/app/templates/clip_review.html b/app/templates/clip_review.html index 7ec99fa..309c3be 100644 --- a/app/templates/clip_review.html +++ b/app/templates/clip_review.html @@ -180,7 +180,7 @@

审核操作

@@ -188,8 +188,8 @@

审核操作

去字幕推送 + {% if output_clips %} - 查看本任务发送内容 {% endif %} diff --git a/app/templates/new_task.html b/app/templates/new_task.html index 0cd86bb..16c5d3a 100644 --- a/app/templates/new_task.html +++ b/app/templates/new_task.html @@ -6,7 +6,7 @@

New Task

新建任务

-

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

+

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

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

基本信息

上传本机视频 - 选择视频后,会复制到该任务的 source 目录。 + 选择视频后,会直接写入 E 盘任务的 source 目录;上传临时文件也在 E 盘。
{% set header_transcript_progress = task.transcript_progress or {} %} -
+
{% if task.auto_mode %} - {% if task.status.startswith("FAILED_") %} - - {% elif task.status in ["pending_review", "completed", "completed_with_errors", "failed"] %} - - {% elif task.status in ["READY_TO_PUBLISH", "COMPLETED"] %} - 前往发送中心 - {% else %} - - {% endif %} - {% if task.candidate_count > 0 %} - 检查候选片段 - {% endif %} - {% if task.output_clip_count > 0 %} - - 发送中心 · {{ publish_link_state.label }} - {% endif %} + + + 前往发送中心 + + 检查候选片段 + {% elif task.transcript_exists %} @@ -61,7 +51,7 @@

任务详情 · {{ task.title }}

data-running="{{ 'true' if task.status in ['CREATED', 'PREPARING_SOURCE', 'TRANSCRIBING', 'AI_ANALYZING', 'CLIP_SELECTING', 'VIDEO_CUTTING', 'METADATA_GENERATING', 'SCHEDULE_CREATING', 'PUBLISH_JOB_CREATING'] else 'false' }}" > 全自动模式已接管这个任务。 -

系统会自动推进音频提取、转写、AI 选片和视频切割;页面在处理中会自动刷新。只有失败或历史任务中断时才需要点击重试/继续。

+

系统会自动推进音频提取、转写、AI 选片和视频切割;状态概览与运行日志每 3 秒自动更新,不会刷新整张页面。只有失败或历史任务中断时才需要点击重试/继续。

{% endif %} @@ -78,31 +68,31 @@

基础信息

视频来源
{{ task.source_type_label }}
视频时长
{{ task.duration }}
视频大小
{{ task.video_size }}
-
候选片段
{{ task.candidate_count }} 条
+
候选片段
{{ task.candidate_count }} 条
最长切片
{{ task.max_clip_duration }} 分钟
创建时间
{{ task.created_at }}
-
更新时间
{{ task.updated_at }}
+
更新时间
{{ task.updated_at }}
源文件状态
{{ "可访问" if task.source_exists else "不可访问 / 未选择" }}
-
切片输出
{{ task.output_clip_count }} 条
+
切片输出
{{ task.output_clip_count }} 条
任务目录
{{ task.task_dir }}
-
+

Status

状态概览

- {{ task.status_label }} + {{ task.status_label }}
- +
- {{ task.progress }}% + {{ task.progress }}%
    {% for step in workflow_steps %} -
  1. +
  2. {{ step.index }} {{ step.name }}
  3. @@ -113,6 +103,7 @@

    状态概览

    当前阶段 异常 / 待处理
+

正在连接任务进度…

@@ -286,7 +277,7 @@

运行日志

待刷新 -
点击 AI 分析后,这里会显示最新运行日志。
+
任务开始后,这里会自动显示最新运行日志。

diff --git a/app/templates/tasks.html b/app/templates/tasks.html index 8100706..6649250 100644 --- a/app/templates/tasks.html +++ b/app/templates/tasks.html @@ -80,7 +80,7 @@

任务列表

审核 字幕 {% if task.output_clip_count > 0 %}发送中心{% endif %} - + {% endfor %} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 34d8457..46dd52f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,9 @@ ### 1.1 架构形态 -当前 v1.5.0 保持 **FastAPI 单体应用 + SQLite**。视频、AI、页面和调度器仍在同一个应用中;只有必须使用宿主系统 Chrome 的真实发布动作由 Windows Worker 执行,不引入 Redis、Celery 或微服务。 +当前 v2.0.0 保持 **FastAPI 单体应用 + SQLite + Windows 发布 Worker**。视频、AI、页面和调度器仍在同一个应用中;只有必须使用宿主系统 Chrome 的真实发布动作由 Windows Worker 执行,不引入 Redis、Celery 或微服务。 + +v2.0 的架构目标不是云端多租户,而是把一台 Windows 电脑上的长视频生产与发布链路做完整、可恢复、可审计。SQLite 是唯一业务事实来源,E 盘任务目录保存大文件,浏览器 Profile 和平台登录态只保留在本机且不进入 Git。 ```text ┌─────────────────────────────────────────────────────────┐ @@ -243,21 +245,26 @@ Worker 会把容器内 `/workspace/tasks/...` 映射到宿主 `.env` 的 `TASKS_ ```text 新建任务表单 → 上传视频 / 选择 NAS 路径 +→ E 盘任务目录与安全文件边界 → FFmpeg 提取音频 → 转写(火山引擎远程 / faster-whisper 本地) -→ AI 候选片段分析(DeepSeek / Ollama) -→ 候选片段人工审核(启用/禁用/编辑时间) -→ FFmpeg 自动切割 → 05_clips/ +→ AI 候选片段分析(DeepSeek / OpenAI-compatible / Ollama) +→ 候选片段人工审核(启用/禁用/编辑标题、摘要与时间) +→ 保存审核结果并按需生成安全的新切片版本 +→ FFmpeg 自动切割 + 文案 + 封面帧 → 全自动模式跳过字幕生成/烧录 -→ 发送中心内容准备与排期 +→ 发送中心内容准备、北京时间排期月历与执行记录 → Scheduler + Windows Worker 真实投稿(抖音 + B站) +→ PUBLISHED / FAILED / NEED_REVIEW 可追溯终态 ``` +任务详情通过轻量 `live-status` 接口每 3 秒局部更新,不重新加载整页;发送中心重新切片时保留旧执行证据,只让当前激活切片进入新的内容准备和排期。 + --- ## 8. 架构演进路线 -### 8.1 当前阶段:P2-1(已完成) +### 8.1 当前阶段:v2.0 本地生产闭环(已完成) - FastAPI 单体应用 - SQLite 单文件数据库 @@ -265,19 +272,22 @@ Worker 会把容器内 `/workspace/tasks/...` 映射到宿主 `.env` 的 `TASKS_ - FFmpeg 同步本地处理 - 本地/远程 AI Provider - 抖音/B站统一真实发布、人工复核和显式手动导出 +- 全自动任务状态轮询、失败续跑和片段审核同步 +- 内容准备、跨午夜排期、最晚排期续接、月历详情与执行记录 +- E 盘统一生产存储、外部原片保护和托管产物安全删除 - 代码检查与 CI 流程 -### 8.2 短期演进(P2-2 ~ P2-3) +### 8.2 v2.0 后续重点 -**目标**:不改变单体形态,增强本地可靠性和安全边界。 +**目标**:不扩大单用户本地范围,优先用真实素材与真实账号完成灰度验收并提高可靠性。 | 方向 | 具体措施 | | --- | --- | -| **数据库** | SQLite 保持不变,增加 WAL 模式、备份脚本 | -| **Job Worker** | 引入本地后台 Job Queue(`threading` / `asyncio`),将耗时任务(转写、AI 分析、切割)异步化,不阻塞 HTTP 请求 | -| **安全增强** | `LOCAL_ADMIN_TOKEN` 鉴权强化,敏感操作确认对话框,操作日志记录 | -| **存储** | 支持 NAS 路径作为存储根目录,`STORAGE_ROOT` 可配置为网络路径 | -| **错误恢复** | 任务失败后可从中断点重试,而非从头开始 | +| **真实灰度发布** | 抖音、B站各用单条低风险素材验证当前页面选择器、成功证据和人工复核路径 | +| **长期稳定性** | 连续运行 Scheduler、Docker Watcher 和 Windows Worker,观察中断恢复与日志完整性 | +| **数据保护** | 定期验证 SQLite 备份、E 盘空间、外部原片保护和永久删除清单 | +| **内容质量** | 用真实长视频继续校准综艺笑点与通用模式的候选质量、文案和封面时间点 | +| **平台维护** | 平台页面改版后更新 Publisher 选择器,不通过绕过验证或静默重传维持“成功率” | ### 8.3 中期演进(P3) @@ -315,7 +325,7 @@ Worker 会把容器内 `/workspace/tasks/...` 映射到宿主 `.env` 的 `TASKS_ | 暂不做的 | 原因 | | --- | --- | | **SaaS 多租户** | 当前是个人本地工具,不需要租户隔离和计费系统 | -| **真实全自动发布** | 平台有验证码、风控、登录失效,全自动不可行也不安全 | +| **绕过验证的无人值守发布** | 平台有验证码、风控、登录失效;v2.0 只在登录有效且平台无需人工确认时自动执行 | | **强依赖云部署** | 首版定位 Windows 本地工具,不应强制要求云服务器 | | **移动端 App** | 核心工作流依赖 FFmpeg 和大文件处理,不适合移动端 | | **实时直播流处理** | 当前是录播后处理,实时流需要完全不同的技术栈 | diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 8f3a2f0..b56194b 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -65,7 +65,7 @@ - `tasks` 表新增 `task_dir_name` 字段,用来记录任务在存储盘里的实际文件夹名。 - `id` 仍是任务唯一 ID,用于数据库关联和网页地址;本地文件夹不再默认使用短 ID,而是使用 `task_dir_name`。 - 新建任务时会根据 `task_name` 生成安全的 Windows 文件夹名;重名时自动追加序号,避免覆盖旧目录。 -- `DELETE /api/tasks/{task_id}` 现在会把 `is_deleted` 设为 `1`,写入 `deleted_at`,并把任务文件夹移动到存储根目录下的 `_回收站`;不会删除文件。 +- `DELETE /api/tasks/{task_id}` 会永久删除系统托管的任务目录和发布包,再把 `is_deleted` 设为 `1` 并写入 `deleted_at`;NAS 或任务目录外的原片不会删除。 - 一次性迁移脚本为 `scripts/migrate_task_dirs_to_project_names.py`,默认 dry-run,带 `--apply` 才会移动文件夹并更新路径字段。 ## 2026-05-25:发布后台新增表 @@ -218,7 +218,7 @@ data/workflow.sqlite3 | --- | --- | --- | | `id` | TEXT | 任务唯一 ID,创建时自动生成 | | `task_name` | TEXT | 任务名称 | -| `task_dir_name` | TEXT | 存储盘实际任务文件夹名;正常任务通常等于项目名,已移入回收站的任务为 `_回收站\项目名` | +| `task_dir_name` | TEXT | 存储盘实际任务文件夹名;永久删除后保留原值作为隐藏历史记录,但对应目录不再存在 | | `source_type` | TEXT | 视频来源:`upload` 或 `nas` | | `platform` | TEXT | 平台类型:`douyin`、`bilibili`、`general` | | `original_video_path` | TEXT | 本地上传视频路径,后续接真实上传后写入 | @@ -366,7 +366,7 @@ data/workflow.sqlite3 为了不破坏已有本地数据库,旧字段不会被强制删除。后续代码以本文件列出的当前字段为准。 新任务默认值调整不会批量更新已有 `tasks` 记录;历史任务已经保存的最大时长和候选数量保持不变。 -任务移入回收站采用软删除方式:`DELETE /api/tasks/{task_id}` 会把 `is_deleted` 改为 `1`、写入 `deleted_at`,并把该任务的存储目录移动到 `_回收站`。工作台、任务列表和片段审核总览默认不显示已移入回收站的任务,原视频、音频、转写、AI 分析文件、切片输出和字幕输出都会保留。 +任务删除采用“媒体永久删除、数据库历史隐藏保留”的方式:`DELETE /api/tasks/{task_id}` 只允许删除 `TASKS_DIR` 下与任务精确对应的目录、该任务的手动发布包和可确认归属的旧版项目 `tasks` 目录。删除成功后把 `is_deleted` 改为 `1` 并写入 `deleted_at`,候选片段、切片、字幕和发布历史仍留在 SQLite 中用于审计。NAS 或任务目录外的原片永远不参与删除;运行中的转写、切片或真实发送任务返回 409,避免后台进程重新生成文件。 `clip_candidates.reason` 是早期推荐理由字段,当前审核页优先读取 `highlight_reason`。数据库初始化时会把已有 `reason` 自动补到 `highlight_reason`。 @@ -378,6 +378,8 @@ data/workflow.sqlite3 历史任务可能仍兼容 `task_id` 目录,但新逻辑以 `task_dir_name` 为准。 +浏览器上传超过内存阈值后的临时文件使用 `UPLOAD_TEMP_DIR`,默认位于 `{TASKS_DIR}\_临时上传`;显式手动导出的发布包使用 `PUBLISH_SCHEDULER_EXPORT_DIR`,默认位于 `{STORAGE_ROOT}\_发布包`。应用启动时会验证这些目录可写,不可用时直接报错,不会回退到 C 盘。 + 正式任务目录结构: ```text @@ -406,7 +408,7 @@ data/workflow.sqlite3 ## 2026-06-09 v1.2 补充说明 -- 以上 v1.2 说明仅是历史记录。v1.5.0 已由 `PublishScheduler` 执行到期任务,并通过 Windows Worker 调用抖音/B站 Publisher。 +- 以上 v1.2 说明仅是历史记录。v2.0.0 已由 `PublishScheduler` 执行到期任务,并通过 Windows Worker 调用抖音/B站 Publisher。 - 平台发送不绕过验证码、登录失效、风控和人工确认;结果不确定写 `NEED_REVIEW`。 - 代码中仍存在兼容性 `clips` 子目录(`TASK_SUBDIRECTORIES` 同时包含 `clips` 和 `05_clips`),新任务的正式输出目录是 `05_clips`。旧 `clips` 目录为兼容保留,不建议删除。 # 2026-06-23:v1.4.0 定时发送字段 @@ -415,7 +417,7 @@ data/workflow.sqlite3 - 旧字段继续兼容:`output_clip_id` 等同于 `clip_id`,`description` 等同于 `caption`,`tags` 等同于 `hashtags`,`video_file_path` 等同于 `video_path`,`provider_response` 兼容 `publish_result`,`retry_count` 兼容 `attempt_count`。 - 发布状态使用:`DRAFT`、`SCHEDULED`、`WAITING`、`PUBLISHING`、`PUBLISHED`、`FAILED`、`CANCELLED`、`NEED_REVIEW`。 - 调度器只扫描 `status = SCHEDULED` 且 `scheduled_at <= 当前时间` 的任务;`NEED_REVIEW`、`CANCELLED`、`PUBLISHED` 不会自动发布。 -- 该 2026-06-23 版本曾默认使用 `manual_export`,2026-07-11 曾改为 `opencli_publish`;v1.5.0 当前默认是 `local_browser`。发布包导出成功写 `EXPORTED` 且不写 `published_at`;只有平台确认提交成功才写 `PUBLISHED` 和 `published_at`。 +- 该 2026-06-23 版本曾默认使用 `manual_export`,2026-07-11 曾改为 `opencli_publish`;v2.0.0 当前默认是 `local_browser`。发布包导出成功写 `EXPORTED` 且不写 `published_at`;只有平台确认提交成功才写 `PUBLISHED` 和 `published_at`。 - 没有 `scheduled_at` 的旧手动发送任务迁移为 `WAITING`,避免被自动调度器误执行。 ## 2026-07-27:取消发送状态兼容 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7a50a66..38f6162 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,38 +2,38 @@ ## 1. 当前部署模式 -v1.3 支持两种部署方式: +v2.0.0 支持两种运行方式。日常使用推荐 Docker Desktop;真实抖音 / B站投稿无论使用哪种方式,都必须由 Windows 主机上的 Chrome Worker 执行。 -### 方式 A:Windows 本地直接运行(推荐日常使用) +### 方式 A:Docker Desktop + Windows Worker(推荐日常使用) ``` 你的 Windows 电脑 -├── Python 3.12(系统安装) -├── FFmpeg(系统安装,需在 PATH 中) -├── 项目代码(任意目录) -├── .venv(Python 虚拟环境) +├── Docker Desktop 运行 niuma-studio +│ └── FastAPI + SQLite Scheduler + FFmpeg +├── NiuMa Studio Docker Watcher +│ └── 容器运行时自动启动 Windows Chrome Worker +├── 系统 Chrome 独立账号目录 └── 浏览器打开 http://127.0.0.1:8001 ``` -**适用场景**:日常使用、开发调试、单机处理。 +**适用场景**:日常处理、排期和真实灰度发布。平时不需要手动打开 PowerShell。 -### 方式 B:Docker 容器运行(推荐测试/隔离环境) +### 方式 B:Windows 本地 Python 直接运行(开发 / 诊断) ``` 你的 Windows 电脑 -├── Docker Desktop -├── 项目代码(任意目录) -├── 容器 niuma-studio -│ ├── Python 3.12 + FFmpeg(容器内预装) -│ └── uvicorn 监听 8001 端口 +├── Python 3.12 + .venv +├── FFmpeg(系统安装,需在 PATH 中) +├── uvicorn 监听 8001 端口 +├── scripts/publish_host_worker.py(真实投稿时) └── 浏览器打开 http://127.0.0.1:8001 ``` -**适用场景**:不想装 Python/FFmpeg、测试环境隔离、CI 验证。 +**适用场景**:代码开发、自动化测试、发布 Worker 诊断。 --- -## 2. 方式 A 详细步骤:Windows 本地直接运行 +## 2. Windows 本地 Python 方式详细步骤 ### 2.1 环境要求 @@ -71,6 +71,9 @@ copy .env.example .env 然后用记事本或 VS Code 打开 `.env`,按注释填写: - `STORAGE_ROOT`:任务产物存放目录(默认 `E:\直播间切片工作流存储`) +- `TASKS_DIR`:每条任务的原片、音频、切片和字幕目录,默认与 `STORAGE_ROOT` 相同 +- `UPLOAD_TEMP_DIR`:浏览器上传大视频时的临时目录,默认 `E:\直播间切片工作流存储\_临时上传` +- `PUBLISH_SCHEDULER_EXPORT_DIR`:手动发布包目录,默认 `E:\直播间切片工作流存储\_发布包` - `AI_ANALYSIS_REMOTE_API_KEY`:DeepSeek API Key(可选,用远程 AI 分析时需要) - `VOLCENGINE_ASR_API_KEY`:火山引擎转写 Key(可选,用远程转写时需要) - `LOCAL_ADMIN_TOKEN`:管理接口鉴权 Token(可留空或设随机字符串) @@ -102,7 +105,7 @@ http://127.0.0.1:8001 --- -## 3. 方式 B 详细步骤:Docker 运行 +## 3. Docker 方式详细步骤 ### 3.1 环境要求 @@ -110,7 +113,7 @@ http://127.0.0.1:8001 ### 3.2 配置环境变量 -与方式 A 相同,先 `copy .env.example .env` 并填写配置。 +与本地 Python 方式相同,先 `copy .env.example .env` 并填写配置。 ### 3.3 构建并启动 @@ -144,7 +147,7 @@ docker compose down - **存储目录**:`docker-compose.yml` 默认将 `E:\直播间切片工作流存储` 挂载到容器内 `/workspace/tasks`。如果你的存储目录在其他位置,请修改 `docker-compose.yml` 中的 `volumes` 配置。 - **代码热更新**:`app/` 和 `prompts/` 目录以 volume 方式挂载,修改代码后容器自动重载。 - **Ollama 连接**:如果 Ollama 在宿主机运行,容器内通过 `http://host.docker.internal:11434/v1` 访问。 -- **opencli 桥接**:容器内通过 `http://host.docker.internal:8765` 访问宿主机上的 opencli 桥接服务。 +- **Windows 发布 Worker**:容器内通过 `http://host.docker.internal:8765` 访问宿主机受 Token 保护的 Chrome Worker;旧 opencli 仅是默认关闭的兼容模式。 --- @@ -154,9 +157,9 @@ docker compose down | 局限 | 说明 | 影响 | | --- | --- | --- | -| **单进程** | API 和视频处理在同一进程 | 处理大视频时页面可能卡住(请求阻塞) | -| **无后台队列** | 没有独立 Worker 进程 | 转写、AI 分析、切割都在请求线程中同步执行 | -| **单机存储** | 任务产物必须在本地磁盘 | 不能跨机器共享任务数据 | +| **单业务进程** | API、全自动流水线和 Scheduler 在同一个 FastAPI 进程 | 进程重启会中断正在处理的步骤,需从失败点重试或续跑 | +| **无外部任务队列** | 使用 FastAPI BackgroundTasks 和轻量 SQLite Job 记录,没有 Redis / Celery | 适合个人单机,不适合多机并发处理 | +| **单机主存储** | 生产任务目录由当前 Windows 主机 / Docker 挂载负责 | 可引用 NAS 原片,但不支持多台处理机共同写同一任务 | | **无负载均衡** | 不支持多实例部署 | 只能一个人用,不能横向扩展 | | **无 HTTPS** | 只有 HTTP | 只适合本地使用,不要暴露到公网 | | **单用户** | 没有登录和用户隔离 | 谁打开浏览器都能操作所有任务 | @@ -261,6 +264,8 @@ Windows 主机(运行 FastAPI) | --- | --- | --- | | `STORAGE_ROOT` | `E:\直播间切片工作流存储` | 任务产物根目录 | | `TASKS_DIR` | 同 `STORAGE_ROOT` | 任务目录(优先级高于 STORAGE_ROOT) | +| `UPLOAD_TEMP_DIR` | `{TASKS_DIR}\_临时上传` | FastAPI 接收大视频时的进程临时目录,不回退到 C 盘 | +| `PUBLISH_SCHEDULER_EXPORT_DIR` | `{STORAGE_ROOT}\_发布包` | 手动导出的本地发布包目录 | | `DATA_DIR` | 项目目录 `data/` | 数据库存放目录 | | `DATABASE_PATH` | `data/workflow.sqlite3` | 数据库文件路径 | | `AI_ANALYSIS_REMOTE_API_KEY` | 空 | DeepSeek API Key | diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 4e13cf2..a8aaa48 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -2,26 +2,47 @@ 这份文档给不熟悉代码和终端的新手使用。你只需要按顺序做,不需要理解每一行命令背后的原理。 +适用版本:牛马片场 `2.0.0`。 + ## 1. 项目是做什么的 项目中文名:牛马片场。 -它的目标是把一条本地直播录像、综艺访谈或长视频素材变成一组可审核、可切割、可继续加字幕和发送的短视频候选片段。 +它的目标是把一条本地直播录像、综艺访谈或长视频素材,变成一组可审核、可切割、可准备文案和封面、可排期并发送到抖音或 B站的短视频内容。 -当前 MVP 主要流程: +当前 v2.0 完整流程: ```text -上传本地视频 --> 创建任务 +上传本地视频 / 选择 NAS 或本地已有视频 +-> 创建独立任务目录 -> 提取音频 --> 本地语音转写 --> AI 分析候选片段 +-> 火山引擎或 faster-whisper 转写 +-> DeepSeek / OpenAI-compatible 或 Ollama 分析候选片段 -> 人工审核片段 --> 自动切割输出短视频 +-> 自动切割并准备标题、简介、话题和封面 +-> 发送中心核对账号与内容 +-> 立即发送或按北京时间排期 +-> Windows Chrome Worker 投稿抖音 / B站 +-> 保存成功、失败或人工复核记录 ``` 针对《康熙来了》类综艺,可以在新建任务或任务详情里选择“综艺笑点优先”。该模式先按重叠窗口找笑点,再补齐前后文,最后全局去重和评分;候选池默认 12 条,但只会默认启用最多 5 条 A 级内容,质量不足时不会凑数。现有直播和通用长视频继续使用“通用内容价值”模式。 +### 1.1 现在已经能做什么 + +- 素材、音频、转写、AI 结果、切片和封面按任务保存到 E 盘存储目录。 +- 全自动任务详情会每 3 秒更新进度与日志,失败后可以重试或继续。 +- 片段审核保存后,可一键生成最新切片并同步到发送中心。 +- 发送中心按任务分组管理抖音 / B站内容,支持内容补齐、封面、排期月历、跨午夜时间窗和续接最晚排期。 +- 立即发送与定时发送共用同一套 Scheduler 和 Windows Worker,执行记录不会因为重新切片而被覆盖。 + +### 1.2 现在仍需要人工做什么 + +- 首次使用抖音或 B站账号时,在系统 Chrome 独立窗口完成登录、二维码、短信或平台要求的验证。 +- 真实发送前逐条核对视频、标题、简介、话题、封面、账号、可见范围和北京时间。 +- 遇到验证码、滑块、登录失效、平台风控或发布结果不确定时,到平台创作者中心人工确认。 +- 项目不会绕过平台限制,也不会在结果不确定时自动重复上传。 + ## 2. 当前项目目录 ```text @@ -148,13 +169,16 @@ http://127.0.0.1:8001/health 启动项目后,按这个顺序检查: -1. 打开首页,确认工作台能显示。 -2. 打开“新建任务”,上传一个本地视频。 -3. 进入任务详情页,确认任务信息和处理按钮能显示。 -4. 生成转写后,确认详情页能看到转写预览。 -5. 点击远程 AI 或本地 AI 分析,生成候选片段。 -6. 打开片段审核页,勾选或修改候选片段。 -7. 触发切割,确认输出片段记录能展示。 +1. 打开首页,确认左侧显示 `v2.0 本地高光生产版`。 +2. 打开“新建任务”,上传一个短测试视频,保持“全自动流程”开启。 +3. 进入任务详情页,确认状态概览和运行日志每 3 秒局部更新,页面不会自动跳回顶部。 +4. 转写与 AI 分析完成后,打开片段审核页,勾选或修改候选片段。 +5. 点击“保存并同步发送中心”,确认需要时会生成最新切片,并自动进入当前任务的内容准备区。 +6. 在发送中心核对抖音 / B站的视频、标题、简介、话题和封面。 +7. 只使用测试任务预览排期,确认月历日期详情和任务清单按北京时间从早到晚排列。 +8. 如果要测试真实发布,必须先确认 Windows Worker 正常、平台账号已登录,并只发送一条低风险测试内容。 + +> 只检查生产链路时,不要点击“立即发送”。排期预览不会触发真实投稿,确认排期后任务会等待调度器到点执行。 ## 8. 命令行测试 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index daa3be4..cc6f8c6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,7 @@ - **P3**:分布式与多机协作 - **P4+**:多用户与平台化 -当前版本:**v1.3.0**(分支整合稳定版) +当前版本:**v2.0.0**(Windows 本地高光生产闭环) --- @@ -52,53 +52,53 @@ - [x] CI 流程(GitHub Actions) - [x] Git 安全规范文档 ---- +### v1.3 ~ v1.5 — 自动流水线与真实发布架构 -## 进行中 +- [x] 全自动任务流水线、失败步骤重试和历史任务续跑 +- [x] SQLite Scheduler、Publisher Registry 与北京时间 / UTC 统一 +- [x] Windows Chrome Worker、抖音 Publisher 与 B站 Publisher +- [x] `PUBLISHED` / `FAILED` / `NEED_REVIEW` 保守终态和执行事件 +- [x] 内容准备、排期计划、执行记录和显式 `manual_export` -### P2-2 — 架构演进准备 +### v2.0 — 分支收拢与本地生产闭环 -- [ ] 架构文档更新([ARCHITECTURE.md](ARCHITECTURE.md)) -- [ ] 路线图文档(本文档) -- [ ] 部署文档([DEPLOYMENT.md](DEPLOYMENT.md)) -- [ ] 明确短期/中期/长期演进方向 -- [ ] 明确暂不做的边界 +- [x] E 盘统一生产存储、临时上传、发布包与安全永久删除 +- [x] 综艺笑点优先 / 通用内容价值两种 AI 选片模式 +- [x] AI 封面时间点、历史内容封面补齐与双平台复用 +- [x] 片段审核保存、按需重新切片并同步发送中心 +- [x] 任务详情每 3 秒局部更新进度、日志与可用操作 +- [x] 发送内容按原始任务分组,已排期状态清晰可见 +- [x] 跨午夜排期、续接平台最晚排期、日期详情和时间升序 +- [x] 取消发送返回内容准备,旧执行证据继续保留 +- [x] 项目说明、架构、流程、路线图和新手文档统一到 2.0 --- -## 计划中 - -### P2-3 — 本地 Job Worker - -**目标**:将耗时任务异步化,不阻塞 HTTP 请求。 +## 进行中 -| 任务 | 说明 | -| --- | --- | -| 引入 `asyncio` 后台任务 | 转写、AI 分析、切割在后台执行 | -| 任务进度实时推送 | SSE 或轮询,前端可看到实时进度 | -| 任务队列管理 | 支持任务排队、取消、重试 | -| 并发控制 | 限制同时进行的 FFmpeg/转写/AI 任务数 | +### v2.0 真实环境验收 -### P2-4 — 本地安全增强 +- [ ] 抖音使用单条低风险素材完成登录、上传、表单填写、封面、提交和成功证据验收 +- [ ] B站使用单条低风险素材完成同等灰度验收 +- [ ] 登录失效、验证码、风控和结果不确定场景都能进入 `NEED_REVIEW` +- [ ] 连续运行 Docker Watcher、Scheduler 与 Windows Worker,验证中断恢复和日志完整性 +- [ ] 用真实长视频继续校准候选质量、文案、封面时间点和排期体验 -**目标**:加固单用户本地使用的安全边界。 +--- -| 任务 | 说明 | -| --- | --- | -| 操作确认对话框 | 删除、清空回收站等敏感操作需二次确认 | -| 操作日志 | 记录关键操作到 `audit_log` 表(本地 SQLite) | -| Token 管理 | `LOCAL_ADMIN_TOKEN` 过期/刷新机制 | -| 文件校验 | 上传文件 MIME 类型和大小校验 | +## 计划中 -### P2-5 — 存储灵活性 +### v2.x — 本地可靠性与安全增强 -**目标**:让存储根目录可以灵活配置,支持 NAS。 +**目标**:保持单用户 Windows 本地架构,优先提高可靠性,不急于引入分布式组件。 | 任务 | 说明 | | --- | --- | -| NAS 路径验证 | 启动时检查 `STORAGE_ROOT` 是否可达 | -| 网络路径支持 | UNC 路径(`\\NAS\share`)读写测试 | -| 空间检查 | 新建任务前检查磁盘剩余空间 | +| 数据备份 | 增加面向新手的一键 SQLite 备份、完整性检查和恢复说明 | +| 磁盘预警 | 新建任务前检查 E 盘剩余空间,避免长视频处理中途失败 | +| 处理并发 | 明确 FFmpeg、转写和 AI 任务的本机并发上限与排队提示 | +| 操作追踪 | 为永久删除、人工确认发布结果等高风险动作补充本地审计记录 | +| 平台维护 | 平台页面变化时更新选择器和成功证据,不绕过平台验证 | --- diff --git a/docs/TASK_FLOW.md b/docs/TASK_FLOW.md index 858020e..261c1f5 100644 --- a/docs/TASK_FLOW.md +++ b/docs/TASK_FLOW.md @@ -36,7 +36,7 @@ pending_video ← 任务已创建,尚未上传视频 - `completed` / `completed_with_errors` 代表"自动切割阶段结束",不是平台发布完成。 - 字幕和发布是独立于主任务状态的后续工作流。 -## 3. v1.3.0 全自动任务状态流 +## 3. v2.0.0 全自动任务状态流 `auto_mode=true` 的任务使用独立的大写状态,不破坏原有手动流程: @@ -78,7 +78,9 @@ FAILED_PUBLISH_JOB_CREATING - 自动选片数量读取 `tasks.candidate_clip_count`,时长上限读取 `tasks.max_clip_duration`;旧自动数量和最小/最大秒数只保留兼容,不再参与新任务决策。 - 切片输出仍写入 `05_clips/`,并写入 `output_clip`;单个切片失败不会阻断其他成功切片生成文案和发布任务。 - `SCHEDULE_CREATING` 当前表示整理发送队列,不再自动计算发布时间。 -- 发布任务先以 `WAITING` / `NEED_REVIEW` 创建;用户在发送中心批量设置时间后进入 `SCHEDULED`,再由 v1.4.0 调度器执行。 +- 发布任务先以 `WAITING` / `NEED_REVIEW` 创建;用户在发送中心批量设置时间后进入 `SCHEDULED`,再由当前 `PublishScheduler` 到点执行。 +- 任务详情调用 `GET /api/tasks/{task_id}/live-status`,处理中每 3 秒局部更新状态、进度、10 步时间线、日志、候选/输出数量和可用操作;完成或失败后停止轮询。 +- 网络短暂中断只显示重试提示,不会整页刷新,也不会清空正在编辑但尚未保存的 AI Prompt。 ## 4. 失败流转 @@ -150,7 +152,8 @@ audio/source.wav ## 8. 自动切割阶段 -- 用户在片段审核页点击"生成切片"后,任务进入 `cutting`。 +- 用户在片段审核页点击“生成切片”时,页面会先保存当前启用状态、标题、摘要和出入点,再让任务进入 `cutting`。 +- 点击“保存并同步发送中心”会先比较当前启用候选与激活成片;有变化、文件缺失或数量不一致时生成新版本,完全一致时只执行幂等同步。 - 所有启用片段都切割成功时,任务进入 `completed`。 - 至少一个片段成功、同时存在失败片段时,任务进入 `completed_with_errors`。 - 所有片段都失败时,任务进入 `failed`。 @@ -214,6 +217,8 @@ output_clip 生成成功 ### 排期与立即发送 - 浏览器提交北京时间 `start_at_local`;后端按 `Asia/Shanghai` 应用每日开始/结束窗口,跨日后顺延到次日开始时间。 +- 排期支持续接当前平台最晚未来任务;抖音和 B站独立计算,选中但尚未保存的任务不会被当成已有排期重复计算。 +- 排期月历可展开某天全部任务;当天详情和主任务清单都按北京时间升序,未排期任务保留在已排期任务之后。 - `scheduled_at` 统一存 UTC ISO 8601,API 同时返回 `scheduled_at_utc` 与 `scheduled_at_local`。 - 自动调度只读取到期的 `SCHEDULED`;`NEED_REVIEW` 即使有时间也不能执行。 - “立即发送”允许 `DRAFT`、`WAITING`、`SCHEDULED`,只把 `scheduled_at` 更新为当前 UTC 并唤醒 Scheduler;不直接调用 opencli 或平台页面。 diff --git a/docs/UI_REFERENCE.md b/docs/UI_REFERENCE.md index 6e0b19a..1142b5d 100644 --- a/docs/UI_REFERENCE.md +++ b/docs/UI_REFERENCE.md @@ -1,5 +1,36 @@ # UI 参考说明 +## v2.0 界面总览 + +- 左侧主导航保持“工作台、新建任务、任务列表、片段审核、字幕推送、发送中心、系统状态”七个入口,侧栏状态统一显示“v2.0 本地高光生产版”。 +- 任务详情是处理过程的事实入口:全自动任务每 3 秒局部更新状态、10 步进度、日志、候选数、输出数和重试 / 继续 / 前往发送中心操作,不整页刷新。 +- 片段审核负责最终选片与出入点确认;“保存并同步发送中心”把保存、必要时重新切片和内容同步合并成一个清楚动作。 +- 发送中心固定分为“内容准备、排期计划、执行记录”:内容准备核对素材和文案,排期计划管理北京时间与月历,执行记录保留成功、失败、取消、导出和人工复核证据。 +- v2.0 继续使用 Apple 风格浅色界面、蓝色主强调、轻量玻璃卡片和充足留白;状态颜色必须服务于“处理中、完成、警告 / 人工处理”,不能只做装饰。 + +## 2026-08-03 更新:排期日期详情与时间升序 + +- `/publish` 的“排期计划”月历保留每格前两条预览和“另有 N 条”,有排期的日期格整体增加悬停、键盘焦点和蓝色选中状态,空日期保持不可操作。 +- 点击日期后,在月历网格下方展开浅蓝玻璃卡片,标题显示日期、当前平台和任务数量;完整列表按北京时间升序展示时间、视频标题、账号、状态与“定位到任务”。 +- 点击当天详情中的任务会滚动并高亮下方原任务,所有发送、清除排期和取消操作仍只保留在主任务行,避免出现两套状态不同步的操作入口。 +- 主任务清单使用时间线语义:有效排期在前并从早到晚排列,未排期统一置后且保留原顺序;动态调整后原地重排。 +- 小屏下当天详情压缩为“时间 + 任务信息”两列,隐藏重复的定位提示;不新增前端框架、接口或数据库字段。 + +## 2026-08-02 更新:续接当前平台最晚排期 + +- 排期抽屉在“第 1 条发布时间”下方新增次级按钮“接在当前平台最晚排期后”,保持手动日期时间输入框可编辑。 +- 点击按钮后原地展示“当前最晚:日期时间;本次第 1 条:日期时间”,并把结果回填到日期时间输入框;无未来排期或请求失败时在同一区域给出提示。 +- 续排只读取当前平台,并排除本次已选任务;抖音和 B站不共用时间线。参数变化后继续清空旧预览并禁用确认按钮。 +- 每日窗口默认显示 `07:00 → 00:00`,两个时间控件仍可修改;跨午夜时段的视觉说明明确 00:00 代表次日午夜。 +- 按钮、提示和输入框继续使用现有浅色抽屉、蓝色强调和紧凑表单样式,不引入新的前端框架。 + +## 2026-08-02 更新:内容准备已排期视觉标记 + +- `/publish` 内容准备卡片继续保留已排期任务,但 `SCHEDULED` 卡片右上角新增蓝色“已排期 + 具体北京时间”胶囊标签。 +- 已排期卡片使用轻蓝渐变底色、蓝色描边、左侧强调线和克制阴影;普通未排期卡片继续使用原白色玻璃卡片,形成清楚但不突兀的状态区分。 +- 已排期卡片底部按钮显示“调整排期”,未排期时显示“加入发布计划”;新增、清除或取消排期后,标签、卡片颜色和按钮文案原地同步。 +- 标签复用现有 `SCHEDULED` 状态与北京时间,不调整页面结构、不新增数据库字段,也不改变抖音/B站发布边界。 + ## 2026-08-01 更新:综艺笑点优先选片与反馈 - 新建任务页把“候选池上限”和“最终启用目标”拆成两个控件,并新增“通用内容价值 / 综艺笑点优先”模式选择;弱集少选的规则在控件下直接说明。 @@ -168,9 +199,9 @@ - 浏览器标签页和收藏夹使用同一套牛马图标资源:`niuma-studio-favicon.ico`、`niuma-studio-favicon-32.png` 和 `niuma-studio-apple-touch-icon.png`。 - E 盘 `E:\直播间切片工作流存储` 仍作为历史存储目录沿用,本轮不改路径,避免影响已有任务和视频。 -## 2026-05-27 更新:任务列表移入回收站 -- 任务列表里的原“隐藏”操作改为“移入回收站”,按钮仍放在每条任务右侧操作区,属于危险操作样式。 -- 点击后会二次确认,说明该任务会从页面列表隐藏,并且项目文件夹会移动到 `E:\直播间切片工作流存储\_回收站`,不会删除原视频、切片、字幕或日志文件。 +## 2026-08-02 更新:任务列表永久删除 +- 任务列表右侧危险操作为“永久删除”,不再提供 E 盘回收站;点击后明确提示原片副本、音频、转写、切片、字幕、封面和发布包都将永久删除且无法恢复。 +- 确认框同时说明 NAS 或任务目录外的原始视频不会被删除;运行中的处理或真实发布任务由后台拒绝删除并展示具体原因。 - 新建任务后的本地文件夹名与项目名保持一致;任务详情页仍展示任务目录,方便用户直接对照 E 盘项目文件夹查找完成视频。 ## 2026-05-27 更新:AI 片段完整性与检查入口 @@ -273,7 +304,9 @@ docs/design/live_streaming_slicing_workflow_ui_16x9.png 包含搜索、平台筛选、状态筛选、排序按钮、任务表格、状态标签和分页样式。 -当前任务列表已增加“移入回收站”操作。该操作只从页面列表移除任务,不删除 E 盘任务目录和视频文件。任务列表会展示输出切片数量和“后续工作流”,用于提示切片完成后是否进入“待加字幕 / 待推送”;如果任务失败,会在状态标签下方显示一行失败原因,方便先判断是否需要重试远程或确认本地模型。 +当前任务列表提供“永久删除”操作:系统永久删除 E 盘托管任务媒体并隐藏数据库历史,外部原片保留。任务列表会展示输出切片数量和“后续工作流”,用于提示切片完成后是否进入“待加字幕 / 待推送”;如果任务失败,会在状态标签下方显示一行失败原因,方便先判断是否需要重试远程或确认本地模型。 + +系统状态页新增“视频临时与导出目录”卡片,直接展示上传临时目录和手动发布包目录是否已在 E 盘就绪。 ### 3. 新建任务 @@ -313,7 +346,7 @@ v1.3.0 起,新建任务页可勾选“新建后自动跑完整流水线”。 内容准备从已完成切片读取数据,默认使用 `05_clips/` 原片、AI 标题/摘要/话题和候选封面。每条记录可独立选择抖音或 B站账号;B站额外展示分区、原创/转载和转载来源。 -排期计划所有时间都标注“北京时间”。顶部使用“抖音排期 / B站排期”双卡片分类切换,卡片分别显示待排期与已排期数量;下方月历采用周一到周日的 42 格月视图,只展示当前平台的已排期任务,支持前后翻月、回到本月和点击任务定位清单。未排期任务继续保留在当前平台清单中。批量操作只负责账号、AI 文案和排期,排期先展示逐条预览再确认保存;真实发送必须从单条任务操作发起,并先进入 `SCHEDULED`,不在页面点击事件中直接操作平台。 +排期计划所有时间都标注“北京时间”。顶部使用“抖音排期 / B站排期”双卡片分类切换,卡片分别显示待排期与已排期数量;下方月历采用周一到周日的 42 格月视图,只展示当前平台的已排期任务,支持前后翻月、回到本月和点击任务定位清单。未排期任务继续保留在当前平台清单中。批量操作只负责账号、AI 文案和排期;抽屉默认使用 `07:00 → 00:00` 每日窗口,可一键把第 1 条接在当前平台最晚排期后,随后仍须展示逐条预览再确认保存。真实发送必须从单条任务操作发起,并先进入 `SCHEDULED`,不在页面点击事件中直接操作平台。 执行记录顶部使用独立的 42 格执行月历,按计划日期优先归档任务,并汇总每天的待执行、进行中、成功、失败、待复核、已导出和已取消数量。点击日期后,下方分页列表与状态筛选共同生效;“已删除记录”作为单独视图,不参与正常月历统计。 @@ -432,3 +465,10 @@ v1.3.0 起,新建任务页可勾选“新建后自动跑完整流水线”。 - 确认提示会说明视频、标题、简介、话题和封面均被保留;操作期间按钮禁用,避免重复请求。 - 操作成功后页面自动切换到“内容准备”,展开任务所在分组并定位到返回的卡片;排期时间清空为“未排期”,用户可直接修改内容或重新设置排期。 - “取消发送并返回准备”“移出内容准备”“跳过任务”保持三种独立语义:前者回到准备区,移出操作隐藏任务,跳过操作终止任务。 + +## 2026-08-02 更新:任务详情进度局部自动刷新 + +- 全自动任务详情页的“状态概览”卡片每 3 秒原地更新进度条、百分比和 10 个地铁路线节点,不刷新整张页面。 +- 顶部当前状态、基础信息中的候选/输出数量和右侧运行日志同步更新;切回浏览器标签页时立即读取一次最新状态。 +- 状态卡底部显示“自动刷新中 / 状态已更新 / 自动更新暂时中断”等轻量提示,沿用蓝色、绿色和黄色的现有状态语义。 +- 流程完成后原地显示发送中心和片段检查入口;失败时对应步骤变黄并显示重试入口。用户正在编辑的 AI Prompt、页面滚动位置和其他未保存内容保持不变。 diff --git a/scripts/opencli_host_bridge.py b/scripts/opencli_host_bridge.py index 7aa9628..1042bbf 100644 --- a/scripts/opencli_host_bridge.py +++ b/scripts/opencli_host_bridge.py @@ -142,7 +142,7 @@ def log_message(self, format: str, *args) -> None: def main() -> None: - # 兼容旧启动命令,但实际启动 v1.5 的受保护发布 Worker。 + # 兼容旧启动命令,但实际启动 v2.0 的受保护发布 Worker。 from scripts.publish_host_worker import main as worker_main worker_main() diff --git a/scripts/publish_host_worker.py b/scripts/publish_host_worker.py index ac944ca..151effd 100644 --- a/scripts/publish_host_worker.py +++ b/scripts/publish_host_worker.py @@ -148,7 +148,7 @@ def _resolve_media_path(raw_value: str, *, required: bool) -> str: def create_worker_app(token: str | None = None) -> FastAPI: worker_token = str(token if token is not None else settings.publish_worker_token) - worker = FastAPI(title="NiuMa Studio Publish Worker", version="1.5.0") + worker = FastAPI(title="NiuMa Studio Publish Worker", version="2.0.0") def require_token(authorization: str = Header(default="")) -> None: if not worker_token: diff --git a/scripts/purge_deleted_task_media.py b/scripts/purge_deleted_task_media.py new file mode 100644 index 0000000..6e76655 --- /dev/null +++ b/scripts/purge_deleted_task_media.py @@ -0,0 +1,161 @@ +"""预览或永久清理已隐藏任务的系统托管媒体文件。""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.config import settings # noqa: E402 +from app.db.database import get_connection # noqa: E402 +from app.services.database_backup_service import create_media_cleanup_backup # noqa: E402 +from app.services.storage_service import ( # noqa: E402 + StorageSafetyError, + build_task_media_cleanup_plan, + task_media_cleanup_plan_size, +) +from app.services.task_lifecycle_service import delete_task_permanently # noqa: E402 + + +def _deleted_tasks() -> list[dict]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, task_name, task_dir_name, source_type, + original_video_path, nas_file_path, status, + COALESCE(is_deleted, 0) AS is_deleted + FROM tasks + WHERE COALESCE(is_deleted, 0) = 1 + ORDER BY deleted_at, created_at, id + """ + ).fetchall() + return [dict(row) for row in rows] + + +def _active_task_directories() -> dict[str, bool]: + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, task_dir_name + FROM tasks + WHERE COALESCE(is_deleted, 0) = 0 + ORDER BY id + """ + ).fetchall() + return { + str(settings.tasks_dir / str(row["task_dir_name"] or row["id"])): ( + settings.tasks_dir / str(row["task_dir_name"] or row["id"]) + ).exists() + for row in rows + } + + +def build_report() -> dict: + items = [] + total_bytes = 0 + existing_directories = 0 + for task in _deleted_tasks(): + plan = build_task_media_cleanup_plan(task) + size_bytes = task_media_cleanup_plan_size(plan) + targets = [str(target.path) for target in plan.existing_targets] + total_bytes += size_bytes + existing_directories += len(targets) + items.append( + { + "task_id": task["id"], + "task_name": task["task_name"], + "size_bytes": size_bytes, + "existing_targets": targets, + "external_source_preserved": str(plan.external_source_path or ""), + } + ) + return { + "mode": "dry-run", + "database_path": str(settings.database_path), + "tasks_dir": str(settings.tasks_dir), + "deleted_task_count": len(items), + "existing_directory_count": existing_directories, + "total_bytes": total_bytes, + "items": items, + } + + +def apply_report(report: dict) -> dict: + active_before = _active_task_directories() + missing_active_before = [path for path, existed in active_before.items() if not existed] + if missing_active_before: + raise RuntimeError( + "发现有效任务目录在清理前已经缺失,已中止:" + ";".join(missing_active_before) + ) + + active_resolved = {Path(path).resolve(strict=False) for path in active_before} + cleanup_resolved = { + Path(path).resolve(strict=False) + for item in report["items"] + for path in item["existing_targets"] + } + overlaps = [] + for cleanup_path in cleanup_resolved: + for active_path in active_resolved: + if ( + cleanup_path == active_path + or cleanup_path in active_path.parents + or active_path in cleanup_path.parents + ): + overlaps.append(f"清理目标 {cleanup_path} 与有效任务 {active_path} 重叠") + if overlaps: + raise RuntimeError("发现清理目标与有效任务目录重叠,已中止:" + ";".join(overlaps)) + + backup_path = create_media_cleanup_backup( + settings.database_path, + settings.data_dir / "backups", + ) + results = [] + for item in report["items"]: + results.append(delete_task_permanently(str(item["task_id"]))) + + missing_active_after = [path for path in active_before if not Path(path).exists()] + if missing_active_after: + raise RuntimeError( + "清理后发现有效任务目录缺失,请立即检查数据库备份:" + ";".join(missing_active_after) + ) + + released_bytes = sum(int(result["freed_bytes"]) for result in results) + return { + **report, + "mode": "apply", + "backup_path": str(backup_path), + "released_bytes": released_bytes, + "results": results, + "active_task_count_verified": len(active_before), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="永久清理已隐藏任务的系统托管媒体") + parser.add_argument( + "--apply", + action="store_true", + help="实际永久删除;不带此参数时只生成预览清单", + ) + args = parser.parse_args() + + try: + report = build_report() + if args.apply: + report = apply_report(report) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + except (StorageSafetyError, RuntimeError) as exc: + print(f"清理已中止:{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start_docker_opencli.ps1 b/scripts/start_docker_opencli.ps1 index 0348f35..f18a395 100644 --- a/scripts/start_docker_opencli.ps1 +++ b/scripts/start_docker_opencli.ps1 @@ -7,7 +7,7 @@ $ErrorActionPreference = "Stop" $ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $ProjectRoot -Write-Host '此兼容脚本现在会启动 v1.5 Windows Chrome 发布 Worker。' +Write-Host '此兼容脚本现在会启动 v2.0 Windows Chrome 发布 Worker。' & (Join-Path $PSScriptRoot 'start_publish_worker.ps1') -Port $BridgePort Write-Host '正在刷新 Docker 服务:http://127.0.0.1:8001' diff --git a/tests/test_auto_pipeline.py b/tests/test_auto_pipeline.py index 41a5e28..bdf226a 100644 --- a/tests/test_auto_pipeline.py +++ b/tests/test_auto_pipeline.py @@ -1,4 +1,4 @@ -"""v1.3.0 全自动任务流水线最小测试。""" +"""v2.0.0 全自动任务流水线回归测试。""" from __future__ import annotations @@ -16,7 +16,8 @@ from app.services.auto_publish_service import create_auto_publish_jobs from app.services.pipeline_engine import PipelineEngine, build_schedule_times from app.services.storage_service import get_artifact_paths -from app.services.task_lifecycle_service import create_task_record +from app.services.task_lifecycle_service import create_task_record, update_task_status +from app.services.task_log_service import append_task_log from app.services.task_service import get_task @@ -221,6 +222,27 @@ def test_schedule_generation_defaults_to_ten_minutes_then_three_hours(): assert scheduled[1].startswith("2026-06-23T11:10:00") +def test_daily_window_schedule_supports_seven_to_midnight_without_looping(): + local_zone = datetime.now().astimezone().tzinfo + start = datetime(2026, 6, 23, 21, 0, tzinfo=local_zone) + scheduled = build_schedule_times( + 3, + { + "auto_schedule_mode": "daily_window", + "auto_schedule_start_at": start.isoformat(timespec="minutes"), + "auto_schedule_interval_hours": 3, + "auto_schedule_daily_start_time": "07:00", + "auto_schedule_daily_end_time": "00:00", + }, + now=start, + ) + assert [datetime.fromisoformat(item).strftime("%Y-%m-%d %H:%M") for item in scheduled] == [ + "2026-06-23 21:00", + "2026-06-24 00:00", + "2026-06-24 07:00", + ] + + def test_create_auto_publish_job_records_scheduled_at(): task = _create_auto_task("test-auto-publish-job") clip_path = _fake_video("publish_clip.mp4") @@ -439,3 +461,81 @@ def test_auto_resume_endpoint_starts_from_clip_selection(monkeypatch): assert starter.call_args.args[0] == task["id"] assert starter.call_args.kwargs["start_step"] == TaskStatus.CLIP_SELECTING assert starter.call_args.kwargs["background_tasks"] is not None + + +def test_live_status_endpoint_tracks_running_auto_pipeline(): + task = _create_auto_task("test-auto-live-running") + update_task_status(task["id"], TaskStatus.AI_ANALYZING) + append_task_log(task["id"], "实时状态测试日志") + + with TestClient(app) as client: + response = client.get(f"/api/tasks/{task['id']}/live-status", headers=_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == TaskStatus.AI_ANALYZING.value + assert payload["status_label"] == "AI 分析中" + assert payload["progress"] == 45 + assert payload["is_running"] is True + assert payload["should_poll"] is True + assert payload["actions"]["primary"] == "processing" + assert len(payload["workflow_steps"]) == 10 + assert payload["workflow_steps"][3]["state"] == "current" + assert any("实时状态测试日志" in line for line in payload["log_lines"]) + + +def test_live_status_endpoint_returns_failure_and_retry_action(): + task = _create_auto_task("test-auto-live-failed") + update_task_status(task["id"], TaskStatus.FAILED_AI_ANALYZING, "模型服务暂时不可用") + + with TestClient(app) as client: + response = client.get(f"/api/tasks/{task['id']}/live-status", headers=_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["should_poll"] is False + assert payload["error_message"] == "模型服务暂时不可用" + assert payload["actions"]["primary"] == "retry" + assert payload["workflow_steps"][3]["state"] == "warning" + + +def test_live_status_endpoint_returns_completed_actions(monkeypatch): + task = _create_auto_task("test-auto-live-completed") + update_task_status(task["id"], TaskStatus.COMPLETED) + monkeypatch.setattr("app.services.task_service.count_clip_candidates", lambda _task_id: 3) + monkeypatch.setattr("app.services.task_service.count_output_clips", lambda _task_id: 2) + + with TestClient(app) as client: + response = client.get(f"/api/tasks/{task['id']}/live-status", headers=_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["progress"] == 100 + assert payload["should_poll"] is False + assert payload["counts"] == {"candidates": 3, "outputs": 2} + assert payload["actions"] == {"primary": "publish", "review": True, "publish": True} + assert all(step["state"] == "done" for step in payload["workflow_steps"]) + + +def test_live_status_endpoint_returns_404_for_missing_task(): + with TestClient(app) as client: + response = client.get("/api/tasks/test-auto-missing/live-status", headers=_headers()) + + assert response.status_code == 404 + assert response.json()["detail"] == "任务不存在" + + +def test_task_detail_live_status_frontend_uses_partial_refresh(): + template = (settings.project_root / "app" / "templates" / "task_detail.html").read_text(encoding="utf-8") + script = (settings.project_root / "app" / "static" / "js" / "app.js").read_text(encoding="utf-8") + live_script = script[ + script.index("function renderTaskLiveStatus"): + script.index("async function pollAiAnalysisStatus") + ] + + assert "data-task-live-overview" in template + assert "data-live-task-actions" in template + assert "/live-status" in live_script + assert "TASK_LIVE_STATUS_INTERVAL_MS = 3000" in script + assert 'document.addEventListener("visibilitychange"' in live_script + assert "window.location.reload" not in live_script diff --git a/tests/test_clip_review_publish_sync.py b/tests/test_clip_review_publish_sync.py new file mode 100644 index 0000000..a4cdc36 --- /dev/null +++ b/tests/test_clip_review_publish_sync.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from unittest.mock import Mock + +from app.main import app +from app.models.task import ClipCandidateBatchItem +from app.services import task_service + + +def _payload() -> list[ClipCandidateBatchItem]: + return [ + ClipCandidateBatchItem( + id="clip-review-sync-001", + title="值得发送的片段", + start_time="00:01:00", + end_time="00:02:00", + enabled=True, + summary="完整笑点片段", + ) + ] + + +def test_review_sync_regenerates_when_active_outputs_do_not_match(monkeypatch) -> None: + save = Mock(return_value={"changed_count": 0, "message": "已保存", "clips": [], "task": {}}) + cut = Mock( + return_value={ + "publish_sync": { + "status": "ok", + "message": "发送中心同步完成。", + "link_state": {"linked_count": 1}, + } + } + ) + monkeypatch.setattr(task_service, "update_clip_candidates_batch", save) + monkeypatch.setattr(task_service, "_active_outputs_match_enabled_candidates", lambda _task_id: False) + monkeypatch.setattr(task_service, "process_task_video_cuts", cut) + + result = task_service.sync_reviewed_clips_to_publish_center("task-review-sync", _payload()) + + assert result["regenerated"] is True + assert result["link_state"]["linked_count"] == 1 + assert "重新生成最新切片" in result["message"] + save.assert_called_once() + cut.assert_called_once_with("task-review-sync") + + +def test_review_sync_reuses_matching_outputs_when_review_is_unchanged(monkeypatch) -> None: + save = Mock(return_value={"changed_count": 0, "message": "已保存", "clips": [], "task": {}}) + sync = Mock( + return_value={ + "status": "ok", + "message": "发送中心同步完成。", + "link_state": {"linked_count": 1}, + } + ) + monkeypatch.setattr(task_service, "update_clip_candidates_batch", save) + monkeypatch.setattr(task_service, "_active_outputs_match_enabled_candidates", lambda _task_id: True) + monkeypatch.setattr("app.services.publish_service.sync_task_publish_jobs", sync) + + result = task_service.sync_reviewed_clips_to_publish_center("task-review-sync", _payload()) + + assert result["regenerated"] is False + assert "无需重复生成" in result["message"] + sync.assert_called_once_with( + "task-review-sync", + prefer_subtitled=False, + restore_removed=True, + ) + + +def test_review_sync_route_is_available() -> None: + assert "/api/tasks/{task_id}/clips/sync-publish" in app.openapi()["paths"] diff --git a/tests/test_database_backup_service.py b/tests/test_database_backup_service.py index 322f5a7..80f52f6 100644 --- a/tests/test_database_backup_service.py +++ b/tests/test_database_backup_service.py @@ -15,6 +15,7 @@ BackupSafetyError, apply_cleanup_plan, build_cleanup_plan, + create_media_cleanup_backup, create_publish_migration_backup, sqlite_quick_check, ) @@ -107,6 +108,17 @@ def test_repeated_backup_within_24_hours_creates_only_one_file(tmp_path): assert sqlite_quick_check(first) == "ok" +def test_media_cleanup_backup_is_always_created_and_valid(tmp_path): + database_path = tmp_path / "workflow.sqlite3" + backup_dir = tmp_path / "backups" + _create_database(database_path) + + backup = create_media_cleanup_backup(database_path, backup_dir) + + assert backup.name.startswith("workflow-before-media-cleanup-") + assert sqlite_quick_check(backup) == "ok" + + def test_concurrent_publish_migration_creates_one_valid_backup(monkeypatch, tmp_path): database_path = tmp_path / "workflow.sqlite3" settings = SimpleNamespace( diff --git a/tests/test_media_storage_lifecycle.py b/tests/test_media_storage_lifecycle.py new file mode 100644 index 0000000..f96f270 --- /dev/null +++ b/tests/test_media_storage_lifecycle.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.db.database import get_connection, init_db +from app.main import app +from app.models.task import TaskCreate, TaskStatus +from app.routers import tasks as tasks_router +from app.services import job_service +from app.services import task_lifecycle_service +from app.services.storage_service import ( + StorageSafetyError, + configure_runtime_media_storage, + save_uploaded_video, +) +from app.services.task_lifecycle_service import ( + TaskDeletionConflictError, + create_task_record, + delete_task_permanently, + update_task_status, +) +from scripts.purge_deleted_task_media import apply_report, build_report + + +@pytest.fixture +def isolated_media_settings(tmp_path): + original_temp = tempfile.tempdir + original_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + + project_root = tmp_path / "project" + data_dir = project_root / "data" + storage_root = tmp_path / "e-drive" / "直播间切片工作流存储" + replacements = { + "project_root": project_root, + "data_dir": data_dir, + "database_path": data_dir / "workflow.sqlite3", + "storage_root": storage_root, + "tasks_dir": storage_root, + "upload_temp_dir": storage_root / "_临时上传", + "publish_scheduler_export_dir": storage_root / "_发布包", + } + settings_objects = [] + seen_settings = set() + for module_name, module in tuple(sys.modules.items()): + if not (module_name.startswith("app.") or module_name == "scripts.purge_deleted_task_media"): + continue + candidate = getattr(module, "settings", None) + if candidate is None or not hasattr(candidate, "database_path") or id(candidate) in seen_settings: + continue + seen_settings.add(id(candidate)) + settings_objects.append(candidate) + + original_values = { + id(candidate): { + name: getattr(candidate, name) + for name in replacements + } + for candidate in settings_objects + } + for candidate in settings_objects: + for name, value in replacements.items(): + object.__setattr__(candidate, name, value) + init_db() + + try: + yield replacements + finally: + tempfile.tempdir = original_temp + for name, value in original_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + for candidate in settings_objects: + for name, value in original_values[id(candidate)].items(): + object.__setattr__(candidate, name, value) + + +def _create_managed_task(task_id: str, task_dir: Path, *, auto_mode: bool = False) -> Path: + source_path = task_dir / "source" / "source.mp4" + source_path.parent.mkdir(parents=True, exist_ok=True) + source_path.write_bytes(b"managed-video") + create_task_record( + TaskCreate( + task_name=task_id, + source_type="upload", + original_video_path=str(source_path), + auto_mode=auto_mode, + ), + task_id=task_id, + task_dir_name=task_id, + ) + return source_path + + +def _headers() -> dict[str, str]: + if not settings.local_admin_token: + return {} + return {"Authorization": f"Bearer {settings.local_admin_token}"} + + +def test_runtime_media_storage_uses_configured_e_drive(isolated_media_settings): + result = configure_runtime_media_storage() + + assert Path(result["tasks_dir"]) == settings.tasks_dir.resolve() + assert Path(result["upload_temp_dir"]) == settings.upload_temp_dir.resolve() + assert Path(result["publish_export_dir"]) == settings.publish_scheduler_export_dir.resolve() + assert tempfile.tempdir == str(settings.upload_temp_dir.resolve()) + assert os.environ["TEMP"] == str(settings.upload_temp_dir.resolve()) + assert os.environ["TMP"] == str(settings.upload_temp_dir.resolve()) + + +def test_large_multipart_upload_spools_in_e_drive(monkeypatch, isolated_media_settings): + captured = {} + previous_temp = tempfile.tempdir + previous_temp_env = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + + def capture_upload(task_id, filename, file_object, task_dir_name=None): + captured["rolled"] = bool(getattr(file_object, "_rolled", False)) + captured["tempdir"] = tempfile.tempdir + return save_uploaded_video(task_id, filename, file_object, task_dir_name) + + monkeypatch.setattr(tasks_router, "save_uploaded_video", capture_upload) + with TestClient(app) as client: + response = client.post( + "/api/tasks/upload", + data={"task_name": "large-upload-e-drive", "platform": "general"}, + files={"video_file": ("source.mp4", b"V" * (2 * 1024 * 1024), "video/mp4")}, + headers=_headers(), + ) + + assert response.status_code == 200 + assert captured["rolled"] is True + assert captured["tempdir"] == str(settings.upload_temp_dir.resolve()) + assert tempfile.tempdir == previous_temp + assert {name: os.environ.get(name) for name in ("TEMP", "TMP")} == previous_temp_env + with get_connection() as connection: + row = connection.execute( + "SELECT original_video_path FROM tasks WHERE id = ?", + (response.json()["id"],), + ).fetchone() + assert Path(row["original_video_path"]).is_relative_to(settings.tasks_dir) + + +def test_failed_upload_removes_partial_task_directory(monkeypatch, isolated_media_settings): + original_limit = settings.max_upload_size_bytes + object.__setattr__(settings, "max_upload_size_bytes", 1024) + monkeypatch.setattr( + tasks_router, + "allocate_task_dir_name", + lambda task_name, exclude_task_id=None: "failed-upload-directory", + ) + try: + response = TestClient(app).post( + "/api/tasks/upload", + data={"task_name": "failed-upload", "platform": "general"}, + files={"video_file": ("source.mp4", b"V" * 2048, "video/mp4")}, + headers=_headers(), + ) + finally: + object.__setattr__(settings, "max_upload_size_bytes", original_limit) + + assert response.status_code == 400 + assert not (settings.tasks_dir / "failed-upload-directory").exists() + + +def test_delete_removes_managed_media_and_keeps_database_history(isolated_media_settings): + task_id = "media-delete-001" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + clip_path = task_dir / "05_clips" / "clip.mp4" + clip_path.parent.mkdir(parents=True, exist_ok=True) + clip_path.write_bytes(b"clip-video") + export_path = settings.publish_scheduler_export_dir / task_id / "clip-1" / "clip.mp4" + export_path.parent.mkdir(parents=True, exist_ok=True) + export_path.write_bytes(b"export-video") + job_service.create_job(task_id, job_service.JOB_TYPE_VIDEO_CUT) + + result = delete_task_permanently(task_id) + + assert result["status"] == "deleted" + assert result["freed_bytes"] >= len(b"managed-videoclip-videoexport-video") + assert not task_dir.exists() + assert not (settings.publish_scheduler_export_dir / task_id).exists() + with get_connection() as connection: + task = connection.execute("SELECT is_deleted FROM tasks WHERE id = ?", (task_id,)).fetchone() + job = connection.execute( + "SELECT status FROM workflow_jobs WHERE task_id = ?", + (task_id,), + ).fetchone() + assert task["is_deleted"] == 1 + assert job["status"] == "cancelled" + + repeated = delete_task_permanently(task_id) + assert repeated["status"] == "already_deleted" + assert repeated["freed_bytes"] == 0 + + +def test_delete_preserves_external_source(isolated_media_settings): + task_id = "media-delete-external" + external_source = settings.storage_root / "共享原片" / "source.mp4" + external_source.parent.mkdir(parents=True, exist_ok=True) + external_source.write_bytes(b"unique-original") + create_task_record( + TaskCreate( + task_name=task_id, + source_type="nas", + nas_file_path=str(external_source), + ), + task_id=task_id, + task_dir_name=task_id, + ) + + result = delete_task_permanently(task_id) + + assert result["external_source_preserved"] is True + assert external_source.read_bytes() == b"unique-original" + assert not (settings.tasks_dir / task_id).exists() + + +def test_delete_rejects_running_task(isolated_media_settings): + task_id = "media-delete-running" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + update_task_status(task_id, TaskStatus.transcribing) + + with pytest.raises(TaskDeletionConflictError, match="正在处理"): + delete_task_permanently(task_id) + + assert task_dir.exists() + with get_connection() as connection: + row = connection.execute("SELECT is_deleted FROM tasks WHERE id = ?", (task_id,)).fetchone() + assert row["is_deleted"] == 0 + + +def test_delete_rejects_running_workflow_job(isolated_media_settings): + task_id = "media-delete-job" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + job = job_service.create_job(task_id, job_service.JOB_TYPE_VIDEO_CUT) + job_service.mark_job_running(job["id"]) + + with pytest.raises(TaskDeletionConflictError, match="后台切片"): + delete_task_permanently(task_id) + + assert task_dir.exists() + + +def test_delete_failure_keeps_task_visible(monkeypatch, isolated_media_settings): + task_id = "media-delete-failure" + task_dir = settings.tasks_dir / task_id + _create_managed_task(task_id, task_dir) + + def fail_cleanup(_plan): + raise RuntimeError("模拟文件被占用") + + monkeypatch.setattr(task_lifecycle_service, "apply_task_media_cleanup_plan", fail_cleanup) + with pytest.raises(RuntimeError, match="文件被占用"): + delete_task_permanently(task_id) + + assert task_dir.exists() + with get_connection() as connection: + row = connection.execute("SELECT is_deleted FROM tasks WHERE id = ?", (task_id,)).fetchone() + assert row["is_deleted"] == 0 + + +def test_delete_rejects_path_traversal(isolated_media_settings): + now = "2026-08-02T00:00:00+00:00" + with get_connection() as connection: + connection.execute( + """ + INSERT INTO tasks (id, task_name, task_dir_name, status, created_at, updated_at) + VALUES ('unsafe-task', 'unsafe-task', '..\\outside', 'completed', ?, ?) + """, + (now, now), + ) + connection.commit() + + with pytest.raises(StorageSafetyError, match="不安全路径"): + delete_task_permanently("unsafe-task") + + +def test_cleanup_report_dry_run_then_apply_only_deletes_hidden_tasks(isolated_media_settings): + hidden_id = "media-hidden-001" + active_id = "media-active-001" + hidden_dir = settings.tasks_dir / hidden_id + active_dir = settings.tasks_dir / active_id + _create_managed_task(hidden_id, hidden_dir) + _create_managed_task(active_id, active_dir) + with get_connection() as connection: + connection.execute( + "UPDATE tasks SET is_deleted = 1, deleted_at = updated_at WHERE id = ?", + (hidden_id,), + ) + connection.commit() + + report = build_report() + + assert report["mode"] == "dry-run" + reported_ids = {item["task_id"] for item in report["items"]} + assert hidden_id in reported_ids + assert active_id not in reported_ids + assert hidden_dir.exists() + assert active_dir.exists() + + applied = apply_report(report) + + assert applied["mode"] == "apply" + assert applied["released_bytes"] > 0 + assert Path(applied["backup_path"]).exists() + assert not hidden_dir.exists() + assert active_dir.exists() + assert applied["active_task_count_verified"] >= 1 + + +def test_cleanup_aborts_before_deleting_overlapping_active_directory(isolated_media_settings): + hidden_id = "media-hidden-overlap" + active_id = "media-active-overlap" + hidden_dir = settings.tasks_dir / hidden_id + active_dir = settings.tasks_dir / active_id + _create_managed_task(hidden_id, hidden_dir) + _create_managed_task(active_id, active_dir) + with get_connection() as connection: + connection.execute( + """ + UPDATE tasks + SET is_deleted = 1, deleted_at = updated_at, task_dir_name = ? + WHERE id = ? + """, + (active_id, hidden_id), + ) + connection.commit() + + report = build_report() + with pytest.raises(RuntimeError, match="有效任务目录重叠"): + apply_report(report) + + assert active_dir.exists() diff --git a/tests/test_publish_center_browser.py b/tests/test_publish_center_browser.py index 5586a82..be5cc76 100644 --- a/tests/test_publish_center_browser.py +++ b/tests/test_publish_center_browser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import socket import threading import time @@ -73,6 +74,7 @@ def test_publish_center_schedule_preview_confirm_and_export(monkeypatch, tmp_pat init_db() _cleanup() douyin_jobs = [_seed_job(tmp_path, index) for index in range(1, 11)] + unscheduled = _seed_job(tmp_path, 0) first = douyin_jobs[0] newest = douyin_jobs[-1] bilibili = _seed_job(tmp_path, 11, "bilibili") @@ -91,11 +93,23 @@ def test_publish_center_schedule_preview_confirm_and_export(monkeypatch, tmp_pat connection.commit() generated_cover = tmp_path / "browser-batch-cover.jpg" generated_cover.write_bytes(b"fake-cover") + preserved_cover = tmp_path / "browser-preserved-cover.jpg" + preserved_cover.write_bytes(b"existing-cover") + with get_connection() as connection: + connection.execute( + "UPDATE publish_jobs SET cover_file_path = ? WHERE id = ?", + (str(preserved_cover), unscheduled), + ) + connection.commit() def fake_backfill_covers(platform=None): with get_connection() as connection: rows = connection.execute( - "SELECT id FROM publish_jobs WHERE task_id LIKE ? AND status = 'WAITING' AND platform = ?", + """ + SELECT id FROM publish_jobs + WHERE task_id LIKE ? AND status = 'WAITING' AND platform = ? + AND TRIM(COALESCE(cover_file_path, '')) = '' + """, (f"{PREFIX}%", platform), ).fetchall() connection.execute( @@ -103,6 +117,7 @@ def fake_backfill_covers(platform=None): UPDATE publish_jobs SET cover_mode = 'time', cover_time_seconds = 30, cover_file_path = ? WHERE task_id LIKE ? AND status = 'WAITING' AND platform = ? + AND TRIM(COALESCE(cover_file_path, '')) = '' """, (str(generated_cover), f"{PREFIX}%", platform), ) @@ -122,6 +137,7 @@ def fake_backfill_covers(platform=None): monkeypatch.setattr(publish_service, "backfill_missing_publish_covers", fake_backfill_covers) future_start = datetime.now() + timedelta(days=2) future_day = future_start.strftime("%Y-%m-%d") + future_day_label = f"{future_start.year} 年 {future_start.month} 月 {future_start.day} 日" following_day = (future_start + timedelta(days=1)).strftime("%Y-%m-%d") port = _free_port() server = uvicorn.Server( @@ -142,6 +158,23 @@ def fake_backfill_covers(platform=None): browser = runtime.chromium.launch(headless=True, executable_path=str(chrome_path)) context = browser.new_context(timezone_id="Asia/Shanghai") page = context.new_page() + page.route( + "**/api/publish/schedules/next-start", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "status": "ok", + "timezone": "Asia/Shanghai", + "latest_scheduled_at_local_display": f"{future_day} 19:00", + "next_start_at_local": f"{future_day}T22:00", + "next_start_at_local_display": f"{future_day} 22:00", + }, + ensure_ascii=False, + ), + ), + ) page.goto(f"http://127.0.0.1:{port}/publish", wait_until="networkidle") assert page.locator( @@ -202,6 +235,16 @@ def fake_backfill_covers(platform=None): f'[data-publish-row][data-section="schedule"][data-job-id="{job_id}"] [data-publish-select]' ).check() page.locator("[data-open-schedule-drawer]").click() + assert page.locator('[name="daily_start_time"]').input_value() == "07:00" + assert page.locator('[name="daily_end_time"]').input_value() == "00:00" + page.locator("[data-use-latest-schedule]").click() + page.locator("[data-latest-schedule-note]").filter( + has_text=f"本次第 1 条:{future_day} 22:00" + ).wait_for() + assert page.locator('[name="start_at_local"]').input_value() == f"{future_day}T22:00" + assert page.locator("[data-latest-schedule-note]").inner_text() == ( + f"当前最晚:{future_day} 19:00;本次第 1 条:{future_day} 22:00" + ) page.locator('[name="start_at_local"]').fill("2020-01-01T06:00") page.locator("[data-preview-schedule]").click() assert page.locator("[data-schedule-feedback].tone-red").filter( @@ -238,6 +281,37 @@ def fake_backfill_covers(platform=None): assert f"{future_day} 06:00" in page.locator( f'[data-publish-row][data-section="schedule"][data-job-id="{first}"] [data-row-schedule]' ).inner_text() + visible_test_rows = page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id^="{PREFIX}"]:visible' + ) + assert visible_test_rows.evaluate_all("rows => rows.map((row) => row.dataset.jobId)") == [ + *douyin_jobs, + unscheduled, + ] + + calendar_day = page.locator(f'[data-calendar-date="{future_day}"]') + assert calendar_day.locator(".calendar-job-chip").all_inner_texts() == [ + "06:00 浏览器测试片段 1", + "09:00 浏览器测试片段 2", + ] + assert calendar_day.locator(".calendar-job-more").inner_text() == "另有 4 条" + calendar_day.click() + day_detail = page.locator("[data-calendar-day-detail]") + assert day_detail.is_visible() + assert day_detail.locator("[data-calendar-day-title]").inner_text() == future_day_label + assert "6 条排期" in day_detail.locator("[data-calendar-day-summary]").inner_text() + assert day_detail.locator(".publish-calendar-day-item time").all_inner_texts() == [ + "06:00", "09:00", "12:00", "15:00", "18:00", "21:00", + ] + day_detail.locator(f'[data-calendar-detail-job="{first}"]').click() + assert "is-calendar-focus" in page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id="{first}"]' + ).get_attribute("class") + page.locator("[data-calendar-day-close]").click() + assert day_detail.is_hidden() + calendar_day.focus() + calendar_day.press("Enter") + assert day_detail.is_visible() dialogs = [] page.on("dialog", lambda dialog: (dialogs.append(dialog.message), dialog.accept())) @@ -256,6 +330,13 @@ def fake_backfill_covers(platform=None): assert "已取消发送并返回内容准备" in page.locator("#send-center-message").inner_text() page.locator('[data-center-tab="schedule"]').click() + assert page.locator( + f'[data-publish-row][data-section="schedule"][data-job-id^="{PREFIX}"]:visible' + ).evaluate_all("rows => rows.map((row) => row.dataset.jobId)") == [ + *douyin_jobs[:-1], + newest, + unscheduled, + ] page.locator(f'[data-publish-row][data-section="schedule"][data-job-id="{first}"] [data-publish-now]').click() assert any("抖音" in message for message in dialogs) page.locator("#send-center-message").filter(has_text="统一调度").wait_for() diff --git a/tests/test_publish_scheduler.py b/tests/test_publish_scheduler.py index 08507b1..0539b24 100644 --- a/tests/test_publish_scheduler.py +++ b/tests/test_publish_scheduler.py @@ -16,7 +16,7 @@ from app.core.config import settings from app.db.database import get_connection, init_db from app.main import app -from app.models.task import PublishJobCreate +from app.models.task import PublishBatchScheduleUpdate, PublishJobCreate, PublishScheduleNextStartRequest from app.services.auto_publish_service import create_auto_publish_jobs from app.services.publish_domain import TARGET_PLATFORMS from app.services.publish_readiness import SendReadinessBlocked @@ -59,6 +59,11 @@ def _utc(delta_seconds: int = 0) -> str: return (datetime.now(timezone.utc) + timedelta(seconds=delta_seconds)).isoformat(timespec="seconds").replace("+00:00", "Z") +def _future_beijing_time(hour: int, *, minute: int = 0, days: int = 2) -> datetime: + future_date = (datetime.now(ZoneInfo("Asia/Shanghai")) + timedelta(days=days)).date() + return datetime(future_date.year, future_date.month, future_date.day, hour, minute, tzinfo=ZoneInfo("Asia/Shanghai")) + + def _insert_job( tmp_path: Path, *, @@ -171,6 +176,115 @@ def test_daily_window_overflow_moves_to_next_local_day(): assert result == ["2026-07-12T12:00:00+00:00", "2026-07-13T01:00:00+00:00", "2026-07-13T04:00:00+00:00"] +def test_schedule_request_defaults_use_seven_to_midnight(): + batch = PublishBatchScheduleUpdate(job_ids=["job-1"]) + next_start = PublishScheduleNextStartRequest(job_ids=["job-1"], platform="douyin") + assert (batch.daily_start_time, batch.daily_end_time) == ("07:00", "00:00") + assert (next_start.daily_start_time, next_start.daily_end_time) == ("07:00", "00:00") + + +def test_next_schedule_start_uses_current_platform_and_excludes_selected_jobs(tmp_path): + selected_time = _future_beijing_time(23) + selected = _insert_job(tmp_path, status="WAITING", scheduled_at=selected_time.astimezone(timezone.utc).isoformat(timespec="seconds")) + latest_time = _future_beijing_time(19) + latest = _insert_job(tmp_path, status="WAITING", scheduled_at=latest_time.astimezone(timezone.utc).isoformat(timespec="seconds")) + _insert_job( + tmp_path, + status="SCHEDULED", + platform="bilibili", + scheduled_at=_future_beijing_time(23).astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + _insert_job( + tmp_path, + status="PUBLISHED", + scheduled_at=_future_beijing_time(23).astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + + result = PublishScheduler().next_batch_schedule_start( + [selected], + platform="douyin", + timezone_name="Asia/Shanghai", + interval_minutes=180, + daily_start_time="07:00", + daily_end_time="00:00", + ) + + assert result["status"] == "ok" + assert result["latest_job_id"] == latest + assert result["latest_scheduled_at_local_display"].endswith(" 19:00") + assert result["next_start_at_local_display"].endswith(" 22:00") + + +@pytest.mark.parametrize( + ("latest_hour", "expected_day_offset", "expected_hour"), + [(21, 1, 0), (22, 1, 7)], +) +def test_next_schedule_start_respects_cross_midnight_window( + tmp_path, + latest_hour, + expected_day_offset, + expected_hour, +): + selected = _insert_job(tmp_path, status="WAITING", scheduled_at="") + latest_time = _future_beijing_time(latest_hour) + _insert_job( + tmp_path, + status="SCHEDULED", + scheduled_at=latest_time.astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + result = PublishScheduler().next_batch_schedule_start( + [selected], + platform="douyin", + interval_minutes=180, + daily_start_time="07:00", + daily_end_time="00:00", + ) + expected = latest_time.date() + timedelta(days=expected_day_offset) + assert result["next_start_at_local_display"] == f"{expected:%Y-%m-%d} {expected_hour:02d}:00" + + +def test_next_schedule_start_returns_empty_without_other_future_schedule(tmp_path): + selected = _insert_job(tmp_path, status="WAITING", scheduled_at="") + result = PublishScheduler().next_batch_schedule_start([selected], platform="douyin") + assert result["status"] == "empty" + assert result["next_start_at_local"] == "" + assert "手动选择" in result["message"] + + +def test_next_schedule_start_api_and_platform_isolation(tmp_path): + selected = _insert_job(tmp_path, status="WAITING", scheduled_at="") + latest_time = _future_beijing_time(19) + _insert_job( + tmp_path, + status="SCHEDULED", + scheduled_at=latest_time.astimezone(timezone.utc).isoformat(timespec="seconds"), + ) + headers = {"Authorization": f"Bearer {settings.local_admin_token}"} if settings.local_admin_token else {} + payload = { + "job_ids": [selected], + "platform": "douyin", + "timezone": "Asia/Shanghai", + "interval_minutes": 180, + "daily_start_time": "07:00", + "daily_end_time": "00:00", + } + client = TestClient(app) + response = client.post("/api/publish/schedules/next-start", json=payload, headers=headers) + assert response.status_code == 200 + assert response.json()["next_start_at_local_display"].endswith(" 22:00") + assert response.json()["next_start_at_local"].endswith("T22:00") + assert "+" not in response.json()["next_start_at_local"] + + bilibili = _insert_job(tmp_path, status="WAITING", platform="bilibili", scheduled_at="") + response = client.post( + "/api/publish/schedules/next-start", + json={**payload, "job_ids": [bilibili]}, + headers=headers, + ) + assert response.status_code == 409 + assert "当前平台与所选任务不一致" in response.json()["detail"] + + def test_scheduled_manual_export_job_can_run_now(tmp_path): job_id = _insert_job(tmp_path, status="SCHEDULED", scheduled_at=_utc(3600)) result = PublishScheduler().publish_now(job_id) @@ -390,9 +504,11 @@ def test_frontend_uses_one_selection_semantic_and_no_schedule_reload(): assert "data-send-job-checkbox" not in template assert "window.location.reload" not in script assert "/api/publish/schedules/preview" in script + assert "/api/publish/schedules/next-start" in script assert "data-schedule-feedback" in template + assert "data-use-latest-schedule" in template assert "正在生成预览…" in script - assert 'addEventListener("input", invalidatePreview)' in script + assert 'scheduleForm?.addEventListener("input", () =>' in script def test_run_once_module_command(tmp_path): diff --git a/tests/test_publish_task_grouping.py b/tests/test_publish_task_grouping.py index 0184718..2e2be48 100644 --- a/tests/test_publish_task_grouping.py +++ b/tests/test_publish_task_grouping.py @@ -155,6 +155,37 @@ def test_publish_page_renders_task_identity_without_full_source_path(tmp_path: P assert "移出内容准备" in html +def test_scheduled_content_card_has_clear_schedule_marker(tmp_path: Path) -> None: + task_id, _ = _insert_task(tmp_path, "已排期标记任务", created_at=_time(-5)) + job_id, _, _ = _insert_clip_job( + tmp_path, + task_id, + platform="douyin", + status="SCHEDULED", + created_at=_time(-4), + ) + + response = TestClient(app).get("/publish") + html = response.text + card_start = html.index(f'data-job-id="{job_id}"') + card_end = html.index("", card_start) + card_html = html[card_start:card_end] + + assert response.status_code == 200 + assert 'data-status="SCHEDULED"' in card_html + assert "data-content-schedule" in card_html + assert "已排期" in card_html + assert "调整排期" in card_html + + +def test_schedule_marker_updates_without_page_reload() -> None: + script = (settings.project_root / "app" / "static" / "js" / "publish-center.js").read_text(encoding="utf-8") + + assert 'row.classList.toggle("is-scheduled", isScheduled)' in script + assert "contentScheduleBadge.hidden = !isScheduled" in script + assert 'isScheduled ? "调整排期" : "加入发布计划"' in script + + def test_dismiss_keeps_files_and_other_platform_and_blocks_recreation(tmp_path: Path) -> None: task_id, _ = _insert_task(tmp_path, "安全移出任务", created_at=_time(-5)) douyin_job, clip_id, clip_path = _insert_clip_job( diff --git a/tests/test_split_services.py b/tests/test_split_services.py index ead4ebb..70aa1ce 100644 --- a/tests/test_split_services.py +++ b/tests/test_split_services.py @@ -15,7 +15,7 @@ from app.services.task_log_service import append_task_log, read_task_log_tail from app.services.task_lifecycle_service import ( create_task_record, - soft_delete_task, + delete_task_permanently, update_task_candidate_clip_count, update_task_status, ) @@ -138,8 +138,8 @@ def test_update_task_status_nonexistent(self): result = update_task_status("nonexistent-id", TaskStatus.completed) assert result is None - def test_soft_delete_task(self): - """软删除任务标记 is_deleted=1""" + def test_permanent_delete_task(self): + """永久删除任务文件并保留隐藏数据库记录""" payload = TaskCreate( task_name="删除测试", source_type="upload", @@ -150,12 +150,12 @@ def test_soft_delete_task(self): ) create_task_record(payload, task_id="test-delete-001") - result = soft_delete_task("test-delete-001") - assert "已隐藏" in result["message"] + result = delete_task_permanently("test-delete-001") + assert result["status"] == "deleted" + assert "永久删除" in result["message"] - # 再次删除应提示无需重复操作 - result2 = soft_delete_task("test-delete-001") - assert "无需重复" in result2["message"] + result2 = delete_task_permanently("test-delete-001") + assert result2["status"] == "already_deleted" def test_update_candidate_clip_count_invalid(self): """候选片段数量超出范围应报错""" diff --git a/tests/test_task_query_service.py b/tests/test_task_query_service.py index 5bc32a3..ebdcaec 100644 --- a/tests/test_task_query_service.py +++ b/tests/test_task_query_service.py @@ -459,7 +459,10 @@ def test_system_status_fields_complete(self): context = get_system_status_context() required_fields = [ - "storage_root", "storage_exists", "database_path", "database_exists", + "storage_root", "storage_exists", "tasks_dir", "tasks_dir_exists", + "upload_temp_dir", "upload_temp_dir_exists", + "publish_export_dir", "publish_export_dir_exists", + "database_path", "database_exists", "ffmpeg_path", "ffmpeg_available", "ffprobe_path", "ffprobe_available", "task_count", "failed_count", "pending_count", "review_count", "completed_count", "recent_errors", "ai_config", "expected_server_url",