- 创建批量任务时一次性定位视频:视频所在目录存在文件名含视频名的字幕文件 (.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% 行覆盖率)。
236 lines
9.3 KiB
Python
236 lines
9.3 KiB
Python
"""批量处理 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}
|