音源包机制
理解这一层,就理解了轻听为什么能「接口失效不发版就修好」。
包模型
音源包就是一个 .js 文件,用户通过 https 直链或本地文件安装。 宿主不执行包体就能先读出它的身份。
| 类型 | id 示例 | 职责 | 谁可以发 |
|---|---|---|---|
kind:"play" 播放包 | play-official、my-cool-pack | 给一首歌返回播放地址(getPlayUrl) | 官方 + 第三方作者 |
kind:"meta" 数据包 | meta-official | 搜索 / 歌单 / 专辑 / 歌手 / 榜单 / 热词 / 歌词 / 封面 | 目前仅官方 |
每个包按 id 独立管理版本:同 id 的新 versionCode 覆盖旧版(更新), 不同 id 互不影响、可共存切换。
落盘结构
安装后的东西都在应用私有目录 filesDir/source-bundle/ 下(不放 uni storage—— 包体约 2 MB,storage 是给小数据的;文件读写统一走 qt-js-engine 插件的 java.io):
source-bundle/
state.json # 包清单与生效状态,schema 3
install/<packId>/
meta-bundle.js # 数据包体
play-bundle.js # 播放包体
chain.json # 播放包专属的可选线路覆盖层// state.json 的关键字段
{
"schema": 3,
"packs": [ { "id": "...", "kind": "play|meta", "versionCode": 0, "versionName": "...",
"updateUrl": "...", "dir": "...", "installedAt": 0, "updatedAt": 0,
"skipCodes": [], "lastProbeAt": 0 } ],
"activeId": "play-official", // 生效的播放包;"" = 未生效
"activeMetaId": "meta-official", // 生效的数据包;"" = 未装/停用
"lastCheckAt": 0
}「已落盘」不等于「已生效」
packs 数组只表示文件在磁盘上。装载失败不会改动落盘状态,用户手动装的 坏包也会留在列表里(并标上「上次失败」),这样用户能看到它、也能手动删掉它。 数据包与播放包分别由 activeMetaId / activeId 指定生效者:数据包多包共存、 "" 表示数据面下线;播放包多包并存、恰好一个生效。
首行自述头(必须)
整个文件第 1 行必须是一条块注释,内嵌 JSON:
/*__QT_PACK__{"kind":"play","id":"my-cool-pack","name":"我的音源包","versionCode":3,"versionName":"1.2.0","updateUrl":"https://example.com/my-cool-pack.js"}*/| 字段 | 必填 | 规则 |
|---|---|---|
kind | ✅ | 第三方包固定 "play" |
id | ✅ | ^[a-z0-9-]{2,32}$。包的终身身份,发布后不可改 |
name | ✅ | 展示名(可中文) |
versionCode | ✅ | ≥1 整数,只增不减。宿主拒绝降级,重复码视为「已装过」 |
versionName | ✅ | 展示版本 |
updateUrl | 包自己的 https 直链。声明后宿主每次启动探测首行头,发现新版本提示用户 | |
notes | 一句话说明(安装预览里展示) |
两处硬规则:
- JSON 里不能出现
*/序列(会提前终止注释头) - 头必须真的是第 1 行,前面不能有空行 / BOM
宿主侧的解析是纯字符串操作,不会执行包体一个字节:不以 /*__QT_PACK__ 开头 直接判废;用 indexOf("*/") 截出头部;JSON.parse 失败、kind / id 为空、 versionCode <= 0、id 不匹配正则,任一条命中就返回「不是合法音源包」。
两个容易忽略的语义:id 同时就是 install/ 下的目录名(所以格式受限); updateUrl 缺省或为空串表示「这个包不自管更新」,宿主不会去探测它。
保留 id
play-official / meta-official 是官方专用。用它们且没有官方私钥签名, 安装会直接被拒绝(不是警告)。
双包拆分
2026-10 起拆成两个包,目的是把风险分层:
| 包 | 体积(实测) | 格式 | 内容 | 触发重发的改动 |
|---|---|---|---|---|
meta-bundle.js | 229 KB | ESM(format: "es",入口 meta-entry.ts) | 数据面 + 播放包安装 / 路由桩 | 改元数据接口、改注册表 |
play-bundle.js | 1.4 MB | IIFE(format: "iife",全局名 qtPlayBundle,入口 play-entry.ts) | LX 脚本宿主 + 各平台官方接口 + chain 执行器 | 换脚本、改官方接口、加宿主能力 |
数据面可随公开仓库分发(不含任何取链实现),取链面由用户自行安装。
LX 自定义源脚本宿主
播放包里内置了一个 LX 自定义源脚本宿主,这是它体积的主要来源之一,也是它最实用的一点:
- 兼容面:符合 LX 自定义源协议的自定义源脚本无需改动即可装载—— 脚本注册
lx.on(lx.EVENT_NAMES.request, ...),宿主按{action:"musicUrl", source, info}回调取链 - 两端一致:同一份脚本在 Android 与 Windows 上跑出同样的结果(这是「双端同源」的实际含义)
- 多线路并存:一个播放包里可以有 LX 脚本 / 平台官方接口 / 聚合脚本三类线路, 某条不通自动换下一条;本档线路全灭时还会跨源兜底救回
- 依赖收敛:宿主按脚本实际依赖面提供最小可用环境——
utils.crypto(md5 + AES)、utils.buffer(utf8 / hex / base64)、SCRIPT_MD5(脚本原文的 md5,脚本自己会校验)、process桩(多个脚本有process.exit反调试)、window遮蔽、定时器 try/catch 包装
两条来自真实事故的构建纪律
- 逐字节保全:混淆脚本体在构建期被恢复为逐字节原文。打包器重打印语法树会破坏脚本 自身的完整性自校验,导致字符串数组解密轮转不收敛、陷入同步忙循环挂死。
- 挂死守卫:每条脚本在 worker 沙箱里被实际执行一遍,看门狗盯住挂死,挂死即中止构建。
这两步在 qt-sources 的构建流水线里是硬闸门,不允许绕过(详见 qt-sources)。
兼容不等于放权
脚本仍跑在受管控的沙箱里,只有宿主注入的 request 通道可用。
为什么播放包必须是 IIFE
它由数据包侧的 installPlayPack(code) 用 new Function(code)() 求值—— 顶层不能出现 import / export,否则求值当场语法错误。这也是为什么 meta-bundle.js 能是 ESM(PC 侧直接 import()),而播放包不能。 两者 target 都是 es2020(部分混淆脚本用到 BigInt),且不压缩 (minify: false)——混淆脚本体的逐字节完整性比体积重要。
构建时 meta 包有一道敏感词闸门:取链面痕迹零命中才允许发布。命中即中止构建,报 meta-bundle.js 命中敏感词 …(禁止内置任何取链面痕迹)。同一套构建流程还会校验 loadChain 默认链可用、bundleInfo() 如实反映双包状态,任何一条不过都直接失败。
各源能力一览
宿主两端不内置任何音源知识,全部从数据包的 sourceRegistry() 读回。当前 六个源的实测自述(platforms/*.ts 里每个模块自己的字段):
| 源 | latestUsesOffset | searchPageMax | 榜单 | 歌单 | 歌手 | 专辑 | 新歌流 | qualities | playlistSorts |
|---|---|---|---|---|---|---|---|---|---|
网易云 wyy | ✅ 真分页 | 100 | ✅ | ✅ | ✅ | ✅ | ✅ | 128 / 320 / flac | 最热 |
QQ qq | ❌ 摆设 | 50 | ✅ | ✅ | ✅ | ✅ | ✅ | 128 / 320 / flac | 最新、最热 |
酷我 kw | ❌ 摆设 | 100 | ✅ | ✅ | ✅ | ✅ | ✅ | 128 / 320 / flac | 最新、最热 |
酷狗 kg | ✅ 真分页 | 30 | ✅ | ✅ | ✅ | ✅ | ✅ | 128 / 320 / flac | 推荐、最热、最新、热藏、飙升 |
哔哩哔哩 bili | ✅ 真分页 | 20 | ✅ | ❌ | ✅ | ❌ | ✅ | 128 / 320(无 flac) | 无 |
咪咕 migu | ✅ 真分页 | 20 | ✅ | ✅ | ❌ | ❌ | ✅ | 128 / 320 / flac | 无 |
这张表是包说了算的,不是宿主写死的。每一条字段都对应一个真实踩过的坑:
| 字段 | 为什么必须由包声明 |
|---|---|
latestUsesOffset | 有的平台新歌流根本不接受 offset,翻页参数传了也没用。宿主需要对「到底了」的判定区别对待 |
searchPageMax | 各平台每页上限差 5 倍(20 – 100)。宿主若写死一个数,比实际上限大就永远「不满一页 = 到底」,比实际小就白翻。未声明时宿主按 50 兜底。有平台实测 limit=200 返回 0 条 |
referer | 下载时宿主要绕过数据面直接向 CDN 取字节,拿不到平台模块内部的头。原先只能由宿主编一张表、未知源兜底成网易云的 Referer——那正是「冒充别的平台」的老毛病。现在表本身随包下发;未声明则宿主干脆不发这个头,宁可少一个头也不冒充 |
features | 榜单 / 歌单 / 歌手 / 专辑 / 新歌流五个功能面。宿主据此显隐入口与标签——没歌单的源就不该出现「歌单广场」 |
qualities | 声明里没有的档位宿主不展示。B 站音频流没有真无损,flac 档只是最高档 AAC,所以它只声明 128 / 320。声明了空数组就是「一档都没有」,比缺省更明确 |
playlistSorts | 歌单广场的排序选项,id 原样透传给上游接口,name 是宿主 UI 文案。空数组 = 不支持排序,宿主不渲染选择器 |
这样设计的代价是每个平台模块必须如实填——TS 的类型由网易云那份定型,少填一个 字段编译不过;换来的好处是新增一个源只改 registry.ts + 那个平台模块并重发 meta 包,两端客户端一行代码都不用动。
生命周期
1. 安装预览
下载/读文件 → 解析首行头 → 官方 id 先过签名硬校验
→ 内容安全扫描 → 展示身份供确认 → 用户确认
│
2. 落盘 + 登记 ▼
写入 install/<id>/,旧版本备份为 .prev
│
3. 装配 ▼
new Function(code)() 求值 → 工厂调用 → API 挂到引擎
│
4. 冒烟自检 ▼
几首真实歌调 getPlayUrl + Range 预检
通过 → 生效(清播放地址缓存)
不通过 → 更新场景自动回滚旧版;首次安装保留文件、标注「上次失败」
│
5. 更新 ▼
官方 manifest(仅官方包)/ 你声明的 updateUrl / 用户手动重装
更新失败一律回滚
│
6. 卸载 / 切换 ▼
删目录出列表 / 热切换到别的包,包代码无需感知冒烟自检具体做什么
装完不信任包自己的说法,拿真实歌曲试一次取链:
- 用固定关键词在酷我搜前 5 首(搜索走数据包,不需要播放包;不写死歌曲 ID—— 歌会下架、接口会变)
- 逐个按
128档请求getPlayUrl,再对拿到的地址做 Range 预检 (Range: bytes=0-4095拉开头 4 KB,看是不是真音频) - 任一成功即算通过,每首最多试 2 次——网络抖动不该判死一个好包
- 无候选曲目时跳过,不拦安装
失败时的处置分两种:更新场景回滚到 .prev 并提示「已回滚到 <旧版本>」; 首次安装保留文件但标注「上次失败」,用户可以自己看着办。
为什么预检不能只看 HTTP 状态码
曾经有个线路对任意歌曲都返回同一个 2.6 秒的 mp3,206 + audio/mpeg 完全合规,用户放两秒就没了。所以真正的可播性判定比「状态码 + content-type」深得多:
| 关卡 | 判什么 |
|---|---|
| 结构 | 状态码 2xx/3xx、content-type 不是 json/html/text、响应体不是 JSON 对象(403 / 416 仍算通过——Range 不被支持 ≠ 不可播) |
| 魔数 | 开头能读出 ASCII 就必须是 fLaC / ID3 / OggS / RIFF 或 offset 4 的 ftyp;读到的字节本来就是二进制(MP3 帧同步、ADTS、WMA)就放行 |
| 体积 | 总长小于 200 KB 判死(占位音频);不足歌长 60%(按 128 kbps 折算)判死(试听片段) |
| 码率 | 实测字节 ÷ 歌长算出的码率低于请求档下限判死(有损档取 0.75 倍,无损档下限 700 kbps),并把实测档位只降不升地回填给宿主,用于下载命名与界面回显 |
方向一律是「宁低勿高」,fail-closed 只落在可确证的坏响应上。
另一个坑:总时长只认 content-range 里的 /total。206 响应的 content-length 只是分片长度(只请求 16 字节它就是 16),拿它当整首歌的 体积会把所有正常链接都判成死链。
官方包签名(ed25519)
官方发布渠道与第三方完全一样(https 直链 / 本地文件),所以官方身份不靠渠道、 只靠内容签名:
发布时由构建机用 ed25519 私钥对包全文(含身份头、剥掉尾部签名块)签名, 文件末尾追加:
js/*__QT_SIGN__{"alg":"ed25519","sig":"<base64 的 64 字节签名>"}*/两端宿主内置官方公钥,对
play-official/meta-official做硬校验: 缺签名块、签名对不上(文件被改过一个字节)一律拒绝安装 / 更新,与来源渠道无关。 安卓端的公钥是一段十六进制常量,与sources.config.json里的 base64 公钥同源签名块必须整个在文件最末尾(后面只允许空白)
验签在 JS 引擎里跑(纯 JS 的 tweetnacl,ed25519 + SHA-512),UTS 侧没有实现
密钥不入库。他人 clone 构建时用 QT_SIGNING_KEY 环境变量提供 base64 seed, 或 QT_SKIP_SIGN=1 跳过(产物会被宿主拒绝官方 id 安装,仅供本地调试)。
第三方包完全不受影响:不需要签名、不用申请密钥、流程一步没变。 也不要去伪造官方签名——没有私钥签不出来,公钥验签是单向的。
内容安全红线
签名只管官方身份;内容安全对所有包一视同仁。安装时宿主扫描包文本 (剥掉尾部签名块后的全文),第三方包命中下列任一特征直接拒绝安装:
| 类别 | 命中特征 |
|---|---|
| 宿主桥 | __TAURI_INTERNALS__、__TAURI__、ipcRenderer、webkit.messageHandlers、UTSAndroid、io.dcloud |
| 直连网络 | WebSocket、EventSource、sendBeacon、new XMLHttpRequest、全局 fetch(、require( |
| 后台执行体 | importScripts、new Worker(、ServiceWorker、serviceWorker |
| 本地 / Node 面 | child_process、process.binding、content:// |
fetch(/require(认调用形态:前一字符是字母 / 数字 /_/$/.的不算- 其余按纯文本子串匹配——别在字符串字面量 / 注释 / 变量名里拼出这些 API 名
- 刻意不拦
eval(/Function(:crypto-js 等加密库内联时的Function("return this")()环境探测是标配,官方包产物里就有 - 官方包(已过签名校验)命中只记日志不阻断——签名即内容背书
拒绝时的报错会带上行号与命中的规则(形如 第 N 行:<pattern> —— <原因>), 方便作者定位。两端(安卓端与桌面端)的规则表是逐条对齐的,改一边必须同步另一边。
运行时能力收缴
静态扫描不是唯一防线。就算某条规则被绕过,引擎里也没有第二条路:
| 运行时能力 | 有无 |
|---|---|
host.request(受管控 HTTP,协议 / 内网校验在宿主侧) | ✅ 唯一网络出口 |
host.log / host.platform、纯 JS 计算、JSON / Date / 正则 / 定时器 | ✅ |
fetch / XMLHttpRequest / WebSocket / EventSource / sendBeacon | ❌ 已收缴(调用即抛错) |
Worker / SharedWorker / ServiceWorker / importScripts | ❌ 已收缴 |
宿主桥(Tauri IPC / 原生 bridge / plus / Node API / 文件系统) | ❌ 不存在 |
eval / Function | ⚠️ 可用但无利可图——上面全是空的 |
契约版本演进
META_REVISION 是能力自述的版本号,写在数据包里、随 sourceRegistry() 一起下发给宿主:
| 版本 | 增加了什么 |
|---|---|
| v2 | 新增 sourceRegistry 入口(宿主不再内置源清单) |
| v3 | 每源附带能力自述 latestUsesOffset / searchPageMax / referer |
| v4 | 每源附带功能面 features{charts,playlists,artist,album,latest},宿主据此显隐入口 |
| v5 | 每源附带 qualities(音质档位子集)与 playlistSorts(歌单排序选项) |
当前值 META_REVISION = 5。两条纪律:
- 未声明即「支持」:v3 及更早的旧包没有
features字段,宿主一律按 「功能都在」处理,行为不变;同理qualities未声明 = 全部档位可用 - 入口的增删不影响
META_REVISION。比如后来新增的歌单链接解析入口,是 第 27 个入口,但它是纯解析、不涉及能力自述,所以版本号没动。作者与宿主都应 按「入口是否存在」做能力探测,取不到就回退本地实现
另一个版本号是 HOST_API_VERSION = 1,它约束的是包与引擎之间的调用协议 (bundleInfo() 报回来的值),与能力自述无关。
包变更后宿主的行为
装完一个包不等于页面会自己变。宿主用两条互补机制让界面跟上:
| 机制 | 覆盖什么 |
|---|---|
qt-source-packs-changed 事件 | 安装(含更新)/ 卸载 / 启停 / 切换成功后广播,正在显示音源数据的页面(首页、搜索、歌单、每日推荐、歌手、榜单)收到后重拉 |
| 包指纹比对 | onShow 时比对当前包指纹,覆盖事件够不着的角落 |
为什么两条都要:事件在「页面已经切走、正被 App 层的更新弹窗挡着」时收不到, 指纹兜底;而指纹只能在页面生命周期里查,覆盖不了「页面正显示着、用户在弹窗里 确认了更新」这种当场就该刷新的场景。
其余相关行为:
- 更新发现按包独立节流 4 小时,两条来源:包自述的
updateUrl(探测首行头) 与官方 manifest(仅官方包) - 发现新版本只提示,不静默安装。官方数据包排在播放包前面提示;用户点确认后 才下载 → 校验 → 替换 → 冒烟,失败回滚
- 装配/装载失败不会让数据消失:旧包文件还在
install/<id>/下,用户可手动切回 - 未安装播放包时在线取链不可用,宿主只弹一次引导(1 分钟节流)指向设置页; 搜索、歌单、本地播放都不受影响
相关
- 音源包作者指南 —— 从零写一个可安装的播放包
- qt-sources(音源包工程) —— 构建流水线与守卫
- qt-uniappx(Android) —— 安卓端怎么装包、引擎怎么跑
- 问题答疑 —— 装不上、取不到链