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:
@@ -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}
|
||||
Reference in New Issue
Block a user