- scheduler: ffmpeg 异步线程 + GPU 串行调度 + 模型复用(2N→2 次加载) - pipeline: 阶段拆分(extract/asr/translate),中间数据存 Task 字段 - translate_service: 长度排序批处理,padding 浪费减少 91% - model_manager: ASR/翻译不共驻,BatchedInferencePipeline 批量解码 - 日志分级: INFO=任务流转里程碑,DEBUG=进度详情;默认 INFO - 前端: 日志最新在上+滚动感知+退避轮询;24h 时间;上传中状态显示 - /health: 返回完整 Whisper/NLLB 配置 - upload_service: 单事务 complete + 扩展名白名单 - task_router: 合并 UploadSession 虚拟任务到列表 - Dockerfile: CPU/GPU 独立构建链,deps 缓存稳定 - prefetch_models: 安装时预下载模型权重
193 lines
7.5 KiB
Python
193 lines
7.5 KiB
Python
"""FastAPI 应用工厂(对齐 server/main.py 风格)。
|
||
|
||
路由概览:
|
||
GET / -> 版本号
|
||
GET /health -> 存活探针
|
||
GET /logs -> 实时日志页(公开)
|
||
GET /docs -> Swagger UI(Basic Auth)
|
||
POST /api/tasks/chunk-uploads/... -> 分片上传(建会话/查状态/传片/complete)
|
||
GET /api/tasks -> 任务列表
|
||
GET /api/tasks/{id} -> 任务状态
|
||
GET /api/tasks/{id}/subtitle -> 下载字幕
|
||
GET /api/logs -> 查询日志(按级别过滤)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import threading
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import Depends, FastAPI
|
||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||
from fastapi.responses import HTMLResponse, JSONResponse
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .config import get_settings
|
||
from .controllers import log_router, task_router, upload_router
|
||
from .database import get_db, init_db_schema
|
||
from .security import require_docs_auth
|
||
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
|
||
from .services.log_buffer import init_log_buffer
|
||
from .services.reaper import reap_stale_sessions
|
||
from .views.history_html import render as render_history_html
|
||
from .views.home_html import render as render_home_html
|
||
from .views.logs_html import render as render_logs_html
|
||
|
||
# 日志分层:
|
||
# - audio2text logger 始终设 DEBUG,确保所有记录(含子步骤)都能产生。
|
||
# - 控制台 handler 用配置的 level:config=info 时控制台只显示简略。
|
||
# - 内存 handler 始终 DEBUG(全收),/api/logs?level= 查询时按参数过滤可见级别。
|
||
# 这样 /logs 页面切到 DEBUG 能看到详细,而控制台仍按 config.level 简略输出。
|
||
_LOG_LEVEL_MAP = {"debug": logging.DEBUG, "info": logging.INFO,
|
||
"warning": logging.WARNING, "error": logging.ERROR}
|
||
_console_level = _LOG_LEVEL_MAP.get(get_settings().logging.level, logging.INFO)
|
||
logging.basicConfig(
|
||
level=logging.DEBUG,
|
||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
)
|
||
# 控制台 handler(basicConfig 安装在 root)按配置级别过滤
|
||
for _h in logging.getLogger().handlers:
|
||
_h.setLevel(_console_level)
|
||
logger = logging.getLogger("audio2text")
|
||
logger.setLevel(logging.DEBUG)
|
||
|
||
# 内存日志缓冲:捕获所有 audio2text.* 日志,供 /logs 页面查询。
|
||
# handler 级别设为 DEBUG(最低),由 /api/logs 查询时按 level 参数过滤。
|
||
_memory_handler = init_log_buffer(get_settings().logging.buffer_size)
|
||
_memory_handler.setLevel(logging.DEBUG)
|
||
logger.addHandler(_memory_handler)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
try:
|
||
init_db_schema()
|
||
logger.info("SQLite 表已就绪。")
|
||
except Exception as exc: # pragma: no cover
|
||
logger.error("初始化数据库失败:%s", exc)
|
||
logger.info(
|
||
"audio2text 启动:ASR=%s/%s,翻译=%s",
|
||
get_settings().asr.model, get_settings().asr.device, get_settings().translation.device,
|
||
)
|
||
# 启动时清理一次被放弃的分片会话
|
||
try:
|
||
await asyncio.to_thread(reap_stale_sessions)
|
||
except Exception as exc: # pragma: no cover
|
||
logger.warning("启动 reaper 失败:%s", exc)
|
||
# 启动 GPU 调度线程(常驻,串行处理 ASR+翻译,模型复用)
|
||
try:
|
||
from .services.scheduler import start_scheduler
|
||
start_scheduler()
|
||
except Exception as exc: # pragma: no cover
|
||
logger.warning("启动 GPU 调度线程失败:%s", exc)
|
||
# 缓存清理:启动时跑一次 + 后台定时循环(守护线程,随进程退出)
|
||
s = get_settings()
|
||
try:
|
||
await asyncio.to_thread(purge_expired_cache)
|
||
except Exception as exc: # pragma: no cover
|
||
logger.warning("启动缓存清理失败:%s", exc)
|
||
try:
|
||
t = threading.Thread(
|
||
target=run_cache_cleaner,
|
||
args=(s.storage.cache_cleanup_interval_hours,),
|
||
name="cache-cleaner",
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
logger.info(
|
||
"缓存清理调度已启动:保留期 %d 天,间隔 %d 小时。",
|
||
s.storage.cache_retention_days, s.storage.cache_cleanup_interval_hours,
|
||
)
|
||
except Exception as exc: # pragma: no cover
|
||
logger.warning("启动缓存清理调度失败:%s", exc)
|
||
yield
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
s = get_settings()
|
||
app = FastAPI(
|
||
title="audio2text",
|
||
description=(
|
||
"音频/视频转双语字幕服务。\n\n"
|
||
"- 上传视频 → ffmpeg 提取音频 → faster-whisper 识别英语 → 断句+时间戳重算\n"
|
||
" → NLLB 翻译为中文 → 双语 SRT\n\n"
|
||
"模型不共驻:ASR 与翻译分阶段加载,翻译时独占显存跑大 batch。\n"
|
||
"/docs 需 Basic Auth,凭据见 config.yaml 的 docs 段。"
|
||
),
|
||
version="1.0.0",
|
||
docs_url=None,
|
||
redoc_url=None,
|
||
openapi_url=None,
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
app.include_router(upload_router)
|
||
app.include_router(task_router)
|
||
app.include_router(log_router)
|
||
|
||
# 受 Basic Auth 保护的文档接口
|
||
@app.get("/openapi.json")
|
||
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
|
||
return JSONResponse(app.openapi())
|
||
|
||
@app.get("/docs")
|
||
def protected_docs(_: str = Depends(require_docs_auth)):
|
||
return get_swagger_ui_html(
|
||
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
|
||
)
|
||
|
||
@app.get("/redoc")
|
||
def protected_redoc(_: str = Depends(require_docs_auth)):
|
||
return get_redoc_html(
|
||
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
|
||
)
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def home_page() -> HTMLResponse:
|
||
return HTMLResponse(render_home_html())
|
||
|
||
@app.get("/health")
|
||
def health() -> dict:
|
||
"""存活探针 + 设备信息。
|
||
|
||
返回 torch 版本、cuda 可用性、GPU 名称、配置的 device,
|
||
便于一眼区分 CPU/GPU 容器是否正确调度到对应硬件。
|
||
torch 导入失败时(理论上不会,因为镜像已装 torch)降级为仅 status。
|
||
"""
|
||
info: dict = {"status": "ok"}
|
||
try:
|
||
import torch
|
||
info["torch"] = torch.__version__
|
||
info["cuda_available"] = torch.cuda.is_available()
|
||
if torch.cuda.is_available():
|
||
info["gpu"] = torch.cuda.get_device_name(0)
|
||
info["gpu_count"] = torch.cuda.device_count()
|
||
except Exception as e: # pragma: no cover
|
||
info["torch_error"] = str(e)
|
||
s = get_settings()
|
||
info["asr_device"] = s.asr.device
|
||
info["asr_model"] = s.asr.model
|
||
info["asr_compute_type"] = s.asr.compute_type
|
||
info["asr_batch_size"] = s.asr.batch_size
|
||
info["asr_language"] = s.asr.language
|
||
info["translation_device"] = s.translation.device
|
||
info["translation_model"] = s.translation.model
|
||
info["translation_batch_size"] = s.translation.batch_size
|
||
info["translation_sort_by_length"] = s.translation.sort_by_length
|
||
return info
|
||
|
||
@app.get("/history", response_class=HTMLResponse)
|
||
def history_page() -> HTMLResponse:
|
||
return HTMLResponse(render_history_html())
|
||
|
||
@app.get("/logs", response_class=HTMLResponse)
|
||
def logs_page() -> HTMLResponse:
|
||
return HTMLResponse(render_logs_html())
|
||
|
||
return app
|
||
|
||
|
||
app = create_app()
|