最近给自己的 Hexo 博客做了一个私有音乐播放器。
音乐文件不直接放进博客仓库,也不公开 R2 存储桶,而是由 Cloudflare Worker 读取私有 R2,再通过分段流的方式返回给浏览器。
最终实现了这些效果:
- 博客文章中可以插入自定义音乐播放器
- R2 存储桶保持私有
- 文章中只填写音乐 ID,不暴露真实文件路径
- 播放前需要获取短期会话
- 不同浏览器直接访问音乐地址会被拒绝
- 普通“另存为”通常只能保存到第一小段内容
- 支持播放、暂停、拖动进度和音量调整
需要说明的是,这不是专业 DRM,不能绝对阻止懂抓包或编程的人获取音频,但足以避免音乐文件被直接公开,并提高普通用户复制和下载的难度。
整体架构
整个播放链路如下:
1 | Hexo 文章 |
实际请求过程是:
1 | 文章中的 track-0001 |
一、使用私有 R2 存储音乐
我创建了一个 R2 存储桶:
1 | zqf-blog-audio |
存储桶没有开启公开访问,目录结构如下:
1 | music/ |
音乐文件的真实路径统一记录在:
1 | music/catalog.json |
例如:
1 | { |
以后新增音乐时,只需要:
- 把音乐上传到
music/files/ - 在
catalog.json中增加记录 - 在文章中使用对应的音乐 ID
文章本身不需要知道音乐在 R2 中的真实文件路径。
二、Worker 如何读取私有 R2
在 Worker 中配置 R2 Binding:
1 | 绑定名称:AUDIO_BUCKET |
Worker 代码可以直接通过下面的方式读取对象:
1 | await env.AUDIO_BUCKET.get("music/catalog.json"); |
这里不需要在代码中填写:
1 | Access Key |
因为 Worker 和 R2 之间的权限已经通过 Cloudflare Binding 建立。
这不是没有验证,而是权限验证由 Cloudflare 在内部完成。
三、使用音乐 ID 隐藏真实路径
文章中插入音乐时,我使用自定义 Hexo 标签:
1 | {% zqf_audio track-0001 | 测试音乐 | 未知歌手 %} |
带封面时可以写成:
1 | {% zqf_audio track-0001 | 测试音乐 | 未知歌手 | /images/music/track-0001.webp %} |
标签最终只会在页面中留下:
1 | data-track-id="track-0001" |
播放器请求:
1 | https://media.mcpq.cc/audio/stream/track-0001 |
Worker 收到音乐 ID 后,再查询 catalog.json:
1 | track-0001 |
这样文章中不会出现 R2 的真实目录结构。
四、临时会话验证
播放器不会一上来就请求音乐,而是先调用:
1 | POST /audio/session |
Worker 检查请求来源是否为博客域名:
1 | https://blog.mcpq.cc |
验证通过后,Worker 使用 Secret 生成一个短期签名 Cookie:
1 | zqf_audio_session |
这个 Cookie 的有效时间很短,例如 45 秒。
播放器请求音乐时必须携带这个 Cookie,否则 Worker 会返回:
1 | 401 Audio session expired |
因此,在一个完全没有访问过博客播放器的浏览器中,直接打开音乐地址通常无法播放。
五、为什么同一浏览器可以直接打开音乐地址
效果: 当博客播放器获取临时 Cookie 后,同一浏览器中的其他标签页也会共享这份访问权限。不过,一旦停止播放并超过 45 秒,Cookie 失效,新标签页中直接打开的音乐地址也会随之无法继续访问。
此外,即使在同一浏览器中直接打开音乐地址并选择“另存为”,通常也只能保存前 512 KiB 的音频内容,大约相当于前 20 秒左右的音乐,无法一次性下载完整文件。
Cookie 属于浏览器配置,而不是某一个标签页。
因此会出现:
1 | 博客页面点击播放 |
但换一个浏览器、无痕窗口或新的浏览器用户配置后,因为没有这个 Cookie,所以直接访问会失败。
播放器播放期间还会定期刷新会话,因此只要播放器仍在活动,同一浏览器里的临时访问权限就可能持续存在。
六、Range 分段传输
Worker 不会一次返回整个 MP3,而是根据浏览器的 Range 请求分段返回。
例如单次最多返回:
1 | 512 KiB |
第一次响应可能是:
1 | Content-Range: bytes 0-524287/完整文件大小 |
浏览器播放时会继续请求后续分段:
1 | bytes 0-524287 |
因此音乐可以完整播放,也可以拖动进度。
但直接使用浏览器“另存为”时,通常只会保存第一次响应,所以得到的文件往往只有前面一小段。
这不是绝对防下载,但可以避免一次请求直接获得完整音乐文件。
七、Hexo 播放器的文件结构
博客中主要使用三个文件:
1 | scripts/zqf-audio-tag.js |
它们分别负责:
1 | zqf-audio-tag.js |
然后在主题配置中引入 CSS 和 JavaScript:
1 | inject: |
八、播放器最终布局
最终播放器采用左右两栏结构:
1 | ┌────────────┬────────────────────────────┐ |
左侧显示封面和歌手,右侧显示歌曲标题、播放按钮、进度和音量。
播放器样式使用 主题 自带的 CSS 变量,因此可以自动跟随:
1 | 主题强调色 |
这样播放器看起来更像主题原生组件,而不是后来强行拼进去的第三方控件。
九、部署与更新
修改完成后重新生成博客:
1 | cd /d C:\Apps\Websites\zqf-blog |
提交到 Git:
1 | git add -A |
部署完成后,使用:
1 | Ctrl + F5 |
强制刷新页面。
播放器 CSS 或 JavaScript 更新后,如果浏览器一直使用旧缓存,可以在主题配置中增加版本号:
1 | - '<link rel="stylesheet" href="/css/zqf-audio-player.css?v=1.0.1">' |
每次修改播放器后更新版本号,浏览器就会重新请求文件。
十、最终实现效果
目前这套方案实现了:
1 | R2 存储桶不公开 |
它不是专业 DRM,也不能承诺音乐永远无法被提取。
但对于个人博客来说,它已经在几个目标之间取得了比较好的平衡:
1 | 音乐文件保持私有 |
更重要的是,整个系统完全由自己控制,不依赖第三方音乐播放器或外部音乐平台。
评论