Imported from 907739769/OSR (
osr-openliststrm/src/main/java/com/osr/openliststrm/pt/media/AGENTS.md). Install upstream withnpx skills add 907739769/OSR --skill media. Copyright stays with the author.
PT 媒体服务器接入
多台服务器的查询语义、被动连通状态、对账源不可用时的降级。
本文件由根
AGENTS.md拆出,只在改到本目录时才载入。全局约定(分层、命名、异步包装、日志纲领)仍在根AGENTS.md。 新增本域的踩坑记录写这里,不要往根AGENTS.md里塞。
NOTES
- 媒体服务器是「启用的全部参与查询,任一台命中即算已入库」,判据只有
IPtMediaServerPlusService#listActive()一份。早先是getActive()(enabled='1' ORDER BY id LIMIT 1),而配置页是个能添任意多台、每台一个独立启用开关的列表——用户配上 Emby + Jellyfin 两台、两台都显示「启用」、两台都能测试连接成功,实际只有 id 最小的那台参与入库判定,另一台是彻底的死配置,页面上没有任何一处看得出来。症状是「我明明配了 Jellyfin,订阅进度却按 Emby 走」,而日志里每一步都正常。现在的语义与页面呈现一致,也顺带支持了「电影在 Emby、剧集在 Jellyfin」「新旧两台并存迁移中」这两种真实用法。四条不要改坏的:- 每台各跑一遍那三条集号规则,不要几台共用一个
wholeSeries变量(SubscriptionService#queryLibraryOn)。listAllEpisodeNumbers的懒加载是按服务器各自持有的,共用会让 A 的全剧编号被拿去判 B 的命中。 - 一台不通只是少命中几集,不影响其余服务器,也不影响对账本身——对账只升不降,少命中的集维持原状态,下一轮它恢复了再补上。所以异常要
continue而不是整个 return。 - 这条订阅要的集已经全齐时跳出循环(
coversAllRequested),常见的单台配置一次多余请求都不发。 - 退回单台语义不会让任何功能报错,所以
SubscriptionServiceTest里那三条(并集 / 一台不通另一台顶上 / 电影第二台有)是它唯一的守卫——实测把listActive()截成第一台,正好红这三条。
- 每台各跑一遍那三条集号规则,不要几台共用一个
- 媒体服务器的连通状态是被动记录的:对账每次真正用到某台服务器时把结果写回(
pt_media_server.last_check_time/ok/error,收口在MediaServerHealthRecorder,20260797)。媒体服务器是入库判定的唯一数据来源,而它挂掉的全部可见症状是「订阅进度不动」——配置页上原先一个信号都没有(三行写的是 类型 / 地址 / 创建时间),隔壁索引器至少有fail_count可看。四条:- 被动而不是另起一个周期任务去打
/System/Info:探针通了不代表查得到数据(反代只转发了一部分路径、API Key 权限不足、库没挂上,都是探针 200 而业务查询空手而归),而这里要回答的恰恰是「对账用它的时候好不好使」——那也正是下一条里卡死清扫用来决定跳不跳过的判据。代价是库里一条 ACTIVE 订阅都没有时状态不刷新。 - 落库必须节流。
queryLibrary是每条订阅调一次的,实测 95 条订阅、对账每 10 分钟一轮,不节流就是每 10 分钟 95 次 UPDATE 刷一张几乎不变的配置表。规则是「状态变了立刻写,没变则按 10 分钟刷一次时间」,与EpisodeAirDateSyncTask只写日期确实变了的行同一条取向,也保证了「刚刚坏掉」「刚刚恢复」这两个唯一有信息量的时刻一定落得进库。决策在ConcurrentHashMap#compute的重映射函数里做、落库在函数外做(那里持着 bin 锁,官方文档禁止耗时操作,IndexerCapabilityCache踩过)。 - 写回走
updateProbeResult的LambdaUpdateWrapper,绝不能updateById(实体):连通成功时要把last_check_error清空,而 NOT_NULL 策略会跳过 null,清空根本写不进去——故障恢复之后页面上永远挂着上一次的错误原因;而且api_key带EncryptedStringTypeHandler,调用方手里那份可能是脱敏过的。同updateAutoSearchMissState那条坑。 last_check_ok为 NULL 是「还没被用到过」,不是「不通」(后端PtMediaServerPlus#lastCheckFailed()、前端composables/mediaServerHealth.ts共用这条判据,两端卡片也共用后者)。混成一个 boolean 会让一台刚添的、完全正常的服务器在页面上显示成红色的「不可用」。
- 被动而不是另起一个周期任务去打
- 卡死在途集清扫在「对账源不可用」时整体跳过,判据是通不通而不只是配没配(
StuckEpisodeSweepService#sweep)。该类的注释早就写着「没有媒体服务器 = 没有对账依据,清扫等于把每一次成功的下载都判成卡死」,而那段推理对「配了但全都连不上」逐字成立——只是原先没覆盖:Emby 宕机超过stuck-episode-timeout-hours(默认 12 小时)之后,未file_confirmed的在途集会被退回 MISSING 并累加fail_count,连续max-consecutive-failures次熔断成 BLOCKED,而故障期间每一轮都满足同样的条件。判据取上一条那个被动写回的连通状态(不是现打一次探针——探针通了不代表查得到数据),时序上也正好:LibrarySyncTask每轮先refreshAll写回状态再调本方法。要求「全部」启用项都失败才跳过,还有一台通就说明对账有依据;last_check_ok为 null 不算失败,否则新装库上清扫会整体停摆。删掉这个分支不会让任何别的用例变红,StuckEpisodeSweepServiceTest里那三条是它唯一的守卫。 queryLibrary的两条 WARN 已按服务器加FaultThrottle节流:不加的话遍历会让它们乘以台数,而一个还没配媒体服务器的新装库本来就是每天订阅数 × 144行逐字相同的 WARN(实测 95 条订阅 ≈ 13700 行/天)。节流的 key 是 serverId(一台都没配时是一个固定常量),不能与missingInLibrary那个按 tmdbId 分组的实例混用——那样一次宕机会被记成几十个互不相干的故障,每个各自放行一条「首次失败」,节流形同虚设。- 连通性测试返回
MediaServerProbe而不是 boolean,失败原因要能指导处置。「连接失败,请检查地址、API Key 与网络」这一句回答不了用户接下来该做什么:401 是 Key 填错、连不上是地址/端口/网络、返回 HTML 是反代把请求转给了别的服务——三者要改的东西完全不同,而旧实现把它们压成同一句,真实原因只留在后端日志里。分类收口在EmbyClient#describeFailure,判据是异常类型而不是消息文本(MediaServerHttpException专门带上状态码就是为此;按 message 里的关键字猜是那种改一次文案就静默失效的写法)。成功那侧同理回显ProductName + Version + ServerName(describeServer)——配了反代、做了端口映射、局域网里跑着两个 Emby 时,这一句省掉的排查不少。三条:缺失的片段整段不写、不写「未知」(同PtNotifyText#torrentProfile);Controller 成功与失败都把detail原样回给前端,换成通用文案等于把刚算出来的信息丢掉;前端 catch 里不要再补一句message.error('连接失败'),request.ts拦截器已经弹过后端的 message,补了就把准确原因盖掉(同「推送失败要把真实原因回到用户眼前」那条)。 - 「用户ID」可从
/Users拉取,但输入框必须保持可手填(listUsers+ 配置页的 chip 列表)。那个 ID 是一串 32 位十六进制,用户得去 Web 控制台的 URL 里抠——没有理由让人手抄。但拉取要管理员权限、也可能因为服务器不通而失败,那时输入框是唯一的出路,所以它是辅助不是替代。/users端点限管理员的理由与/test逐字相同(会把已保存的 API Key 填进来、发往请求体里调用方指定的 url),两者共用prepareForProbe。IMediaServerClient#listUsers有默认实现返回空表:将来接 Plex 之类不支持这个概念的服务器时不必为此改动,配置页那侧自动退回纯手填。打开弹窗时要清掉上一台的 users,否则会把 A 的用户显示在 B 的表单里。 - 停用或删除最后一台启用中的媒体服务器要多问一句(
usePtMediaServer的handleDelete/submitForm)。后果是连锁的——订阅的「已入库」判定失效、进度不再推进、卡死在途集清扫整体跳过——而这两个操作原先的提示分别是模板文案「是否确认删除编号为…的数据项?」和一个连确认框都没有的单选按钮,后者尤其容易误触。两条:批量删除要判「选中的是否覆盖了全部启用中的」,只删掉其中一台不该报警;判据取当前列表里enabled === '1'的条数(这张表只有个位数行、每页 12 条,跨页不存在;真跨页了多问一句也无害)。这段警告删掉之后功能照常工作,composables/__tests__/usePtMediaServer.spec.ts那几条是它唯一的守卫。 - 类型显示名收口在
composables/mediaServerTypes.ts,PC 卡片 / 移动端卡片 / 表单下拉三处共用。此前两处写的是item.type === 'JELLYFIN' ? 'Jellyfin' : 'Emby'——除了会漂移,它还有个额外毛病:未知类型显示成 Emby。MediaServerClientFactory明说了将来会接 Plex,那天一到,漏改的那处不报错、只是把 Plex 显示成 Emby。现在认不出的类型原样显示。 listActive()刻意不加缓存。 它每条订阅调一次(对账每 10 分钟一轮、实测 93 条订阅),看起来该缓存,但缓存会破坏上一条的清扫判据:LibrarySyncTask每轮先refreshAll写回连通状态、紧接着调sweep,而 sweep 读到的若是本轮开始前的缓存快照,Emby 刚挂掉那一轮它拿到的就是旧的last_check_ok,照跑——正是那个洞本身。要缓存就得给 sweep 单开一个「不走缓存」的方法,而两个语义相近、只差一个字的方法迟早被用错。收益那侧也不值:这是一张只有几行的表,一条WHERE enabled='1' ORDER BY id的成本可以忽略。