关于博客

给博客装上一个基于 Cloudflare R2 的私有音乐播放器

2026-07-25 #Cloudflare#Workers#Hexo#R2#音乐播放器

最近给自己的 Hexo 博客做了一个私有音乐播放器。

音乐文件不直接放进博客仓库,也不公开 R2 存储桶,而是由 Cloudflare Worker 读取私有 R2,再通过分段流的方式返回给浏览器。

最终实现了这些效果:

  • 博客文章中可以插入自定义音乐播放器
  • R2 存储桶保持私有
  • 文章中只填写音乐 ID,不暴露真实文件路径
  • 播放前需要获取短期会话
  • 不同浏览器直接访问音乐地址会被拒绝
  • 普通“另存为”通常只能保存到第一小段内容
  • 支持播放、暂停、拖动进度和音量调整

需要说明的是,这不是专业 DRM,不能绝对阻止懂抓包或编程的人获取音频,但足以避免音乐文件被直接公开,并提高普通用户复制和下载的难度。

整体架构

整个播放链路如下:

1
2
3
4
5
6
7
8
9
Hexo 文章
↓
自定义 zqf_audio 标签
↓
前端音乐播放器
↓
Cloudflare Worker
↓
Cloudflare R2 私有存储桶

实际请求过程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
文章中的 track-0001
↓
播放器请求临时会话
↓
Worker 返回短期签名 Cookie
↓
播放器请求 /audio/stream/track-0001
↓
Worker 查询 catalog.json
↓
找到真实 R2 文件路径
↓
通过 R2 Binding 读取音乐
↓
以 Range 分段返回给浏览器

一、使用私有 R2 存储音乐

我创建了一个 R2 存储桶:

1
zqf-blog-audio

存储桶没有开启公开访问,目录结构如下:

1
2
3
4
5
6
music/
├─ catalog.json
├─ files/
│ └─ track-0001.mp3
├─ covers/
└─ lyrics/

音乐文件的真实路径统一记录在:

1
music/catalog.json

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"version": 1,
"tracks": {
"track-0001": {
"title": "测试音乐",
"artist": "未知歌手",
"file": "music/files/track-0001.mp3",
"cover": null,
"lyrics": null,
"enabled": true
}
}
}

以后新增音乐时,只需要:

  1. 把音乐上传到 music/files/
  2. 在 catalog.json 中增加记录
  3. 在文章中使用对应的音乐 ID

文章本身不需要知道音乐在 R2 中的真实文件路径。

二、Worker 如何读取私有 R2

在 Worker 中配置 R2 Binding:

1
2
绑定名称:AUDIO_BUCKET
存储桶:zqf-blog-audio

Worker 代码可以直接通过下面的方式读取对象:

1
await env.AUDIO_BUCKET.get("music/catalog.json");

这里不需要在代码中填写:

1
2
3
4
Access Key
Secret Key
Cloudflare API Token
账号密码

因为 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
2
track-0001
→ music/files/track-0001.mp3

这样文章中不会出现 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
2
3
4
5
6
7
8
9
博客页面点击播放
↓
浏览器取得临时 Cookie
↓
同一浏览器新标签打开音乐地址
↓
浏览器自动携带 Cookie
↓
Worker 验证通过

但换一个浏览器、无痕窗口或新的浏览器用户配置后,因为没有这个 Cookie,所以直接访问会失败。

播放器播放期间还会定期刷新会话,因此只要播放器仍在活动,同一浏览器里的临时访问权限就可能持续存在。

六、Range 分段传输

Worker 不会一次返回整个 MP3,而是根据浏览器的 Range 请求分段返回。

例如单次最多返回:

1
512 KiB

第一次响应可能是:

1
2
Content-Range: bytes 0-524287/完整文件大小
HTTP 206 Partial Content

浏览器播放时会继续请求后续分段:

1
2
3
4
bytes 0-524287
bytes 524288-1048575
bytes 1048576-1572863
……

因此音乐可以完整播放,也可以拖动进度。

但直接使用浏览器“另存为”时,通常只会保存第一次响应,所以得到的文件往往只有前面一小段。

这不是绝对防下载,但可以避免一次请求直接获得完整音乐文件。

七、Hexo 播放器的文件结构

博客中主要使用三个文件:

1
2
3
scripts/zqf-audio-tag.js
source/js/zqf-audio-player.js
source/css/zqf-audio-player.css

它们分别负责:

1
2
3
4
5
6
7
8
zqf-audio-tag.js
→ 注册 Hexo 标签并生成播放器 HTML

zqf-audio-player.js
→ 播放、暂停、进度、音量和临时会话逻辑

zqf-audio-player.css
→ 播放器外观和移动端适配

然后在主题配置中引入 CSS 和 JavaScript:

1
2
3
4
5
6
7
inject:
head:
- '<link rel="stylesheet" href="/css/custom.css">'
- '<link rel="stylesheet" href="/css/zqf-audio-player.css">'

bottom:
- '<script src="/js/zqf-audio-player.js"></script>'

八、播放器最终布局

最终播放器采用左右两栏结构:

1
2
3
4
5
┌────────────┬────────────────────────────┐
│ 封面 │ 歌曲名称 私有音源 │
│ 歌手 │ 播放按钮 ──── 播放进度 │
│ │ 播放状态 音量 │
└────────────┴────────────────────────────┘

左侧显示封面和歌手,右侧显示歌曲标题、播放按钮、进度和音量。

播放器样式使用 主题 自带的 CSS 变量,因此可以自动跟随:

1
2
3
4
5
6
主题强调色
深色模式
纸张背景
边框颜色
阴影效果
按钮风格

这样播放器看起来更像主题原生组件,而不是后来强行拼进去的第三方控件。

九、部署与更新

修改完成后重新生成博客:

1
2
3
cd /d C:\Apps\Websites\zqf-blog
pnpm hexo clean
pnpm hexo generate

提交到 Git:

1
2
3
git add -A
git commit -m "feat: 添加私有音乐播放器"
git push

部署完成后,使用:

1
Ctrl + F5

强制刷新页面。

播放器 CSS 或 JavaScript 更新后,如果浏览器一直使用旧缓存,可以在主题配置中增加版本号:

1
2
- '<link rel="stylesheet" href="/css/zqf-audio-player.css?v=1.0.1">'
- '<script src="/js/zqf-audio-player.js?v=1.0.1"></script>'

每次修改播放器后更新版本号,浏览器就会重新请求文件。

十、最终实现效果

目前这套方案实现了:

1
2
3
4
5
6
7
8
9
10
R2 存储桶不公开
Worker 内部读取 R2
文章只暴露音乐 ID
真实文件路径不出现在文章中
临时签名 Cookie 验证
不同浏览器直接访问失败
Range 分段播放
限制单次返回大小
支持拖动播放进度
播放器自动适配博客主题

它不是专业 DRM,也不能承诺音乐永远无法被提取。

但对于个人博客来说,它已经在几个目标之间取得了比较好的平衡:

1
2
3
4
音乐文件保持私有
博客中可以正常播放
部署和维护成本较低
普通用户无法直接取得完整文件

更重要的是,整个系统完全由自己控制,不依赖第三方音乐播放器或外部音乐平台。

评论
分享

评论