- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码 - faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻) - 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面 - 历史页文件名搜索;缓存定时清理(默认保留7天,可配置) - 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
161 lines
6.0 KiB
Python
161 lines
6.0 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)
|
||
# 缓存清理:启动时跑一次 + 后台定时循环(守护线程,随进程退出)
|
||
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:
|
||
return {"status": "ok"}
|
||
|
||
@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()
|