Initial commit: audio2text 双语字幕生成服务

- 音频/视频转双语(英/中)SRT 字幕,Docker 容器化,CPU 开发/GPU 生产同一份代码
- faster-whisper ASR(词级时间戳) + 断句时间戳重算 + NLLB 翻译(模型不共驻)
- 分片上传(断点续传) + SQLite 持久化 + 主页/历史/日志页面
- 历史页文件名搜索;缓存定时清理(默认保留7天,可配置)
- 双 Dockerfile(cpu/gpu) + setup/start/stop 脚本
This commit is contained in:
2026-07-06 06:54:19 +00:00
commit 00e2a95fb7
44 changed files with 4110 additions and 0 deletions

160
app/main.py Normal file
View File

@@ -0,0 +1,160 @@
"""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)
# 缓存清理:启动时跑一次 + 后台定时循环(守护线程,随进程退出)
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()