事情的起因非常简单:跟砂糖们去动漫屋一起看番,但是动漫屋的资源都没办法正常加载,或者有的人能加载出来有的人加载不出来。直接去b站找资源用直连播放,也是类似的情况。但我想跟砂糖一起看的这个番,在我NAS上也有。于是我就想把 NAS 上的视频放到 VRChat 里和砂糖一起看。
需求也不复杂,能播放,能暂停,能拖进度条。复制一个链接进房间播放器,就跟平常直接用b站解析的直链一样。
然后我就从单纯传一个链接,一路折腾出了一个带共享缓存、NAS 转码、字幕烧录、预载、动态限速和管理页面的小项目。
现在它已经开源了,名字叫 一起看 · Jellyfin VRC Relay。
项目地址:Tokiichika/jellyfin-vrc-relay
这篇先聊聊它是怎么来的,以及为什么最后会长出这么多功能。
能放是能放,但流量不太对
我的 NAS 上装着 Jellyfin,最直接的办法就是拿到串流链接,塞进 VRChat 世界里的播放器。


但这里有个问题:房间里看起来是一块大屏幕,实际拉视频的却是每个人自己的客户端。
也就是说,五个人一起看,通常就是五个人分别向我的 NAS 请求视频。我这边又用了内网穿透,重复传输既吃上传带宽,也吃穿透流量,就我家宽带50M上行的小水管,估计也扛不住俩仨人(
假设视频平均码率是 8Mbps,五个人同时观看,NAS 的持续上传需求就至少要 40Mbps。实际还会受播放器缓冲、音频和其他开销影响,这里只是算个大概。
刚好我还有一台标称不限流量、200Mbps 带宽的云服务器,于是就有了一个想法:
能不能让云服务器去 NAS 拿视频,再由它发给客户端?
大概是这样:
不过,光加一层普通反向代理还不够。如果每个观众的请求都原样转回 NAS,那只是绕了一圈,重复上传的问题照样存在。
真正要做的是共享缓存:已经拿到的数据留在云端,后面的人直接复用;几个人同时请求同一块还没缓存的数据,也尽量合并成一次回源。
当然,这不代表一部视频永远只从 NAS 下载一次。缓存被淘汰、转码参数不同,或者请求失败需要重试时,还是可能再次下载。云服务器也仍然要承担所有观众的出口流量。
我想要点播,不是把它变成直播
另一个从一开始就确定的需求是:我要能暂停和拖动进度。
所以这套东西围绕点播来做。原文件走字节范围请求,也就是播放器可以指定“我要文件的这一段”;需要 Jellyfin 转码时,则使用 HLS 点播分片。
可以把分片理解成把视频拆成一小段一小段,播放器播到哪里,就取对应的部分,云服务器也按需要缓存。
这也比较符合我的服务器条件:只有2核4G。我也不想为了看一集视频,就先在云端存下整部原片。视频缓存默认上限设成了 9GB,后来又做成可配置的。
这里的9GB指单纯的视频缓存,不是 Docker 镜像、日志和整个系统加起来都只占 9GB。
顺便说一下,房间里谁来控制播放、如何让大家同步进度,还是世界播放器负责。这个项目负责提供视频链接和数据,不接管房间的同步逻辑。
VLC 播得好好的,怎么进 VRC 就卡了?
最开始让我比较迷惑的是,同一个视频,在 Jellyfin 网页里播放没什么问题,用 VLC 打开原始链接也很流畅,但进 VRChat 就一直卡。
当时那份素材的视频编码是 HEVC,也就是 H.265,音轨是 FLAC。

后来我换了一份 H.264 视频、AAC 音频的素材,VRC 里就不卡了。
这组对比当然不能直接证明“所有问题都是 HEVC 导致的”,毕竟视频和音频都换了。但至少给了我一个很有用的方向:不能因为 VLC 能放,就默认房间播放器也能舒服地处理同样的编码和封装。
Jellyfin 网页能正常播放,也不一定代表它正在直放原文件,它可能已经替浏览器做了转码。
于是项目加上了让 NAS 输出 H.264 / AAC 的功能,默认参数是:
视频 H.264,最高 1080p,目标码率 8Mbps。
音频 AAC,立体声,192kbps。
视频和音频是否转码、分辨率和码率,都可以自己调整。

这个默认值主要是为了兼容和方便起步,不是什么适用于所有片源的“最佳画质参数”。本来就适合播放器的视频,可以保留兼容轨道,或者直接走原文件模式,省下一次重新编码。
至于转码放在哪里做,我没有让那台2核4G的云服务器硬扛。
我当时的 NAS 是 E5 平台,配了一张Tesla P4,Jellyfin 版本是 10.10.7。转码交给 NAS 上的 Jellyfin,云端负责缓存和分发,分工就比较清楚了。


后来也把 NAS 硬件加速的相关设置做进了管理页,方便以后换 Intel 核显或者其他显卡时调整。不过驱动、设备映射这些基础环境,还是得先在 NAS 上配置好。

画面顺了,字幕又没了
视频播放正常以后,我又发现:字幕呢?
明明 Jellyfin 里有外挂字幕,网页端也能显示,怎么复制链接过去就没有了?
原因其实很好理解。外挂字幕是另一份文件,内封字幕也是独立的轨道。Jellyfin 客户端知道怎么把它们拿出来显示,但房间播放器只拿到视频流,不一定会顺带获取或正确显示字幕。
既然已经可以转码,那就干脆加个字幕烧录。
管理页先读取 Jellyfin 识别到的字幕列表,有字幕时默认选第一条,再按需要改成正确的语言或版本。选择烧录后,NAS 会把字幕直接画进视频画面,再重新编码输出。

这样一来,观众看到的画面里已经有字幕,不需要额外折腾播放器的字幕支持。
代价也很直观:烧进去以后,这个链接里的字幕就不能随手关掉或切换语言了。要换字幕,需要重新生成链接。它还会增加 NAS 的处理负担,字体缺失、ASS 样式和复杂特效的效果,也需要结合 NAS 上的实际环境检查。
对我这种提前选好一条字幕、大家一起看的场景来说,这个取舍还是比较合适的。
首次加载太慢?那就提前等
还有一个很明显的问题是首次加载。
我遇到过播放器长时间显示载入中,偶尔还提示格式错误,等了好一会儿才开始播放的情况。但重新进房间,用同一个链接再放,因为服务器上已经有缓存,起播就快了很多。
这并不能解释所有加载报错,不过至少说明,冷启动时的等待值得单独处理。
第一次播放可能要等 Jellyfin 启动转码、生成开头分片,然后云服务器再把这些分片拿到手。大家坐下来以后再一起等这一轮,体验确实不太好。
于是我加了一个手动预载按钮。
默认预载视频开头的 5%,按时长计算,并取完整分片。比如 25 分钟的视频,目标大约是前 75 秒。
开场前先点一下,把开头的转码和下载提前做掉,等砂糖们进来再播放,就更从容一些。

这个功能没有让转码凭空变快,只是把一部分等待挪到了观看之前。拖到没缓存的位置,依然可能要等。我当时实测拖进度大概有四五秒延迟,Jellyfin 网页转码播放也差不多,所以没有把“任意位置瞬间跳转”当成它能保证的事。
8Mbps 的视频,怎么把出口跑到 180Mbps 了?
做了流量面板以后,我又看到了一个挺有意思的现象:房间里只有我一个人,向观众发送的瞬时速度,有时候却能冲到 180Mbps。
一开始很容易把视频码率和下载速度混在一起。但播放器不一定每秒只下载一秒的视频,它会提前缓冲,也可能在拖动之后重新请求数据。
比如一段 10 秒、平均 8Mbps 的视频,大约有 80Mb 数据。如果播放器一秒就把它下载完,那这一秒的传输速度就能到约 80Mbps。再加上实际码率波动,短时间的速度比视频码率高并不奇怪。
所以,不能只因为看到峰值高,就断定数据被重复发送了;但如果完全不限,多个播放器一起抢缓冲,确实可能把总出口吃满。
我最开始的想法是,把 200Mbps 的 80% 拿出来分配,再除以客户端数量。后来又加上单个出口 IP 的上限和最低接纳带宽。
按目前的默认设置:
总预算是 160Mbps,也就是 200Mbps 的 80%。
单个出口 IP 默认最多 32Mbps,避免一个人把缓冲拉满整个出口。
动态分配时,每个已接纳的出口 IP 至少要有 10Mbps 的预算。
如果再加一个新 IP 就不够分了,拒绝新增请求,优先保留已经在看的客户端。

按这个例子算,仅看总预算和最低门槛,最多是 16 个出口 IP。但这只是接纳规则,不是“保证 16 人无卡顿”的性能承诺。源站速度、服务器线路和视频实际码率,仍然会影响播放。
这里特意说的是出口 IP,不是精确人数。同一个网络下的几个人可能共用一个公网 IP,所以页面上的活跃客户端数也只能作为估算来看。
B 站直链本来就能放,为什么也要支持中转?
再后来,我把 B 站视频也加进来了。

这件事听起来好像有点多余:B 站解析后的直链,本来就可以直接丢给播放器,为什么还要经过我的服务器?
确实,如果大家直连都很流畅,直接放就好了,也省得占用自己的云端带宽。
我加这个功能,主要是想把使用方式和管理入口统一起来。可以直接粘贴 BV 号、视频页面链接,甚至是“【视频标题】加链接”这样的分享文字,让服务器去解析;拿到的视频数据可以共享缓存,流量、限速和报错也能在同一个面板里看。
通过 BV 创建时,还有一个方便的地方:服务保留了视频标识,可以在临时直链快过期或失效时尝试重新解析。如果只给它一个已经解析好的 MP4 直链,没有对应视频标识,就不能提供同样的自动续期能力。
直链解析的实现则是参考这个油猴脚本:mmyo456/BiliAnalysis
当时我还有一个猜测:在自己电脑上解析出来的 CDN 地址,未必适合云服务器所在的网络,那么让服务器自己解析,会不会更合适?
这部分我没有把它写成确定结论。服务器解析不等于自动选出最快节点,具体还得看实际返回的地址和回源速度。中转本身又多了一跳,服务器线路不好时,完全可能比直连更慢。
目前 B 站这条流程主要处理受支持的单文件 H.264/AAC MP4,负责缓存和分发,不是在云端再做一套万能转码,也不承诺所有视频都能解析。
功能越加越多,页面也得跟上
早期我更关心的是“能不能播”。等基本流程跑通,才发现很多小地方用起来很别扭。
比如主页连一次管理密钥,设置页还要再连一次;切个页面整个网页重载;复制链接没有成功提示;保存设置失败了也不够明显。
这些问题单看都不大,但真正坐下来用的时候,会不断打断操作。
所以后来把主页和设置页合并成了一套界面,共用登录状态,并在当前标签页会话中保留登录状态,减少刷新时重复输入密钥的麻烦。生成链接、复制、保存配置,都补了成功或失败提示,按钮也会区分连接状态。
面板里逐步加上了发送和回源速度、缓存占用、系统 CPU 和内存、活跃客户端估算,以及可以按需打开的调试日志。数据默认每三秒刷新,也能自己修改。
外观上则试了一点所谓液态玻璃风格,加上比较克制的背景光。最后用的是本地 CSS、SVG 和原生 JS,没有为了效果再引入一大套重型渲染库,也不依赖外部 CDN 加载前端资源。

毕竟它主要还是个管理工具,我希望它好看一点,但不想为了几个发光按钮把浏览器搞的能让风扇转起来。
整理一下,干脆开源
做到这里,我觉得这已经不只是我自己临时用一下的脚本了。遇到类似需求的人(虽然这个需求可能比较小众),可能也能省掉一部分重复折腾,所以就把它整理后开源了。
项目采用 MIT 许可证。开发过程中,我负责提出需求、在自己的 NAS 和 VRChat 环境里测试、反馈问题,再和 Codex 一起逐步调整实现。Codex 协助完成了代码、界面、排查、测试和文档工作。
回头看,很多功能都不是一开始就计划好的:
卡顿了,就检查编码;字幕没了,就加烧录;冷启动太慢,就做预载;看到出口峰值太高,就加限速;自己操作着别扭,就继续改交互。
基本就是“用起来,发现问题,再改”的过程。
开源前还专门整理了脱敏配置和部署流程。准备好 Linux、Python 3、Docker 和 Compose 后,解压完整部署包,在目录里执行:
sudo bash install.sh脚本会引导填写公开 HTTPS 地址和 Jellyfin 主机名,生成管理密钥及配置,然后构建、启动服务。不需要再手动给配置文件改名,或者额外跑一次迁移脚本。
域名、证书、反向代理和防火墙这些,仍然需要按自己的环境配置。具体步骤和现阶段的验证范围都放在仓库文档里,部署前建议看一下。
到了现在,我差不多刚好用完了codex(plus,astra_low)的周额度(
最后说两句
这个项目比较适合这样一种情况:家里有 NAS、并且有Jellyfin,上传或穿透流量比较宝贵,手里又有一台线路和带宽合适的服务器,有好几个砂糖要一起看视频(
如果原来的链接已经播得很好,就不一定需要多加这一层。它解决的是我实际碰到的一组问题,不是所有视频播放场景都必须经过的步骤。
另外,工具开源不等于内容也获得了授权。请只处理和分享自己有权提供的内容,不要用于非法用途或传播违法违规内容。管理密钥和“仅供个人使用”的提示只是访问管理手段,不能代替内容授权或部署时需要考虑的合规要求;相关说明也整理在了 README 中。
最开始我真的只是想和砂糖一起看个视频。现在至少,下次再遇到动漫屋里面资源放不了,或者有的人能看有的人不能看,不用只对着一个“载入中”猜半天了。
如果你也有类似需求,可以看看项目,有问题或者建议欢迎提 Issue。
评论