2740 字
14 分钟
一个晚上:把家里的服务注册表合进主平台,把烤肉流水线做成常驻服务,顺带查清 YouTube 403 的真正死因

昨晚十一点多决定收拾一下家里的 homelab。起因很小:我有两套服务注册表在并行跑,干的是一模一样的事,想合成一套。

一套在我那台正经 API 平台(代号 Project Hail Mary,下面简称 phm)里,是 components/registry,FastAPI 跑在 8002 端口,SQLite 存储,带 ACL、M2M 令牌、心跳、审计,功能齐全。另一套是个独立的小注册表,功能简单,就是个让服务自报到的目录。

整晚做完三件事:注册表合并、烤肉流水线服务化、以及一条端到端调通过程中最磨人的 YouTube 403 排查。下面按顺序记。

第一个坑:注册表根本不在我以为的机器上#

我最初让它去合并「mini 上那个注册表」。查下来发现:那东西根本不在 Mac mini 上,在树莓派上。

它通过一个 Tailscale 的 VIP 服务对外,内部代理到本机 8070:

service.<tailnet>.ts.net → 127.0.0.1:8070

没有鉴权,还自带一个 FastMCP 接口给 AI agent 查服务。是我自己记错了机器。这种事值得记一笔——动手前先确认目标在哪台机器上,比改完发现搞错了机器再回滚省事得多。

合并注册表:把 pi 那套的能力并进 phm#

pi 那套有三个有用的 MCP 只读工具:list_servicesdiscoverget_service_info。把它们移植进 phm 的 registry,改成查 SQLite 而不是查内存。同时给 REST 侧补上对应端点:

GET /api/services/discover/{capability} # 按能力发现服务
GET /health # 健康检查

再给服务表加一个 metadata 列(additive,不破坏老数据)。MCP 挂载点用一个 ASGI 中间件 MCPAuthGuard 套上和 REST 读端点同一套鉴权——不能因为加了 MCP 入口就开了一个无鉴权的旁门。

这里踩了一个坑。mcp==1.27.2 默认开启 DNS-rebinding 保护,只认 loopback 的 Host header,经反向代理用域名访问 streamable-http MCP 会直接返回:

HTTP/1.1 421 Misdirected Request

421 是「服务器认为这个请求发错了地方」。MCP SDK 在 1.x 引入了这层保护,配置类是 TransportSecuritySettings,得显式把允许的 host 加进白名单:

from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(
allowed_hosts=["registry.example.com", "registry.example.com:*", "127.0.0.1:*"],
)

加上之后域名访问才通。这是个安全特性不是 bug——它防的是 DNS rebinding 攻击,但自托管经反代时必须手动放行自己的域名。

部署走 bootstrap/09-registry-init.sh。这脚本最后一步是:

Terminal window
systemctl enable --now registry.service

enable --now 对一个已经在跑的服务不会重启,只会确保它开机自启 + 当前在跑。结果是新代码部署了但旧进程还在跑,改动没生效。得手动补一刀:

Terminal window
systemctl restart registry.service

合并完成后,把依赖方切过来。mini 上的 asr 和 tts 两个服务原来往 pi 报到,改成往新地址注册,走 registry 现成的 TOFU(Trust On First Use)X-Service-Secret 路径:

https://registry.example.com # asr-mac-mini / tts-mac-mini 重新注册

确认两个服务在新注册表里 healthy、可被 discover 之后,把树莓派那套停掉、写一个 DEPRECATED.md 立碑,清掉它的 tailscale serve 和 VIP。service.<tailnet>.ts.net 从此是死地址。

烤肉:把手动流水线固化成常驻服务#

先解释一句「烤肉」:把 YouTube 视频下下来,机器翻译配中文字幕,再传到 B 站。圈里叫烤肉,生肉烤成熟肉。这套流水线我攒了挺久,一直命令行手动跑,这次想固化成常驻服务,配个网页面板,再开一个 LLM 能调的 REST 端点。

打开仓库发现一个情况:烤肉有两套实现并存。我这边写了一套基于单文件 jobs.py 的版本,另一个 session 在 mini 上写了一套基于 queue/ 的多阶段队列(cli / daemon / db / stages / worker 分层),而且是验证过、能跑通的。

两套撞在一起,得选一个当底座。结论是保留验证过的 queue/daemon 那套——能跑通的代码比结构更漂亮的代码值钱。它的设计是 SQLite 里一张 jobs 表,任务在几个阶段间流转:

pending → downloaded → translated → burned → uploaded

