Safari 播不了歌,Chrome 却正常:一次音频代理的 HEAD/Range 排障

Safari 播不了歌,Chrome 却正常:一次音频代理的 HEAD/Range 排障

今天给 BotCF Music 修了一个很典型、也挺会骗人的浏览器兼容问题:Chrome 能放歌,Safari 不能放。页面、歌单、搜索都正常,歌曲 URL 解析也能返回结果,所以一开始很容易把方向想成“音源坏了”或者“前端点击事件没触发”。最后真正的坑不在歌曲元数据,也不在播放器按钮,而在浏览器对音频流的 HTTP 契约要求不同。

现象很简单:

  • Chrome 里点击播放可以出声。
  • Safari 里同样的歌、同样的页面、同样的接口,看起来就是不播放。
  • 搜索接口和 song_url 解析都能返回可用的歌曲地址。

关键转折点是把问题从“播放器是否调用 play”改成“浏览器实际拿音频时拿到了什么响应”。Safari 的媒体栈会更认真地探测音频资源,尤其关心 HEADRange 请求能不能得到合理响应。也就是说,它不只需要一个最终能下载的 MP3 URL,还希望服务端能像一个像样的媒体服务器一样回答:

  • HEAD 请求能返回音频类型、长度、是否支持字节区间。
  • Range: bytes=0-1 能返回 206 Partial Content
  • 响应里有 Content-RangeContent-LengthAccept-Ranges
  • 跨域时,JS/媒体层能看到必要的响应头。

Chrome 在这类场景下相对宽松,或者说容错路径更多,所以它能播不代表这个音频链路就是标准的。Safari 不播反而是在提醒:这个代理还没有完整履行媒体资源的 HTTP 语义。

这次最后做的修复是把播放链路收敛到同域音频代理,并补齐媒体请求所需的响应形状:

  1. /api/v1/freemusic/stream/download/audio-proxy 补上 HEAD 路由处理。
  2. stream 不再把前端直接丢向外站音频 URL,而是跳到同域 /api/v1/freemusic/audio-proxy
  3. audio-proxy 透传浏览器发来的 Range 请求。
  4. 代理响应透传 content-typecontent-lengthcontent-rangeaccept-rangesetaglast-modified
  5. CORS 里显式暴露 content-disposition, content-length, content-range, accept-ranges
  6. 解析阶段的可播放性探测也用 Range: bytes=0-1 做轻量检查,避免把 HTML/JSON 错误页误判成音频。

修完后的现场验证,用同一首 kuwo/3195905 走线上 music.botcf.com

song_url -> /api/v1/freemusic/audio-proxy?url=http%3A%2F%2Fcar-lv.kuwo.cn%2F...

HEAD /api/v1/freemusic/stream?... -> 302 -> 200
content-type: audio/mpeg
content-length: 10965357
accept-ranges: bytes
access-control-expose-headers: content-disposition, content-length, content-range, accept-ranges

GET /api/v1/freemusic/stream?... with Range: bytes=0-1 -> 302 -> 206
content-type: audio/mpeg
content-length: 2
content-range: bytes 0-1/10965357

所以这次“Safari 有问题、Chrome 没问题”的真实解释不是 Safari 玄学,而是 Chrome 暂时帮我们绕过了一个不完整的媒体代理实现。Safari 更严格地要求服务端支持媒体播放常见的 HEAD 和字节区间请求,问题才暴露出来。

一个挺实用的排障经验:只要是 <audio> / <video> 在某个浏览器里“没反应”,不要只看业务接口 JSON 是否成功,也不要只看按钮是否触发。直接抓媒体 URL 的 HEADGET RangeContent-TypeContent-RangeAccept-Ranges,会快很多。

这类问题最容易迷惑人的地方是:播放失败发生在浏览器媒体栈里,前端代码可能没有明显异常,服务端 JSON 也可能全是 200。真正的证据在最后那条音频流上。