Files
vrsub/src/wov_app/routers/batch.py
T
cat-shark d1557eca46 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% 行覆盖率)。
2026-09-03 08:22:42 +08:00

236 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""批量处理 API 路由。
面向本地版用户:输入文件夹路径与所选工作流创建批量任务,直接处理所选
文件夹下的所有视频(**不上传副本**),每个视频的中间态与最终产物都存放在
视频旁的同名文件夹中。支持暂停/继续(重新开始继续上次未完成的内容)、
已处理视频自动跳过与最终产物下载。
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from wov_app import batch as batch_engine
from wov_app.db import Database
from wov_app.schemas import BatchJobCreate
router = APIRouter(tags=["batch"])
def _get_db() -> Database:
"""从 FastAPI 应用状态中延迟获取数据库实例。"""
from wov_app.main import app
return app.state.db
def _get_worker() -> batch_engine.BatchWorker | None:
"""从 FastAPI 应用状态中延迟获取批量引擎实例。"""
from wov_app.main import app
return getattr(app.state, "batch", None)
def _product_finals(video: dict) -> dict[str, str]:
"""列出该视频可下载的最终产物(键为下载 alias,值为文件名)。
来源合并两处:
- 旧版完成标记 `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:
video["finals"] = _product_finals(video)
return videos
@router.post("/api/batch/jobs")
def create_batch_job(
payload: BatchJobCreate,
db: Database = Depends(_get_db),
) -> dict:
"""创建批量处理任务:校验文件夹与工作流,扫描视频并登记明细。"""
try:
job_id = batch_engine.create_job(
db, payload.folder, payload.workflow_id, payload.recursive
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
job = db.get_batch_job(job_id)
job["videos"] = _enrich_videos(db, db.list_batch_videos(job_id))
return job
@router.get("/api/batch/jobs")
def list_batch_jobs(db: Database = Depends(_get_db)) -> list[dict]:
"""返回最近的批量任务列表(不含视频明细,明细按需单独查询)。"""
return db.list_batch_jobs()
@router.get("/api/batch/jobs/{job_id}")
def get_batch_job(job_id: str, db: Database = Depends(_get_db)) -> dict:
"""返回批量任务详情,附带每个视频的处理状态与最终产物清单。"""
job = db.get_batch_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="batch job not found")
job["videos"] = _enrich_videos(db, db.list_batch_videos(job_id))
return job
@router.post("/api/batch/jobs/{job_id}/pause")
def pause_batch_job(
job_id: str,
db: Database = Depends(_get_db),
) -> dict:
"""暂停批量任务:当前 run 在分块/帧边界停下,后续视频不再开始。"""
worker = _get_worker()
if db.get_batch_job(job_id) is None:
raise HTTPException(status_code=404, detail="batch job not found")
if worker is None:
raise HTTPException(status_code=503, detail="batch worker not available")
worker.pause_job(job_id)
return {"id": job_id, "status": "PAUSED"}
@router.post("/api/batch/jobs/{job_id}/resume")
def resume_batch_job(
job_id: str,
db: Database = Depends(_get_db),
) -> dict:
"""继续批量任务:从上次断点(未完成的视频)继续处理。"""
worker = _get_worker()
if db.get_batch_job(job_id) is None:
raise HTTPException(status_code=404, detail="batch job not found")
if worker is None:
raise HTTPException(status_code=503, detail="batch worker not available")
worker.resume_job(job_id)
return {"id": job_id, "status": "QUEUED"}
@router.delete("/api/batch/jobs/{job_id}")
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")
for item in db.list_batch_videos(job_id):
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}
@router.get("/api/batch/jobs/{job_id}/videos/{video_id}/download")
def download_batch_video(
job_id: str,
video_id: str,
alias: str = Query(...),
db: Database = Depends(_get_db),
) -> FileResponse:
"""下载视频的最终产物:解析 alias 对应的文件后返回。
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")
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")
if not target.is_file():
raise HTTPException(status_code=404, detail="artifact file missing")
return FileResponse(target, filename=target.name)
# ---------------------------------------------------------------------------
# 本地目录浏览(目录树选择器)
#
# 浏览器出于安全限制拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录
# 浏览能力:roots 返回可浏览的根(Windows 盘符 / POSIX 根 + 家目录),dirs
# 返回指定目录的直接子目录,前端据此渲染懒加载目录树,点击选择后回填路径。
# ---------------------------------------------------------------------------
@router.get("/api/batch/roots")
def list_batch_roots() -> list[dict]:
"""返回本地文件系统的可浏览根目录列表。
Windows 返回存在的盘符(C:\、D:\…)与家目录;POSIX 返回根目录 / 与
家目录。每个条目为 {"path": 绝对路径, "name": 展示名}。
"""
import os
import string
roots: list[dict] = []
if os.name == "nt":
# Windows:探测 A-Z 盘符,仅返回实际存在的驱动器。
for letter in string.ascii_uppercase:
drive = Path(f"{letter}:\\")
if drive.exists():
roots.append({"path": str(drive), "name": f"{letter}:\\"})
else:
roots.append({"path": "/", "name": "/"})
home = Path.home()
roots.append({"path": str(home), "name": f"家目录({home.name}"})
return roots
@router.get("/api/batch/dirs")
def list_batch_dirs(path: str = Query(...)) -> dict:
"""列出指定目录下的直接子目录,供目录树节点懒加载。
目录不存在、不可读或个别子项无权限时返回空列表(不报 500),前端保持
树可用;隐藏点开头的隐藏目录减少噪音。
"""
target = Path(path).expanduser()
dirs: list[dict] = []
if target.is_dir():
try:
for child in sorted(target.iterdir()):
try:
# 隐藏目录(.git 等)不展示;只列目录,不暴露文件内容。
if child.is_dir() and not child.name.startswith("."):
dirs.append({"path": str(child), "name": child.name})
except OSError:
# 单个子项不可读(权限不足)时跳过,不拖垮整棵树。
continue
except OSError:
# 整个目录不可读(如无权限的系统目录)时返回空列表。
dirs = []
return {"path": str(target), "dirs": dirs}