每个阶段一个 worker。我在它上面加了新的一层:REST API(含 /openapi.json 给 LLM 读)、网页面板、鉴权、以及任务完成后的清理。

这台 mini 最终跑三个 launchd 常驻服务:

app.example.kaorou.api # REST + 网页面板,127.0.0.1:8096
app.example.kaorou.daemon # 队列各阶段 worker
app.example.kaorou.potoken # 下面会讲到的 PO-Token 服务,:4416

全部 RunAtLoad + KeepAlive,开机自启、崩溃自恢复。

内网面板的登录:cookie 跨不了 apex 域#

面板我只想让它待在内网,通过 tailscale serve 暴露:

https://mac-mini.<tailnet>.ts.net → 127.0.0.1:8096

tailscale serve 只在 tailnet 内可达(要公网得用 tailscale funnel,是另一个命令),符合「仅内部服务」的要求。

但登录卡了一会儿。我那套 GitHub 登录种的 cookie 叫 phm_jwt,是 HttpOnly 的、域是 .example.com。浏览器不会把它发给一个 .ts.net 的页面;而且一个 apex 域的响应根本没法给另一个 apex 域种 cookie,浏览器直接拒。这是同源策略的硬约束,绕不过去。

我不想在面板里手动粘 token——那很蠢。最后的解法是在认证服务这头加一个 GET /authorize 端点,让它做一次「持有则换发」:

  1. 读浏览器现有的 phm_jwt cookie(用户如果在 .example.com 上登录过,这个 cookie 就在);
  2. 用和登录态完全相同的 RS256 校验来验它;
  3. 验过就直接签一个新的短期 access token,302 跳回面板,并把令牌塞进回跳 URL 的 fragment(井号后面那段):
https://mac-mini.<tailnet>.ts.net/#access_token=<JWT>

选 fragment 而不是 query 是有讲究的:fragment 不会发给服务器、不进 Referer、不进访问日志,比把 token 放在 ?access_token= 里安全。面板的 JS 从 location.hash 取出令牌,存进 sessionStorage,之后每个请求当 Bearer 带上:

const token = new URLSearchParams(location.hash.slice(1)).get("access_token");
if (token) sessionStorage.setItem("kaorou_token", token);
history.replaceState(null, "", location.pathname); // 把 token 从地址栏抹掉

服务端每次都把这个 Bearer 转发给 auth.example.com/api/users/me 现验,再比对 allowlist(管理员 id / email)。.<tailnet>.ts.net 加进认证服务的 return_to 白名单,没登录过就退回走一次 GitHub。

效果是:点一下登录就进,全程一个 token 都不用手敲,mini 本地也不存任何长期凭证。

翻译默认切到 DeepSeek#

原来的 detect_llm_url 会去探测本地和内网的 Qwen。我把默认改成 DeepSeek:

_DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1" # model: deepseek-chat
# 优先级:显式传参 → 环境变量 KAOROU_LLM_BASE_URL → DeepSeek 默认

key 从 .envDEEPSEEK_API_KEY 读,显式设 KAOROU_LLM_BASE_URL 仍可覆盖回内网模型。

端到端调通:三连失败到查出真凶#

全弄完跑一次端到端,扔进去一个 YouTube 视频。下面是整晚最磨人的部分,一步步记。

失败一:ffmpeg 不在 PATH 里#

第一次直接崩,报错:

ERROR: You have requested merging of multiple formats
but ffmpeg is not installed. Aborting due to no merging.

这台机器明明装了 ffmpeg:

Terminal window
$ which ffmpeg
/opt/homebrew/bin/ffmpeg

问题在 launchd。launchd 给后台进程的 PATH 是 macOS 的最小集,不含 Homebrew 的 /opt/homebrew/bin。yt-dlp 下到的是分离的视频流 + 音频流(格式 401+140,401 是视频、140 是 AAC-LC 128k 的 m4a 音频),合并这一步要调 ffmpeg,找不到就崩。字幕烧录、ASR 抽音频也都依赖它。

修法是在服务的环境脚本 kaorou-env.sh 里显式补 PATH:

Terminal window
export PATH=/opt/homebrew/bin:$PATH

失败二:视频流 403,但字幕能下#

ffmpeg 修好后,字幕(.en.vtt)能下、缩略图能下,唯独视频流 403 Forbidden。日志里两条警告:

WARNING: no impersonate target is available
WARNING: Ignoring unsupported remote component(s): ejs

第一条说的是 yt-dlp 的浏览器指纹模拟(impersonate)功能没启用——这个功能依赖 curl_cffi,没装就用不了。我先把它装上:

