设置页(/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)
199 lines
7.7 KiB
Python
199 lines
7.7 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 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 用配置的 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)
|
||
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()
|