Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ AI_LOCAL_FALLBACK_PROTOCOL=
AI_LOCAL_HEALTH_TIMEOUT_SECONDS=30

# 发送中心 opencli
# Docker 主页面固定使用 8001;Windows opencli 辅助服务由 scripts/start_docker_opencli.ps1 启动
# Docker 主页面固定使用 8001;Windows opencli 辅助服务推荐双击 start_niuma_studio_docker.cmd 一起启动
OPENCLI_LOCAL_BASE_URL=http://127.0.0.1:8001
OPENCLI_HOST_BRIDGE_URL=http://host.docker.internal:8765

Expand Down
22 changes: 22 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Development Log

## 2026-07-09 抖音优先发送中心与 Docker opencli 启动体验
- 发送中心当前范围收敛为抖音优先:刷新队列、页面队列、统计和发送记录只展示抖音任务;B站相关后端能力保留,但暂不显示在发送中心。
- 待发送队列从平铺卡片改为按创建任务折叠分组,默认展开第一期;任务组摘要显示任务名、切片数量、状态和“拖到日期”手柄,便于按一期一期管理。
- 页面移除 B站筛选、B站投稿字段和 B站预览说明;发送确认文案只提醒检查抖音创作者中心登录态。
- 新增 `start_niuma_studio_docker.cmd` 双击启动器,并拆出 `scripts/start_opencli_host_bridge.ps1`;`scripts/start_docker_opencli.ps1` 现在复用独立辅助服务脚本后再刷新 Docker。
- 新增 `scripts/install_opencli_helper_autostart.ps1`,可选地把 Windows opencli 辅助服务设置为当前用户登录后自启动;文档明确说明 Docker 容器不能直接启动 Windows Chrome,自动发送仍需宿主机辅助服务。
- 静态资源版本更新为 `20260709-douyin-task-groups`,并同步更新 README、部署文档、项目指南、UI 参考和测试脚本。

## 2026-07-02 发送中心日期表与任务卡片管理
- 发送中心排期区改为 7 天“发送日期表”,只按日期列展示,不再生成时间轴 / 小时格;支持上一周、本周、下一周切换。
- 待发送队列改为按创建任务分组,每张大卡片代表一个创建任务,卡片内保留抖音 / B站和各条切片的发送内容编辑、封面帧、保存和发送操作。
- 任务卡顶部新增“拖到日期”手柄,只拖动任务组本身,避免视频、输入框和按钮干扰拖拽;同时保留选择日期后点击“安排到这一天”的兜底操作。
- 新增 `PATCH /api/publish/jobs/schedule-date`,用于把任务组下可排期条目安排到指定日期;不新增数据库字段,仍写入现有 `publish_jobs.scheduled_at`。
- 静态资源版本更新为 `20260702-publish-date-board`,确保 Docker 页面刷新后加载新的 CSS / JS。

## 2026-07-01 抖音已排期任务手动立即发送修复
- 修复发送中心“发送此条”对 `SCHEDULED` 抖音任务没有启动 opencli 的问题:用户显式点击单条发送或勾选后批量发送时,已排期任务会按本次手动操作立即进入 opencli 发送队列。
- 保留“开始发送全部”的安全边界:未勾选任何任务时,只会发送 `WAITING` / `FAILED` 的 opencli 任务,不会把未来排期任务全部提前发送。
- 发送中心前端新增更清晰的确认文案和错误反馈:已排期任务会提示“确认后立即发送,不再等待原计划时间”;空队列、opencli 缺失或发送队列忙碌时会关闭发布遮罩并恢复按钮。
- 静态资源版本更新为 `20260701-douyin-scheduled-send`,确保浏览器加载新的发送中心脚本。
- 新增测试覆盖:显式发送 `SCHEDULED` opencli 任务、未勾选批量不发送未来排期、`WAITING` / `FAILED` 任务仍可批量启动。

## 2026-06-25 全自动切片修复与发送中心批量排期
- 修复 AI 分析完成后候选片段“先写入、随后又被全部删除”的事务顺序错误;候选片段现在在单个 SQLite 事务内替换,任一新片段写入失败都会自动回滚并保留旧结果。
- 修复历史全自动任务卡在 AI 分析后的问题:任务可从最近一次 AI 分析历史恢复候选片段,并从自动选片阶段继续,不需要重新消耗一次 AI 分析。
Expand Down
29 changes: 29 additions & 0 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Next Steps