Terminal window
uv pip install curl_cffi

还是 403。

接着换 player client 试。yt-dlp 可以伪装成不同的 YouTube 客户端拿不同的接口:

Terminal window
yt-dlp --extractor-args "youtube:player_client=web_safari" ...
# 再试 tv / mweb / android

web_safaritvmwebandroid 四个挨个来。要么 403,要么「requested format not available」。

翻机器的时候发现一个东西:~/Codes/bgutil-ytdlp-pot-provider。这是个 PO-Token(Proof of Origin Token)provider。YouTube 现在要求视频流带一个绑定来源的令牌,缺了它 googlevideo 就返回 403。这个项目专门发这种令牌,跑一个 HTTP server(默认端口 4416)+ 一个 yt-dlp 插件配合。

它的 node 服务其实还在 :4416 上跑着:

Terminal window
$ curl -s http://127.0.0.1:4416/ping
{"server_uptime":...,"version":"..."}

但对应的 yt-dlp 插件没装进烤肉的 venv,所以 yt-dlp 根本没去取令牌。补上插件,再用 extractor-args 接上:

Terminal window
uv pip install bgutil-ytdlp-pot-provider
yt-dlp --extractor-args \
"youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416" ...

还是 403。

真凶:IPv6 段被 ban#

到这儿才停下来认真想:令牌也有了、模拟也有了、插件也接上了,问题大概率不在应用层。

是 IPv6。我家这条宽带的 IPv6 段落在 YouTube 拉黑的池子里——googlevideo 在网络层就把请求拒了,带不带 po-token 都没用。这是 yt-dlp 社区里反复出现的已知现象:浏览器走 IPv4 正常,命令行走 IPv6 就 403。强制走 IPv4:

ydl_opts["source_address"] = "0.0.0.0" # 等价于 --force-ipv4

通了。

通了之后#

整条线一气呵成:

  • 格式 401+1401.9 GB 的视频流下完;
  • DeepSeek 分 19 批翻了 375 条字幕,约 81 秒;写出双语 SRT;
  • ffmpeg 用 h264_videotoolbox(macOS 上走 Apple VideoToolbox 的硬件编码器)把字幕烧进画面,耗时约 1454 秒(24 分钟),输出 1.17 GB
  • 这次跑的是 --no-upload,停在上传前。

最后把那个 po-token 服务也做成 launchd(app.example.kaorou.potoken:4416,开机自启 + 崩溃自恢复)。不然机器重启一次,又得从 403 开始查。

收尾:重试和「无痕」#

调通之后补了两样。

重试。 之前取消的任务,状态被写成 done + error=cancelled。原来的 retry_failed 只认 failed 状态,碰不到这种被取消的任务,所以取消掉的任务没法重来。加了一个 db.retry_job,把任务从头(stage=pending)重新入队,配上端点和按钮:

POST /jobs/{id}/retry # 带鉴权 + owner 校验

面板上对应一个「重试」按钮。daemon 也会对真正 failed 的任务自动 requeue_retryableattempts < max_attempts=3)。

无痕。keep_result 默认改成 false:任务跑到终态后,整个 per-video 工作目录全部清掉,真无痕。但结果不能丢——bvid(B 站视频号)这种存在数据库 jobs.bvid 字段里,清目录不影响它。想留中间产物就显式传 keep_result=true

一条经验#

整晚最有用的一条:遇到一个跨了好几层的报错,先别在最熟的那一层反复试。

我在 player client、impersonate、po-token 这些应用层手段上耗了好几轮,每一轮都「看起来该有用」,但真正的死因在网络层——IPv6 被 ban。三个应用层修复叠在一起也救不了一个网络层的拒绝。脏活累活可以交出去,但「会不会问题其实在更底下一层」这个拐弯,得自己拐。


这套流水线的「视频理解」部分(下载 → ASR 转录 → 场景截图 → VLM 描述)之前单独写过一篇:《用自建 Tailnet 服务搭一条视频处理流水线》。这篇是它的另一面——把同一条下载链路从手动脚本固化成带鉴权、能自恢复、有面板的常驻服务,以及那个折腾了我半宿的 403。

一个晚上:把家里的服务注册表合进主平台,把烤肉流水线做成常驻服务,顺带查清 YouTube 403 的真正死因
https://blog.lishuyu.app/posts/烤肉流水线服务化与youtube-403排查/
作者
猫猫魔女
发布于
2026-06-04
许可协议
CC BY-NC-SA 4.0