Files
audio2text/app/main.py
audio2text dev 7635e5e766 feat: 设置页调 batch_size + 删除任务 + /docs 去认证 + 离线模式
设置页(/settings):
- 新增 Setting DB 模型(键值存储,持久化运行时覆盖)
- config.py 加 _apply_overrides:get_settings() 合并 DB 覆盖值
  (_applying_overrides 标志防递归:DB 初始化回调 get_settings 时不重入)
- save_setting() 写 DB + 清 lru_cache,后续任务读到新值
- settings_router.py: GET/PUT /api/settings
- settings_html.py: 设置页表单(batch_size/beam_size/sort_by_length)
  + 设备只读信息 + 保存后 reload 确认
- 验证:改 asr_batch_size=16 beam_size=1 -> 任务 ASR 日志确认生效
- 验证:重启容器后设置从 DB 恢复(持久化)

删除任务:
- task_router.py: DELETE /api/tasks/{id},仅 done/failed 可删
  删字幕/音频/视频产物 + UploadSession + Task DB 记录
- home_html.py + history_html.py: done/failed 任务显示删除按钮
  + confirm 确认 + 调用 DELETE API + 刷新列表
- 验证:删除 task 15 成功,删除进行中 task 返回 409

/docs 去认证:
- 移除 require_docs_auth 依赖,/docs /redoc /openapi.json 直接公开

离线模式:
- Dockerfile dev/final 加 HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
  (模型已缓存在 ./models volume,无需联网验证)
- 验证:离线模式完整跑通 ASR+翻译(task 15 done)
2026-07-11 11:33:34 +08:00

199 lines
7.7 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 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, settings_router, task_router, upload_router
from .database import get_db, init_db_schema
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
from .views.settings_html import render as render_settings_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)
app.include_router(settings_router)
# 文档接口(无认证,直接公开)
@app.get("/openapi.json")
def openapi_endpoint() -> JSONResponse:
return JSONResponse(app.openapi())
@app.get("/docs")
def docs_endpoint():
return get_swagger_ui_html(
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
)
@app.get("/redoc")
def redoc_endpoint():
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_beam_size"] = s.asr.beam_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())
@app.get("/settings", response_class=HTMLResponse)
def settings_page() -> HTMLResponse:
return HTMLResponse(render_settings_html())
return app
app = create_app()