问题背景

在搭建家庭媒体服务器时,我选择了 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客户端受影响


漫长排查之路

在解决这个问题的过程中,我尝试了多种方案,包括但不限于:

  1. 调整安全头策略
  2. 修改 Jellyfin 网络配置
  3. WebSocket 优化
  4. 关闭 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 服务器返回完整文件而非部分流。这导致:

  1. Jellyfin收到完整文件请求而非部分请求
  2. 服务器返回HTTP 200而非206(部分内容)
  3. 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;
}

为什么这两个头如此重要?

  1. Range $http_range

    • 传递客户端请求的字节范围(如 bytes=0-1048576
    • 允许 Jellyfin 返回部分内容而非完整文件
    • ExoPlayer 依赖此功能实现流媒体分块加载
  2. 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;
    }

经验总结

  1. 不要忽视基础协议
    HTTP Range 请求是流媒体传输的基础,但在反向代理配置中常被忽略
  2. 客户端差异很重要
    网页播放器与移动端播放器(尤其是 ExoPlayer)可能有不同的协议要求
  3. 分阶段测试

    • 先验证基础代理功能
    • 再测试媒体播放
    • 最后添加安全策略

致谢:特别感谢 Jellyfin 社区和 Github 上的技术讨论,为解决问题提供了宝贵思路。希望这篇博客能帮助遇到类似问题的朋友节省数小时的调试时间!


最后修改:2025 年 08 月 11 日
如果觉得我的文章对你有用,请随意赞赏