"""批量处理 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/)的残留; 视频旁已经放置的产物属于用户数据,保留不删。 """ 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}