
家里 NAS 上跑着三个 podsync 容器,把一个频道转成播客音频喂给我的播客客户端。这套东西平时很省心,直到某天日志里开始反复刷同一条错误。这篇是完整的排障记录——之所以写下来,是因为过程里有三次方向判断被推翻,而每一次被推翻,都比一开始"猜对"更能说明这个系统到底是怎么运转的。
一、现象
日志里,某一个 feed 每轮更新都失败,其它 feed 正常:
ERRO ... failed to update feed: https://www.youtube.com/channel/UC.../videos
error="update failed: failed to parse duration : bad format string"注意报错里 duration 后面那个值是空的。而且它只影响 A01——后面的 A02、A03 照常下载,说明 podsync 是逐个 feed 更新的,一个挂了不拖累其余。损失面很小,但这条错误会一直刷,且只要触发条件在,就不会自愈。
二、定位:三次推翻
第一次判断:加个过滤把它挡掉 —— 失败
第一反应是:列表顶部大概有一个"未开播的首播/直播预告",它没有时长,把解析撑爆了。那就用 yt-dlp 的 match-filter 把这类视频过滤掉。于是往该 feed 的 youtube_dl_args 里加了:
youtube_dl_args = [..., "--match-filter", "live_status!=is_upcoming"]重启后,报错依旧。
第二次判断:过滤发生的阶段不对 —— 部分正确,但仍不完整
查文档后明白了:youtube_dl_args 只在下载阶段传给 yt-dlp,而崩溃发生在更早的列表构建阶段。过滤加在了下载侧,够不到列表侧的解析,自然没用。同理,podsync 自带的 filters(标题、时长过滤)也是解析完之后才执行的,一样挡不住。
这一步方向对了,但我当时以为列表也是 yt-dlp 抽的。直到看清报错本质,才有了第三次、也是最终的定位。
第三次判断(最终):这是 Go 二进制里的 bug,配置无解
关键线索是 bad format string 这个措辞——它来自 podsync 解析 ISO8601 时长(PT#H#M#S 那种格式)时对空值的报错。而 ISO8601 时长只有 YouTube Data API 才会返回;yt-dlp 给的时长是浮点秒数,不长这样。
于是链路彻底清楚了:
- 拉列表 = podsync 调 YouTube Data API,逐个解析视频的
contentDetails.duration(ISO8601); - 下载 = yt-dlp(cookies / node / GetPOT 都在这一侧)。
那个频道当时挂了多个长周期的预约首播(首播时间排到第二天),这些未开播视频的 API 时长是空字符串,podsync 老版 Go 二进制拿到空值直接抛 bad format string、整条 feed 崩。
结论:崩溃在 podsync 的 Go 代码里,任何配置层面的过滤都救不了它。 一个讽刺的旁证是——那两条卡住我的预告标题里都带"XX分钟",而我配置里本就写了 not_title = "XX分钟" 想排除它们;但解析崩在过滤之前,连执行这条排除规则的机会都没有。
查到官方 issue #638 正是这个问题,且已 Closed 但没有关联主线 PR——最合理的解读是:提出者(fqx,同时是 podsync-with-yt-dlp fork 的维护者)在自己 fork 里修好后关掉了上游 issue,主线从未合并这个补丁。所以升级官方镜像大概率没用,补丁只在 fqx 的 fork 里。
三、方案:换一个"能容忍空时长"的构建
确认我跑的是三年前的官方 mxpv/podsync:latest(Docker Hub),且 :latest tag 不会自动刷新运行中容器的 Go 二进制——yt-dlp 靠 self_update 一直是新的,但 podsync 本体一直是老的。这解释了为什么它一边下载正常、一边解析崩溃:下载能力和崩溃点是两条独立的线。
走弯路:把新二进制塞进老镜像 —— 失败
第一个想法是外科手术式的:用多阶段构建,只把 fqx 修好的 podsync 二进制 COPY 进我现在这个能正常下载的老镜像,其余不动。
FROM ghcr.io/fqx/podsync-with-yt-dlp:latest AS patched
FROM mxpv/podsync:latest
COPY --from=patched /app/podsync /app/podsync构建成功,但一启动:
exec /app/podsync: no such file or directory文件明明在。这个"no such file or directory"其实是 Linux 上缺动态链接库/loader 最典型的误导性报错——fqx 二进制(2024 环境)和三年前老镜像的 musl/libstdc++ 版本代差太大,依赖对不上。跨代基础镜像移植二进制,不可靠。
正解:以 fqx 镜像为运行时基底
反过来做——直接用 fqx 镜像当运行时,不再往老镜像塞东西。它自带能跑的二进制和配套环境,而 podsync 的 self_update 会在启动时把内置的老 yt-dlp 自动升级到最新。测试容器起来后:
- 二进制正常启动(印证了之前是"缺库");
- yt-dlp 从
2024.05.27自更新到2026.07.04(与原来完全一致); - A01 不再有
bad format string,正常 building feed。
主问题解决。随后把三个容器逐个切到 fqx 镜像。
有个必须守住的铁律:三个容器挂同一个 /app/data,podsync 的 badger 数据库是单写锁,切换必须逐个来——先停旧、再起新,任何时刻同一份 data 只能有一个 podsync 进程。切换时还踩到一个小坑:直接从挂载卷拷现成的 node 二进制会报 symbol not found——因为那个 node 是为旧镜像的 libstdc++ 编译的。解法是先在新容器里 apk add --upgrade libstdc++ 把库升上去,node 就能加载了。
四、意外的续集:PO Token 其实一直是空的
迁移完想确认下载防护(GetPOT)还在不在,结果探针接连翻车:
- 手敲
yt-dlp ...报executable not found——因为 podsync 沿用老名字youtube-dl,路径也不在默认 PATH; - 用对名字后终于看到关键一行:
[debug] [youtube] [pot] PO Token Providers: none注意是 Providers: none。 这里要区分两个概念:Extractor Plugins: GetPOT 只代表 GetPOT 这个框架加载了,但框架背后需要一个真正干活的 provider 才能产出 PO Token。三个容器都是 none——也就是说框架在、但没有 provider,PO Token 实际并没在生成。
之前下载还一直正常,是因为 yt-dlp 自身还有基于 EJS 的基础机制兜底,加上 YouTube 当时没对这些请求强制要 PO Token。属于"能跑但不够稳"。既然要补全,就上标准方案。
补全:bgutil PO Token provider(HTTP 模式)
架构是:单独跑一个 bgutil provider 容器(HTTP 服务,端口 4416),三个 podsync 共用它。
- 起 provider 容器:
docker run --name bgutil-provider -d --restart unless-stopped --init \
-p 4416:4416 brainicism/bgutil-ytdlp-pot-provider:latest- 每个 podsync 容器里装 plugin 端(fqx 镜像只带框架、不带这个 provider 的 plugin):
docker exec <容器> python3 -m pip install -U bgutil-ytdlp-pot-provider- 给每个容器的 yt-dlp 配置追加 provider 地址(三个 podsync 在默认 bridge,用网关
172.17.0.1连宿主机发布的端口):
--extractor-args "youtubepot-bgutilhttp:base_url=http://172.17.0.1:4416"重启后验证,三个容器一致变为:
PO Token Providers: bgutil:http-1.3.1 (external)(另外两行 script-node/script-deno 显示 unavailable 是正常的——我们只部署了 http 模式,官方也会优先采用它。)
一个持久化的坑:pip 装的 plugin 和这行 config 都在容器内、不在挂载卷,容器一旦重建就没了。所以要把"升级 libstdc++ + 恢复 node + 写 ejs/bgutil 配置 + 装 plugin"整套固化进重建初始化脚本,否则每次重建都要重来。
五、验证
A01 崩溃已根治
最新几轮更新完整跑通:updating A01 → running cleaner → creating A01.xml → next update,不再有 bad format string。那几条"XX分钟"预告被 fqx 的空时长兜底直接处理掉,没进下载队列,结果一样干净。
PO Token 链路真实生效
三个容器 PO Token Providers: bgutil:http-1.3.1 (external) 全部 available;debug 也确认 plugin 目录已被识别(Plugin directories: /usr/lib/python3.10/site-packages/yt_dlp_plugins)。
一个关于 429 的假警报
手动测试时撞到过 HTTP Error 429: Too Many Requests——一度以为要上 WARP 换出口。但复盘发现:这是我拿同一个视频短时间连点七八次逼出来的限流,不是日常表现。翻三个 feed 的实际运行日志,没有任何一条 429 / Sign in to confirm / bot 报错,还能看到真实的 successfully downloaded file。所以日常低频(3 小时周期)根本不触发,WARP 这药不用吃——何况 WARP 出口是 Cloudflare 共享脏段,对 YouTube 反而更容易招 bot 检测,多半帮倒忙。
(顺带在 c 日志里发现一个与本次无关的 playlistNotFound (404),是某个源频道被删/改 ID 了,另行清理。)
六、复盘:几条能带走的经验
- 报错措辞是最好的定位线索。
bad format string+ 空值,直接指向 ISO8601 解析,进而锁定"这是 API 列表阶段、不是下载阶段",一句话把问题从 yt-dlp 挪到了 podsync 本体。 - 搞清每个参数作用在哪个阶段。
youtube_dl_args只管下载;列表构建走 YouTube Data API。作用阶段错位,是我第一次尝试失败的根因。 docker :latest不会自动刷新运行中的容器。 yt-dlp 靠 self_update 常新,会掩盖底层 Go 二进制其实很老的事实。二者要分开看。- 别跨代基础镜像移植二进制。
no such file or directory很多时候是"缺库"的伪装。与其把新二进制塞进老环境,不如直接用新环境当基底。 - 框架 ≠ 能力。
GetPOT加载了不代表 PO Token 在生成,真正的判据是PO Token Providers里有没有 available 的 provider。 - 区分"测试触发"和"生产表现"。 手动高频探测撞出的 429,不能当成日常问题来治。先确诊真实日志,再决定要不要动出口——很多时候最好的处理是"什么都不做"。
最终状态:三个容器都在 fqx 镜像上、yt-dlp 2026.07.04、A01 不再崩、下载有 bgutil PO Token 兜底——比出问题之前更稳了一档。
(记:在处理这类问题的时候明显感觉claude的能力强于chatgpt。)