问题背景
在搭建家庭媒体服务器时,我选择了 Jellyfin 作为媒体解决方案,并使用 Nginx 反向代理提供安全的远程访问。初始配置严格遵循jellyfin官方文档:
# use a variable to store the upstream proxy
set $jellyfin 127.0.0.1;
# Security / XSS Mitigation Headers
add_header X-Content-Type-Options "nosniff";
# Permissions policy. May cause issues with some clients
add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always;
# Content Security Policy
# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
# Enforces https content and restricts JS/CSS to origin
# External Javascript (such as cast_sender.js for Chromecast) must be whitelisted.
add_header Content-Security-Policy "default-src https: data: blob: ; img-src 'self' https://* ; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'; font-src 'self'";
location / {
# Proxy main Jellyfin traffic
proxy_pass http://$jellyfin:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
# Disable buffering when the nginx proxy gets very resource heavy upon streaming
proxy_buffering off;
}
location /socket {
# Proxy Jellyfin Websockets traffic
proxy_pass http://$jellyfin:8096;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
}
# ... 其他常见配置
}
尽管网页端播放正常,但 Android 客户端使用集成播放器(ExoPlayer)时却出现 "source error" 错误。
矛盾现象:
✅ 直接访问 http://服务器IP:8096
播放正常
❌ 通过 https://example.com
访问时出现源错误
✅ 网页播放器工作正常
❌ 仅Android客户端受影响
漫长排查之路
在解决这个问题的过程中,我尝试了多种方案,包括但不限于:
- 调整安全头策略
- 修改 Jellyfin 网络配置
- WebSocket 优化
- 关闭 http/3(quic)
但这些调整一点用都没有。真正的突破点出现在深入分析 ExoPlayer 的工作机制后。
关键发现:Range 请求的重要性
通过抓包分析 Android 客户端的网络请求,我发现了一个关键差异:
正常请求(直接连接):
GET /Videos/1234/stream.mp4
Range: bytes=0-1048576
异常请求(反向代理):
GET /Videos/1234/stream.mp4
ExoPlayer 默认使用 HTTP Range 请求 进行流媒体分块加载,而我的反向代理配置未正确传递 Range
头信息,导致 Jellyfin 服务器返回完整文件而非部分流。这导致:
- Jellyfin收到完整文件请求而非部分请求
- 服务器返回HTTP 200而非206(部分内容)
- ExoPlayer无法处理完整文件流,抛出"source error"
终极解决方案
在 Nginx 配置中添加以下两行立即解决了问题:
location / {
# Proxy main Jellyfin traffic
proxy_pass http://$jellyfin:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
# Disable buffering when the nginx proxy gets very resource heavy upon streaming
proxy_buffering off;
# 关键修复
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
}
为什么这两个头如此重要?
Range $http_range
- 传递客户端请求的字节范围(如
bytes=0-1048576
) - 允许 Jellyfin 返回部分内容而非完整文件
- ExoPlayer 依赖此功能实现流媒体分块加载
- 传递客户端请求的字节范围(如
If-Range $http_if_range
- 处理断点续播的验证条件
- 避免播放中途出现内容不一致错误
- 确保媒体流连续性
完整优化配置
以下是我的最终 Nginx 配置(已稳定运行):
# use a variable to store the upstream proxy
set $jellyfin 127.0.0.1;
# Security / XSS Mitigation Headers
add_header X-Content-Type-Options "nosniff";
# Permissions policy. May cause issues with some clients
add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always;
# Content Security Policy
# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
# Enforces https content and restricts JS/CSS to origin
# External Javascript (such as cast_sender.js for Chromecast) must be whitelisted.
add_header Content-Security-Policy "default-src https: data: blob: ; img-src 'self' https://* ; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'; font-src 'self'";
location / {
# Proxy main Jellyfin traffic
proxy_pass http://$jellyfin:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
# Disable buffering when the nginx proxy gets very resource heavy upon streaming
proxy_buffering off;
# 关键修复
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
}
location /socket {
# Proxy Jellyfin Websockets traffic
proxy_pass http://$jellyfin:8096;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Forwarded-Host $http_host;
}
经验总结
- 不要忽视基础协议
HTTP Range 请求是流媒体传输的基础,但在反向代理配置中常被忽略 - 客户端差异很重要
网页播放器与移动端播放器(尤其是 ExoPlayer)可能有不同的协议要求 分阶段测试
- 先验证基础代理功能
- 再测试媒体播放
- 最后添加安全策略
致谢:特别感谢 Jellyfin 社区和 Github 上的技术讨论,为解决问题提供了宝贵思路。希望这篇博客能帮助遇到类似问题的朋友节省数小时的调试时间!