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:
@@ -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)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地目录浏览(目录树选择器)
|
||||
|
||||
Reference in New Issue
Block a user