feat: 文件夹批量处理引擎(后端)

- BatchWorker 单线程轮询 batch_jobs 表,处理 source=batch 的运行,
  与主调度器互不抢占(next_queued_run 排除 batch 来源)
- 直接读取用户所选文件夹下的视频逐个执行流水线,不上传到工作目录;
  中间态与产物落在视频旁同名文件夹,batch.done.json 完成标记去重
- 支持暂停/继续、失败容错(单视频失败不阻塞后续)、删除任务只清库
- 孤儿清理跳过 source=batch 运行,防止误删用户视频文件夹
- workflow_runs 新增 source 列(upload/batch),旧库自动迁移
This commit is contained in:
2026-08-23 16:25:09 +08:00
parent b16e0e9f3c
commit 711867e79f
10 changed files with 2056 additions and 9 deletions
+205
View File
@@ -0,0 +1,205 @@
"""批量处理 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 _enrich_videos(db: Database, videos: list[dict]) -> list[dict]:
"""为每个视频补充最终产物清单(从同名文件夹的完成标记读取)。
finals 形如 {别名: 文件名}(如 {"cn_srt": "movie.zh-CN.20260819.srt"}),
前端据此渲染下载链接;未完成的视频没有产物。
"""
for video in videos:
marker = batch_engine.load_marker(Path(video["work_dir"]))
video["finals"] = (marker or {}).get("finals") or {}
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 记录。
磁盘上的同名文件夹与产物属于用户数据,保留不删(与任务页删除接口的
行为不同),只清理数据库记录。
"""
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)
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 为工作流 final_outputs 的别名(如 cn_srt / ass);只有完成标记
中记录且文件真实存在的产物才可下载。
"""
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 {}):
raise HTTPException(status_code=404, detail="artifact not found")
path = Path(video["work_dir"]) / marker["finals"][alias]
if not path.is_file():
raise HTTPException(status_code=404, detail="artifact file missing")
return FileResponse(path, filename=path.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}