Files
BiliNote/backend/app/routers/config.py
huangjianwu 41f17592c2 fix(backend): 部署韧性——模型自愈/就绪门禁/全局代理/启动诊断
- whisper: model.bin 截断/损坏时删目录重下重试一次,修「Unable to
  open file model.bin」死循环;mlx 同样按 config.json 判完整性
- /generate_note 加就绪门禁:本地转写引擎模型没下好直接拦截,返回
  reason=transcriber_model_not_ready,不让任务静默卡在首次下载
- 全局代理:新增 ProxyConfigManager(JSON 配置 + HTTP_PROXY env 兜底)
  + build_openai_client,统一注入代理到 LLM/Groq 客户端;yt-dlp 与
  youtube-transcript-api 也走代理
- build_openai_client 校验 api_key 非空,空 key 给「xxx 的 API Key
  未配置」而不是天书般的 Illegal header value b'Bearer '
- universal_gpt: 模型拒绝自定义 temperature(o1/o3/gpt-5 系列)时
  就地去掉参数重试,不消耗重试预算
- connect_test 改用真实 chat completion 而非 /v1/models 探测
- main.py: lifespan 拆 [startup 1/5..5/5] 分段日志 + 异常清晰定位
- /sys_health 重构为结构化返回 {backend,ffmpeg,db,whisper_model}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:01:14 +08:00

