feat: 批量处理重做——视频旁已有字幕即跳过、产物对齐 CN 命名并清理过程文件

- 创建批量任务时一次性定位视频:视频所在目录存在文件名含视频名的字幕文件
  (.srt/.ass/.ssa/.vtt)直接记 SKIPPED,不触发流水线;运行时只消费已定位
  的明细,不再重新扫描文件夹。
- 视频完成后把最终产物放到视频旁,命名对齐媒体库约定:中文字幕存为
  <视频名>.CN.srt、双目字幕存为 <视频名>.CN_dual_eye.ass;其余扩展名产物
  保留原文件名。
- 收尾删除 run 记录与过程工作空间;工作空间改到应用私有目录
  storage/batch/<job_id>/<bv_id>/,与用户媒体库隔离,防止媒体库把切片数据
  当视频入库。
- 批量 API 产物清单/下载改为解析视频旁字幕文件,旧版 batch.done.json 语义
  别名保持兼容;删除任务时清理私有工作空间。
- 前端说明与创建提示同步;测试按新语义重写并补覆盖(278 passed,100% 行覆盖率)。
This commit is contained in:
2026-09-03 08:22:42 +08:00
parent ec8c8d7dcd
commit d1557eca46
7 changed files with 754 additions and 438 deletions
+47 -17
View File
@@ -33,15 +33,31 @@ def _get_worker() -> batch_engine.BatchWorker | None:
return getattr(app.state, "batch", None)
def _enrich_videos(db: Database, videos: list[dict]) -> list[dict]:
"""为每个视频补充最终产物清单(从同名文件夹的完成标记读取)。
def _product_finals(video: dict) -> dict[str, str]:
"""列出该视频可下载的最终产物(键为下载 alias,值为文件名)。
finals 形如 {别名: 文件名}(如 {"cn_srt": "movie.zh-CN.20260819.srt"}),
前端据此渲染下载链接;未完成的视频没有产物。
来源合并两处:
- 旧版完成标记 `batch.done.json`(位于 work_dir/同名文件夹),键为语义
别名(如 cn_srt/ass),用于兼容旧版批量任务;
- 视频所在目录(视频旁)中**文件名含视频名**的字幕文件,键即文件名。
新版处理完成后产物放到视频旁,靠旁挂字幕文件即可列出与下载。
"""
finals: dict[str, str] = {}
marker = batch_engine.load_marker(Path(video["work_dir"]))
if marker:
finals.update(marker.get("finals") or {})
for sidecar in batch_engine.list_sidecar_subtitles(Path(video["video_path"])):
finals[sidecar.name] = sidecar.name
return finals
def _enrich_videos(db: Database, videos: list[dict]) -> list[dict]:
"""为每个视频补充最终产物清单(视频旁的字幕文件 + 旧版完成标记)。
finals 形如 {alias: 文件名},前端据此渲染下载链接;未完成的视频没有产物。
"""
for video in videos:
marker = batch_engine.load_marker(Path(video["work_dir"]))
video["finals"] = (marker or {}).get("finals") or {}
video["finals"] = _product_finals(video)
return videos
@router.post("/api/batch/jobs")
@@ -111,8 +127,8 @@ def resume_batch_job(
def delete_batch_job(job_id: str, db: Database = Depends(_get_db)) -> dict:
"""删除批量任务:移除任务、明细记录与关联的 run 记录。
磁盘上的同名文件夹与产物属于用户数据,保留不删(与任务页删除接口的
行为不同),只清理数据库记录
只清理数据库记录与应用私有工作空间(storage/batch/<job_id>)的残留;
视频旁已经放置的产物属于用户数据,保留不删
"""
if db.get_batch_job(job_id) is None:
raise HTTPException(status_code=404, detail="batch job not found")
@@ -120,6 +136,8 @@ def delete_batch_job(job_id: str, db: Database = Depends(_get_db)) -> dict:
if item.get("run_id"):
db.delete_run(item["run_id"])
db.delete_batch_job(job_id)
# 清理任务在应用私有存储下的工作空间残留(视频完成后已逐视频清理)。
batch_engine.remove_job_workspace(job_id)
return {"deleted": job_id}
@@ -130,22 +148,34 @@ def download_batch_video(
alias: str = Query(...),
db: Database = Depends(_get_db),
) -> FileResponse:
"""下载视频的最终产物:从同名文件夹的完成标记解析产物文件后返回。
"""下载视频的最终产物:解析 alias 对应的文件后返回。
alias 为工作流 final_outputs 的别名(如 cn_srt / ass);只有完成标记
中记录且文件真实存在的产物才可下载。
alias 解析顺序:
1. 旧版完成标记里的语义别名(如 cn_srt/ass)→ 文件位于 work_dir
2. 视频旁(视频所在目录)文件名含视频名的字幕文件名 → 直接返回该文件。
只有存在且文件真实落盘的产物才可下载。
"""
video = db.get_batch_video(video_id)
if video is None or video["job_id"] != job_id:
raise HTTPException(status_code=404, detail="video not found")
marker = batch_engine.load_marker(Path(video["work_dir"]))
if marker is None or alias not in (marker.get("finals") or {}):
work_dir = Path(video["work_dir"])
marker = batch_engine.load_marker(work_dir)
target: Path | None = None
if marker and alias in (marker.get("finals") or {}):
candidate = work_dir / marker["finals"][alias]
if candidate.is_file():
target = candidate
else:
# 新版/既有字幕:alias 是视频旁的字幕文件名。
for sidecar in batch_engine.list_sidecar_subtitles(Path(video["video_path"])):
if sidecar.name == alias:
target = sidecar
break
if target is None:
raise HTTPException(status_code=404, detail="artifact not found")
path = Path(video["work_dir"]) / marker["finals"][alias]
if not path.is_file():
if not target.is_file():
raise HTTPException(status_code=404, detail="artifact file missing")
return FileResponse(path, filename=path.name)
return FileResponse(target, filename=target.name)
# ---------------------------------------------------------------------------
# 本地目录浏览(目录树选择器)