Skip to content

音源包机制 ​

理解这一层,就理解了轻听为什么能「接口失效不发版就修好」。

包模型 ​

音源包就是一个 .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               # 播放包专属的可选线路覆盖层
jsonc
// 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:

js
/*__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.js229 KBESM(format: "es",入口 meta-entry.ts)数据面 + 播放包安装 / 路由桩改元数据接口、改注册表
play-bundle.js1.4 MBIIFE(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 里每个模块自己的字段):

源latestUsesOffsetsearchPageMax榜单歌单歌手专辑新歌流qualitiesplaylistSorts
网易云 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. 卸载 / 切换  ▼
   删目录出列表 / 热切换到别的包,包代码无需感知

冒烟自检具体做什么 ​

装完不信任包自己的说法,拿真实歌曲试一次取链:

  1. 用固定关键词在酷我搜前 5 首(搜索走数据包,不需要播放包;不写死歌曲 ID—— 歌会下架、接口会变)
  2. 逐个按 128 档请求 getPlayUrl,再对拿到的地址做 Range 预检 (Range: bytes=0-4095 拉开头 4 KB,看是不是真音频)
  3. 任一成功即算通过,每首最多试 2 次——网络抖动不该判死一个好包
  4. 无候选曲目时跳过,不拦安装

失败时的处置分两种:更新场景回滚到 .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 分钟节流)指向设置页; 搜索、歌单、本地播放都不受影响

相关 ​

仅供学习与技术交流使用。本项目不存储、不分发任何音乐内容,请尊重音乐版权。
本站由 腾讯云 EdgeOne Pages 提供构建与托管支持,谨致谢意。