385 lines
13 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.
import os
import platform
from pathlib import Path
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing import Optional
from app.utils.response import ResponseWrapper as R
from app.utils.logger import get_logger
from app.utils.path_helper import get_model_dir
from app.services.cookie_manager import CookieConfigManager
from app.services.transcriber_config_manager import TranscriberConfigManager
from ffmpeg_helper import ensure_ffmpeg_or_raise
logger = get_logger(__name__)
router = APIRouter()
cookie_manager = CookieConfigManager()
transcriber_config_manager = TranscriberConfigManager()
class CookieUpdateRequest(BaseModel):
platform: str
cookie: str
@router.get("/get_downloader_cookie/{platform}")
def get_cookie(platform: str):
cookie = cookie_manager.get(platform)
if not cookie:
return R.success(msg='未找到Cookies')
return R.success(
data={"platform": platform, "cookie": cookie}
)
@router.post("/update_downloader_cookie")
def update_cookie(data: CookieUpdateRequest):
cookie_manager.set(data.platform, data.cookie)
return R.success(
)
class TranscriberConfigRequest(BaseModel):
transcriber_type: str
whisper_model_size: Optional[str] = None
AVAILABLE_TRANSCRIBER_TYPES = [
{"value": "fast-whisper", "label": "Faster Whisper本地"},
{"value": "bcut", "label": "必剪(在线)"},
{"value": "kuaishou", "label": "快手(在线)"},
{"value": "groq", "label": "Groq在线"},
{"value": "mlx-whisper", "label": "MLX Whisper仅macOS"},
]
WHISPER_MODEL_SIZES = ["tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"]
@router.get("/transcriber_config")
def get_transcriber_config():
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
config = transcriber_config_manager.get_config()
return R.success(data={
**config,
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
"whisper_model_sizes": WHISPER_MODEL_SIZES,
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
})
@router.post("/transcriber_config")
def update_transcriber_config(data: TranscriberConfigRequest):
config = transcriber_config_manager.update_config(
transcriber_type=data.transcriber_type,
whisper_model_size=data.whisper_model_size,
)
return R.success(data=config)
# ---- 全局代理配置(作用于 LLM API + 转写 API + yt-dlp 下载)----
class ProxyConfigRequest(BaseModel):
enabled: bool
url: Optional[str] = None
@router.get("/proxy_config")
def get_proxy_config():
from app.services.proxy_config_manager import ProxyConfigManager
mgr = ProxyConfigManager()
cfg = mgr.get_config()
# effective 给前端展示「当前实际生效的代理」——可能来自配置,也可能来自 env 兜底
return R.success(data={
**cfg,
"effective": mgr.get_proxy_url() or "",
})
@router.post("/proxy_config")
def update_proxy_config(data: ProxyConfigRequest):
from app.services.proxy_config_manager import ProxyConfigManager
mgr = ProxyConfigManager()
cfg = mgr.update_config(enabled=data.enabled, url=data.url)
return R.success(data={
**cfg,
"effective": mgr.get_proxy_url() or "",
})
# ---- Whisper 模型下载状态 & 下载触发 ----
# 用于跟踪正在进行的下载任务
_downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done" | "failed")
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
"""检查指定 whisper 模型是否已下载完整到本地。
必须 model.bin 落盘才算完成,仅有空目录或半成品不能算「已下载」——
否则监控页会显示绿勾但加载时报「Unable to open file 'model.bin'」。
"""
model_dir = get_model_dir(subdir)
model_path = os.path.join(model_dir, f"whisper-{model_size}")
return (Path(model_path) / "model.bin").exists()
def _check_mlx_whisper_model_exists(model_size: str) -> bool:
"""检查 mlx-whisper 模型是否已下载完整到本地。
与 fast-whisper 的目录布局不同mlx 模型按 HuggingFace repo_id
(如 mlx-community/whisper-tiny-mlx落盘且没有 model.bin
用 config.json 作为「下载完成」的判据,和 mlx_whisper_transcriber.py 保持一致。
"""
try:
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
except Exception:
return False
repo_id = MLX_MODEL_MAP.get(model_size)
if not repo_id:
return False
model_dir = get_model_dir("mlx-whisper")
model_path = os.path.join(model_dir, repo_id)
return (Path(model_path) / "config.json").exists()
@router.get("/transcriber_models_status")
def get_transcriber_models_status():
"""返回所有 whisper 模型的下载状态。"""
statuses = []
for size in WHISPER_MODEL_SIZES:
downloaded = _check_whisper_model_exists(size, "whisper")
download_status = _downloading.get(size)
statuses.append({
"model_size": size,
"downloaded": downloaded,
"downloading": download_status == "downloading",
})
# 也检查 mlx-whisper仅 macOS
mlx_available = platform.system() == "Darwin"
mlx_statuses = []
if mlx_available:
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
for size in WHISPER_MODEL_SIZES:
mlx_key = f"mlx-{size}"
repo_id = MLX_MODEL_MAP.get(size)
# 用 config.json 判定,和 _check_mlx_whisper_model_exists / 加载逻辑保持一致
downloaded = _check_mlx_whisper_model_exists(size)
mlx_statuses.append({
"model_size": size,
"downloaded": downloaded,
"downloading": _downloading.get(mlx_key) == "downloading",
"available": repo_id is not None,
})
return R.success(data={
"whisper": statuses,
"mlx_whisper": mlx_statuses,
"mlx_available": mlx_available,
})
class ModelDownloadRequest(BaseModel):
model_size: str
transcriber_type: str = "fast-whisper" # "fast-whisper" 或 "mlx-whisper"
def _do_download_whisper(model_size: str):
"""后台下载 faster-whisper 模型。"""
from app.transcriber.whisper import MODEL_MAP
from modelscope import snapshot_download
try:
_downloading[model_size] = "downloading"
model_dir = get_model_dir("whisper")
model_path = os.path.join(model_dir, f"whisper-{model_size}")
# 用 model.bin 判定而非目录存在:半成品目录不能算「已下载」
if (Path(model_path) / "model.bin").exists():
_downloading[model_size] = "done"
return
repo_id = MODEL_MAP.get(model_size)
if not repo_id:
_downloading[model_size] = "failed"
return
logger.info(f"开始下载 whisper 模型: {model_size}")
snapshot_download(repo_id, local_dir=model_path)
logger.info(f"whisper 模型下载完成: {model_size}")
_downloading[model_size] = "done"
except Exception as e:
logger.error(f"whisper 模型下载失败: {model_size}, {e}")
_downloading[model_size] = "failed"
def _do_download_mlx_whisper(model_size: str):
"""后台下载 mlx-whisper 模型。"""
key = f"mlx-{model_size}"
try:
_downloading[key] = "downloading"
from huggingface_hub import snapshot_download as hf_download
from app.transcriber.mlx_whisper_transcriber import resolve_mlx_repo_id
try:
repo_id = resolve_mlx_repo_id(model_size)
except ValueError as e:
logger.error(str(e))
_downloading[key] = "failed"
return
model_dir = get_model_dir("mlx-whisper")
model_path = os.path.join(model_dir, repo_id)
# 用 config.json 判定而非目录存在:半成品目录不能算「已下载」
if (Path(model_path) / "config.json").exists():
_downloading[key] = "done"
return
logger.info(f"开始下载 mlx-whisper 模型: {model_size}{repo_id}")
hf_download(repo_id, local_dir=model_path, local_dir_use_symlinks=False)
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
_downloading[key] = "done"
except Exception as e:
logger.error(f"mlx-whisper 模型下载失败: {model_size}, {e}")
_downloading[key] = "failed"
@router.post("/transcriber_download")
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
"""触发后台下载指定的 whisper 模型。"""
if data.model_size not in WHISPER_MODEL_SIZES:
return R.error(msg=f"不支持的模型大小: {data.model_size}")
if data.transcriber_type == "mlx-whisper":
if platform.system() != "Darwin":
return R.error(msg="MLX Whisper 仅支持 macOS")
key = f"mlx-{data.model_size}"
if _downloading.get(key) == "downloading":
return R.success(msg="模型正在下载中")
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
else:
if _downloading.get(data.model_size) == "downloading":
return R.success(msg="模型正在下载中")
background_tasks.add_task(_do_download_whisper, data.model_size)
return R.success(msg="模型下载已开始")
@router.get("/sys_health")
async def sys_health():
"""结构化健康状态——任何子项异常都不应让整个 endpoint 5xx。
每个字段:'ok' | 'missing' | 'error'
前端 useCheckBackend 用 /sys_check 做存活判定(不依赖外部依赖),
/sys_health 用来在设置页区分「后端没起」vs「后端起了但 ffmpeg 缺」vs「DB 写不进去」等更细的状态。
"""
ffmpeg_status = "ok"
try:
ensure_ffmpeg_or_raise()
except Exception:
ffmpeg_status = "missing"
db_status = "ok"
try:
from app.db.engine import engine
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception:
db_status = "error"
# 当前转写器配置 + 模型是否已下载(用 model.bin 落盘判定,与 transcriber 加载逻辑一致)
whisper_info: dict = {"size": None, "type": None, "downloaded": False, "checked": False}
try:
cfg = transcriber_config_manager.get_config()
size = cfg["whisper_model_size"]
ttype = cfg["transcriber_type"]
whisper_info["size"] = size
whisper_info["type"] = ttype
# 只有本地引擎才有「下载」概念groq / bcut / kuaishou 在线引擎跳过
if ttype == "fast-whisper":
whisper_info["downloaded"] = _check_whisper_model_exists(size, "whisper")
whisper_info["checked"] = True
elif ttype == "mlx-whisper":
whisper_info["downloaded"] = _check_mlx_whisper_model_exists(size)
whisper_info["checked"] = True
except Exception:
pass
return R.success(data={
"backend": "ok",
"ffmpeg": ffmpeg_status,
"db": db_status,
"whisper_model": whisper_info,
})
@router.get("/sys_check")
async def sys_check():
"""轻量存活判定:后端进程能响应这个 endpoint 就算「起来了」,不查外部依赖。
给桌面端 useCheckBackend / Tauri ready-probe 用。
"""
return R.success()
@router.get("/deploy_status")
async def deploy_status():
"""返回部署监控所需的所有状态信息。
所有子项都用 try 包起来——监控页本身不应该被任何一个子项打死。
特别是 torch它只在 fast-whisper 路径用得到,用 Groq / 必剪 / 快手在线
引擎的轻量部署完全可以不装,那种情况这个 endpoint 不应该 500。
"""
import os
# CUDA 状态
try:
import torch
cuda_available = torch.cuda.is_available()
cuda_info = {
"available": cuda_available,
"torch_installed": True,
"version": torch.version.cuda if cuda_available else None,
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
}
except Exception:
cuda_info = {
"available": False,
"torch_installed": False,
"version": None,
"gpu_name": None,
}
# Whisper 模型 / 转写器配置 + 本地下载状态
try:
transcriber_cfg = transcriber_config_manager.get_config()
size = transcriber_cfg["whisper_model_size"]
ttype = transcriber_cfg["transcriber_type"]
if ttype == "fast-whisper":
downloaded = _check_whisper_model_exists(size, "whisper")
elif ttype == "mlx-whisper":
downloaded = _check_mlx_whisper_model_exists(size)
else:
downloaded = False # 在线引擎无下载概念
whisper_info = {
"model_size": size,
"transcriber_type": ttype,
"downloaded": downloaded,
}
except Exception:
whisper_info = {"model_size": None, "transcriber_type": None, "downloaded": False}
# FFmpeg 状态
try:
ensure_ffmpeg_or_raise()
ffmpeg_ok = True
except Exception:
ffmpeg_ok = False
return R.success(data={
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
"cuda": cuda_info,
"whisper": whisper_info,
"ffmpeg": {"available": ffmpeg_ok},
})