## 2026-07-09 抖音优先发送中心怎么测试
1. 先确认 Docker Desktop 已经打开。
2. 在项目根目录双击 `start_niuma_studio_docker.cmd`,它会启动 Windows opencli 辅助服务、刷新 Docker,并打开 `http://127.0.0.1:8001/publish`。
3. 页面打开后按 `Ctrl + F5` 强制刷新一次,确认加载 `20260709-douyin-task-groups` 版资源。
4. 点击“刷新抖音队列”,确认发送中心只出现抖音任务;不应再看到 B站筛选按钮、B站分区、B站声明或 B站投稿预览。
5. 在“按创建任务分组”区域检查:同一个创建任务生成的多个视频应收在同一组里;默认展开第一组,其他组可以点任务标题展开/收起。
6. 需要排期时,把任务组顶部“拖到日期”拖进日期列;如果拖不动,就在组内选择兜底日期并点“安排这一期到这一天”。
7. 发送前只需要确认 Chrome 已登录抖音创作者中心;当前不要测试 B站发送,等抖音流程稳定后再恢复 B站显示。
8. 如果页面提示 opencli 辅助服务未连接,仍可整理队列和排期;自动发送前重新双击 `start_niuma_studio_docker.cmd`。

## 2026-07-02 发送中心日期表怎么测试
1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。
2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确认页面加载 `20260702-publish-date-board` 版资源。
3. 在“发送日期表”里查看 7 天日期列;可以点“上一周 / 本周 / 下一周”切换时间范围。
4. 下方“待发送队列”应该按创建任务显示为大卡片,每张任务卡里再展示抖音 / B站和切片发送条目。
5. 拖动任务卡顶部的“拖到日期”按钮到某一天;不要拖视频、输入框或发送按钮。
6. 正常情况:页面会提示正在安排任务,随后刷新;该任务卡里的可排期条目应显示对应日期的计划时间。
7. 如果拖不动,在任务卡顶部选择“兜底日期”,再点“安排到这一天”。
8. 如果需要一次性批量排期,仍可勾选具体发送条目后使用“应用发布计划”;已发布任务不能重复发送。

## 2026-07-01 抖音发送按钮修复后怎么测试
1. 在项目目录运行 `.\scripts\start_docker_opencli.ps1`,让 Docker 页面和 Windows opencli 辅助服务都启动。
2. 打开 `http://127.0.0.1:8001/publish`,按 `Ctrl + F5` 强制刷新一次,确保加载 `20260701-douyin-scheduled-send` 版脚本。
3. 在发送中心点击“抖音”筛选,找一条状态为“待发送 / 定时”的抖音任务。
4. 点击这条任务的“发送此条”;如果它已经有发布时间,确认框会提示本次会立即发送,不再等待原计划时间。
5. 正常情况:页面会出现“正在发布”提示,Chrome / opencli 会打开抖音创作者页面并开始填写内容。
6. 如果未勾选任何任务就点“开始发送全部”,系统只会发送等待处理或发送失败任务,不会提前发送未来排期任务。
7. 如果页面提示 opencli 缺失、队列为空或正在发送中,按钮会恢复可点状态;按提示启动辅助服务或稍后重试即可。

## 2026-06-25 全自动流程修复后怎么测试
1. 打开 `http://127.0.0.1:8001/tasks/new`,按 `Ctrl + F5` 强制刷新一次。
2. 选择视频,填写“单条切片最长”和“候选片段数量”,勾选“新建后自动跑完整流水线”。
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ v1.4.0 已实现定时发送与自动发布执行器:系统会按 `publish_job
- 视频处理:已接入 FFmpeg / FFprobe,用于音频提取、切片、封面帧和字幕成片。
- 转写:支持火山引擎远程转写和本地 faster-whisper。
- AI 分析:支持远程 OpenAI-compatible / DeepSeek 和本地 Ollama;长视频会按小段分析再合并候选片段。
- 发送中心:支持生成抖音 / B站待发送队列、AI 标题 / 简介 / 话题、候选封面帧,并通过 opencli 调用已登录 Chrome 辅助投稿。
- 发送中心:当前优先生成和管理抖音待发送队列,支持 AI 标题 / 简介 / 话题、候选封面帧,并通过 opencli 调用已登录 Chrome 辅助投稿;B站能力先保留在代码里,暂不显示在发送中心主流程
- 安全边界:不会绕过验证码、登录失效、平台风控或人工确认;不会保存账号密码、cookie 或真实 API Key。
- 配置安全:真实 `.env` 已被 Git 忽略,不会提交真实 API Key。
- 品牌说明:当前页面主名为“牛马片场”,英文代号为 `NiuMa Studio`,Docker 技术名为 `niuma-studio`。
Expand Down Expand Up @@ -121,7 +121,14 @@ pytest --cov=app --cov-report=term-missing

## Docker 启动

推荐启动方式:Docker 一键启动。
推荐启动方式:双击项目根目录的 `start_niuma_studio_docker.cmd`。

这个启动器会同时做两件事:

- 启动 Windows opencli 辅助服务,用来控制你已经登录的 Windows Chrome。
- 刷新 Docker 服务并打开 `http://127.0.0.1:8001/publish`。

如果你只想启动 Docker 页面、不使用发送中心自动发送,也可以手动运行:

