Initial commit: audio2text 双语字幕生成服务
- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码 - faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻) - 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面 - 历史页文件名搜索;缓存定时清理(默认保留7天,可配置) - 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
This commit is contained in:
7
app/controllers/__init__.py
Normal file
7
app/controllers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""路由聚合:导出各 controller 的 router,供 main.py include。"""
|
||||
|
||||
from .log_router import router as log_router
|
||||
from .task_router import router as task_router
|
||||
from .upload_router import router as upload_router
|
||||
|
||||
__all__ = ["log_router", "task_router", "upload_router"]
|
||||
31
app/controllers/log_router.py
Normal file
31
app/controllers/log_router.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""日志路由:查询内存日志缓冲,供 /logs 页面消费。
|
||||
|
||||
分层语义:
|
||||
- debug:详细(ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度)
|
||||
- info:简略(仅任务阶段转换,如 "任务 N [transcribing 55%]")
|
||||
- warning / error:更详细,error 含完整 traceback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from ..services.log_buffer import get_log_buffer
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
|
||||
@router.get("", summary="查询日志(按级别过滤)")
|
||||
def get_logs(
|
||||
level: str = Query("info", pattern="^(debug|info|warning|error)$",
|
||||
description="最小级别:debug=详细, info=简略, error=仅错误"),
|
||||
tail: int = Query(200, ge=1, le=2000, description="最多返回条数"),
|
||||
) -> dict:
|
||||
records = get_log_buffer().get_records(level=level, tail=tail)
|
||||
return {"level": level, "tail": tail, "count": len(records), "logs": records}
|
||||
|
||||
|
||||
@router.delete("", summary="清空日志缓冲")
|
||||
def clear_logs() -> dict:
|
||||
get_log_buffer().clear()
|
||||
return {"status": "cleared"}
|
||||
88
app/controllers/task_router.py
Normal file
88
app/controllers/task_router.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""任务路由:列表 / 状态 / 下载字幕。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..database import get_db
|
||||
from ..models.task import Task
|
||||
from ..schemas.task import TaskListResponse, TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["task"])
|
||||
|
||||
|
||||
def _to_resp(task: Task) -> TaskResponse:
|
||||
return TaskResponse(
|
||||
id=task.id,
|
||||
filename=task.filename,
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
error=task.error,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TaskListResponse, summary="任务列表")
|
||||
def list_tasks(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
q: str = Query("", description="按文件名模糊搜索(大小写不敏感,匹配子串)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TaskListResponse:
|
||||
q_base = db.query(Task).order_by(Task.id.desc())
|
||||
if q.strip():
|
||||
# SQLite 的 LIKE 默认大小写不敏感(ASCII),ilike 等价于 LIKE
|
||||
like = f"%{q.strip()}%"
|
||||
q_base = q_base.filter(Task.filename.ilike(like))
|
||||
total = q_base.count()
|
||||
tasks = q_base.offset(offset).limit(limit).all()
|
||||
return TaskListResponse(tasks=[_to_resp(t) for t in tasks], total=total)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse, summary="任务状态")
|
||||
def get_task(task_id: int, db: Session = Depends(get_db)) -> TaskResponse:
|
||||
task = db.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"任务不存在:{task_id}")
|
||||
return _to_resp(task)
|
||||
|
||||
|
||||
@router.get("/{task_id}/subtitle", summary="下载字幕")
|
||||
def download_subtitle(
|
||||
task_id: int,
|
||||
type: str = Query("bilingual", pattern="^(bilingual|en|zh)$"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
task = db.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"任务不存在:{task_id}")
|
||||
if task.status != "done":
|
||||
raise HTTPException(409, f"任务尚未完成(当前状态:{task.status})")
|
||||
|
||||
rel = {
|
||||
"bilingual": task.bilingual_srt_path,
|
||||
"en": task.en_srt_path,
|
||||
"zh": task.zh_srt_path,
|
||||
}[type]
|
||||
if not rel:
|
||||
raise HTTPException(404, f"该类型字幕不存在:{type}")
|
||||
|
||||
s = get_settings()
|
||||
path = s.output_dir() / rel
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, f"字幕文件丢失:{path}")
|
||||
|
||||
base = Path(task.filename).stem
|
||||
suffix = "" if type == "bilingual" else f".{type}"
|
||||
download_name = f"{base}{suffix}.srt"
|
||||
return FileResponse(
|
||||
path=path,
|
||||
media_type="application/x-subrip",
|
||||
filename=download_name,
|
||||
)
|
||||
69
app/controllers/upload_router.py
Normal file
69
app/controllers/upload_router.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""分片上传路由:建会话 / 查状态 / 传分片 / complete。
|
||||
|
||||
协议与 server 完全一致,区别仅在 complete 后创建的是转写 Task 而非 UploadedFile。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..schemas.task import (
|
||||
ChunkUploadResponse,
|
||||
CompleteResponse,
|
||||
CreateSessionRequest,
|
||||
CreateSessionResponse,
|
||||
SessionStatusResponse,
|
||||
)
|
||||
from ..services.upload_service import UploadService
|
||||
|
||||
router = APIRouter(prefix="/api/tasks/chunk-uploads", tags=["upload"])
|
||||
|
||||
|
||||
def _service(db: Session = Depends(get_db)) -> UploadService:
|
||||
return UploadService(db)
|
||||
|
||||
|
||||
@router.post("", response_model=CreateSessionResponse, summary="创建分片上传会话")
|
||||
def create_session(
|
||||
body: CreateSessionRequest,
|
||||
service: UploadService = Depends(_service),
|
||||
) -> CreateSessionResponse:
|
||||
return service.create_session(body)
|
||||
|
||||
|
||||
@router.get("/{upload_id}/status", response_model=SessionStatusResponse, summary="查询会话状态(断点续传)")
|
||||
def session_status(
|
||||
upload_id: str,
|
||||
service: UploadService = Depends(_service),
|
||||
) -> SessionStatusResponse:
|
||||
return service.get_status(upload_id)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/chunks/{index}", response_model=ChunkUploadResponse, summary="上传单个分片")
|
||||
async def upload_chunk(
|
||||
upload_id: str,
|
||||
index: int,
|
||||
request: Request,
|
||||
service: UploadService = Depends(_service),
|
||||
) -> ChunkUploadResponse:
|
||||
uploaded = service.write_chunk(upload_id, index, await request.body())
|
||||
return ChunkUploadResponse(upload_id=upload_id, index=index, uploaded_chunks=uploaded)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/complete", response_model=CompleteResponse, summary="完成拼接并创建转写任务")
|
||||
def complete_session(
|
||||
upload_id: str,
|
||||
service: UploadService = Depends(_service),
|
||||
) -> CompleteResponse:
|
||||
"""拼接分片 + 创建转写任务 + 入队管线。
|
||||
|
||||
controller 负责编排:service.complete 只管存储(拼接 + 建 Task),
|
||||
管线触发由 controller 调用,service 不依赖 pipeline(避免循环依赖)。
|
||||
"""
|
||||
resp = service.complete(upload_id)
|
||||
# 仅新建任务时入队(幂等 complete 返回的也是同一 task_id,enqueue 幂等无副作用)
|
||||
from ..services.pipeline import enqueue_task
|
||||
enqueue_task(resp.task_id)
|
||||
return resp
|
||||
Reference in New Issue
Block a user