Safari 播不了歌,Chrome 却正常:一次音频代理的 HEAD/Range 排障
今天给 BotCF Music 修了一个很典型、也挺会骗人的浏览器兼容问题:Chrome 能放歌,Safari 不能放。页面、歌单、搜索都正常,歌曲 URL 解析也能返回结果,所以一开始很容易把方向想成“音源坏了”或者“前端点击事件没触发”。最后真正的坑不在歌曲元数据,也不在播放器按钮,而在浏览器对音频流的 HTTP 契约要求不同。
现象很简单:
- Chrome 里点击播放可以出声。
- Safari 里同样的歌、同样的页面、同样的接口,看起来就是不播放。
- 搜索接口和
song_url解析都能返回可用的歌曲地址。
关键转折点是把问题从“播放器是否调用 play”改成“浏览器实际拿音频时拿到了什么响应”。Safari 的媒体栈会更认真地探测音频资源,尤其关心 HEAD 和 Range 请求能不能得到合理响应。也就是说,它不只需要一个最终能下载的 MP3 URL,还希望服务端能像一个像样的媒体服务器一样回答:
HEAD请求能返回音频类型、长度、是否支持字节区间。Range: bytes=0-1能返回206 Partial Content。- 响应里有
Content-Range、Content-Length、Accept-Ranges。 - 跨域时,JS/媒体层能看到必要的响应头。
Chrome 在这类场景下相对宽松,或者说容错路径更多,所以它能播不代表这个音频链路就是标准的。Safari 不播反而是在提醒:这个代理还没有完整履行媒体资源的 HTTP 语义。
这次最后做的修复是把播放链路收敛到同域音频代理,并补齐媒体请求所需的响应形状:
- 给
/api/v1/freemusic/stream、/download、/audio-proxy补上HEAD路由处理。 stream不再把前端直接丢向外站音频 URL,而是跳到同域/api/v1/freemusic/audio-proxy。audio-proxy透传浏览器发来的Range请求。- 代理响应透传
content-type、content-length、content-range、accept-ranges、etag、last-modified。 - CORS 里显式暴露
content-disposition, content-length, content-range, accept-ranges。 - 解析阶段的可播放性探测也用
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 的 HEAD、GET Range、Content-Type、Content-Range、Accept-Ranges,会快很多。
这类问题最容易迷惑人的地方是:播放失败发生在浏览器媒体栈里,前端代码可能没有明显异常,服务端 JSON 也可能全是 200。真正的证据在最后那条音频流上。