```powershell
docker compose up --build
Expand All @@ -144,6 +151,7 @@ Docker 启动说明:
- `.env` 文件会被自动加载(如果存在)
- 存储目录 `E:\直播间切片工作流存储` 会自动挂载到容器内
- 代码目录和 prompts 目录以 volume 方式挂载,支持热更新
- Docker 容器不能直接启动 Windows Chrome;抖音自动发送需要 Windows opencli 辅助服务,所以推荐使用根目录双击启动器。

---

Expand Down
14 changes: 14 additions & 0 deletions app/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ def validate_schedule_job_ids(cls, value: list[str]) -> list[str]:
return normalized


class PublishDateScheduleUpdate(BaseModel):
job_ids: list[str] = Field(default_factory=list)
target_date: str = Field(..., min_length=10, max_length=10)
start_time: str = Field(default="09:00", min_length=5, max_length=5)
interval_hours: int = Field(default=3, ge=1, le=168)

@validator("job_ids")
def validate_date_schedule_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)
Expand Down
14 changes: 14 additions & 0 deletions app/routers/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
PublishBatchScheduleUpdate,
PublishCoverCreate,
PublishCoverFrameBatchCreate,
PublishDateScheduleUpdate,
PublishJobContentUpdate,
PublishJobCreate,
PublishJobScheduleUpdate,
Expand Down Expand Up @@ -234,6 +235,19 @@ async def update_publish_jobs_schedule_batch(payload: PublishBatchScheduleUpdate
raise HTTPException(status_code=400, detail=str(exc)) from exc


@router.patch("/jobs/schedule-date")
async def update_publish_jobs_schedule_date(payload: PublishDateScheduleUpdate) -> dict:
try:
return PublishScheduler().update_date_schedule(
payload.job_ids,
target_date=payload.target_date,
start_time=payload.start_time,
interval_hours=payload.interval_hours,
)
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:
Expand Down
83 changes: 83 additions & 0 deletions app/services/publish_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ def build_batch_schedule_times(
return scheduled


def build_date_schedule_times(
count: int,
*,
target_date: str,
start_time: str,
interval_hours: int,
) -> list[str]:
if count <= 0:
return []

clock = parse_clock(start_time, "当天开始时间")
try:
cursor = datetime.fromisoformat(f"{target_date.strip()}T{clock.isoformat(timespec='minutes')}")
except ValueError as exc:
raise ValueError("目标日期格式无效,请使用 YYYY-MM-DD") from exc
if cursor.tzinfo is None:
cursor = cursor.astimezone()

interval = timedelta(hours=max(1, int(interval_hours)))
return [(cursor + interval * index).isoformat(timespec="seconds") for index in range(count)]


def _row_to_dict(row) -> dict[str, Any] | None:
return dict(row) if row else None

Expand Down Expand Up @@ -372,6 +394,67 @@ def update_batch_schedule(
"jobs": [get_publish_job_raw(job_id) for job_id in normalized_ids],
}

def update_date_schedule(
self,
job_ids: list[str],
*,
target_date: str,
start_time: str = "09:00",
interval_hours: int = 3,
) -> dict[str, Any]:
normalized_ids = list(dict.fromkeys(str(job_id).strip() for job_id in job_ids if str(job_id).strip()))
if not normalized_ids:
raise ValueError("至少选择一条发布任务")

placeholders = ", ".join("?" for _ in normalized_ids)
with get_connection() as connection:
rows = connection.execute(
f"SELECT * FROM publish_jobs WHERE id IN ({placeholders})",
normalized_ids,
).fetchall()
jobs_by_id = {row["id"]: dict(row) for row in rows}
missing_ids = [job_id for job_id in normalized_ids if job_id not in jobs_by_id]
if missing_ids:
raise ValueError(f"有 {len(missing_ids)} 条发布任务不存在")
blocked = [
job_id
for job_id in normalized_ids
if str(jobs_by_id[job_id].get("status") or "").upper() in {"PUBLISHED", "CANCELLED"}
]
if blocked:
raise ValueError("已发布或已取消的任务不能修改排期")

schedule_times = build_date_schedule_times(
len(normalized_ids),
target_date=target_date,
start_time=start_time,
interval_hours=interval_hours,
)

now = now_iso()
with get_connection() as connection:
for job_id, scheduled_at in zip(normalized_ids, schedule_times, strict=True):
current_status = str(jobs_by_id[job_id].get("status") or "").upper()
next_status = "NEED_REVIEW" if current_status == "NEED_REVIEW" else "SCHEDULED"
connection.execute(
"""
UPDATE publish_jobs
SET scheduled_at = ?, status = ?, updated_at = ?,
error_code = '', error_message = '', last_error = ''
WHERE id = ?
""",
(scheduled_at, next_status, now, job_id),
)
connection.commit()

return {
"status": "ok",
"action": "schedule_date",
"updated_count": len(normalized_ids),
"message": f"已把 {len(normalized_ids)} 条任务安排到 {target_date}。",
"jobs": [get_publish_job_raw(job_id) for job_id in normalized_ids],
}

def _set_schedule_to_now(self, job_id: str) -> None:
now = now_iso()
with get_connection() as connection:
Expand Down
Loading
Loading