"""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) # 启动 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()