Files
audio2text/app/main.py
audio2text dev 73110848f4 feat: 调度器+并发管线+GPU优化+日志分级+前端修复
- 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: 安装时预下载模型权重
2026-07-06 21:59:59 +08:00

193 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FastAPI 应用工厂(对齐 server/main.py 风格)。
路由概览:
GET / -> 版本号
GET /health -> 存活探针
GET /logs -> 实时日志页(公开)
GET /docs -> Swagger UIBasic 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 用配置的 levelconfig=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",
)
# 控制台 handlerbasicConfig 安装在 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()