Compare commits

...
4 Commits
Author SHA1 Message Date
snowy-yang 76fb812a50 feat(login): wy cookie 绑定与 qs 短信/sessionid 登录
wy 新增 POST /login/wy(cookie 须含 MUSIC_U,account/get 实测校验、失效
401,与 kg 假 token 同款语义);qs 新增 POST /login/qs/sms、/login/qs/sms/login
(官方 PC 客户端 WebInterfaceSdk 同款 send_code/sms_login,失败映射官方文案,
2046 需去汽水 App 完成安全验证)与 POST /login/qs(sessionid 手动绑定,
user_info 端点恒 16 无权限、不做上游校验)。smoke 补 6 项登录校验用例。
2026-10-04 17:31:38 +08:00
snowy-yang 6b05fde64b refactor(qs)!: 手机端链路彻底清除——PC 签名成为唯一供给
安卓自签 crypto/ 整目录(11 文件)与 device.ts 设备注册删除,audiodecrypt
上移 qs/ 根,getDeviceIds 并入 passport(_qs_passport_device 持久化);
musicSearch 去指纹搜索(需 PC 签名,匿名搜索下线);mssdk 签名供给改三级:
QS_SIGN_SERVER 显式 → 本机 16689 /healthz 自动探测(qs-signer 的 mssdk-sign)
→ data/qs-sign.json 固定值兜底。
2026-10-04 17:31:33 +08:00
snowy-yang cec63f864e fix(bili): 取流 CDN 分级选址——backupUrl 按 host 分级探测,根治 PCDN 点歌卡死
playurl 的 baseUrl 大概率落在 PCDN(京东云无线宝 mcdn.bilivideo.cn、迅雷
mountaintoys 等),旧代码只取 baseUrl(backupUrl 0 处使用)且探测失败仍
原样返回坏地址,点歌端连 PCDN 挂死。现候选 = baseUrl+backupUrl 按 host
分级(upos/Akamai 正规 CDN > cn-* 等 bilivideo.com 系 > PCDN),探测改为
fetch 只读响应头即断开(不等 body,CDN 忽略 Range 回 200 全量时不再整文件
缓冲),单跳 8s / 总预算 20s / 至多 3 候选,全败回退最优候选(fail-soft);
CDN 探测不再携带 Cookie(直链自带签名,避免登录态泄给第三方 PCDN 域名)。
getAudioUrl 返回 urls[],parse 改取 urls[0]。

新增 test/bili-cdn-test.ts(SDK 层 PCDN 硬断言,并入 pnpm test)与 smoke
「URL bili(CDN 分级选址)」软断言项。
2026-10-04 17:29:28 +08:00
snowy-yang e1357dd412 refactor(api)!: 平台 id 回退——joox/5sing/bili/apple 恢复全名,目录对齐 bd/mg
- 撤销 3381e9b/1b972ec 中 jx/5s/bl/ap 四个短码,恢复 joox/5sing/bili/apple;
  qian→bd、migu→mg 保留
- 目录与文件对齐保留的新 id:sdk/qian/→sdk/bd/、sdk/migu/→sdk/mg/、
  sdk/apis/qian.ts→bd.ts、migu.ts→mg.ts、src/http/migu-stream.ts→mg-stream.ts
  (规则改为「目录=对外 id」,聚合入口的模块变量同步更名;music.migu.cn 等域名、
  上游 music-lib 对照注释、miguGet 等功能标识符不动)
- 对外 id 全表:kw kg tx wy qs xm me qt lr mg bd joox 5sing bili apple

**破坏性变更**:joox/5sing/bili/apple 四平台的 URL、/sources 返回与
data/auth.json 凭据键恢复全名,已存短码键的登录态需重新登录。

验证:typecheck ✓、pnpm test 112/112 ✓
2026-10-03 21:04:55 +08:00
73 changed files with 721 additions and 1492 deletions

No files matched your search

+21 -21
View File
@@ -4,7 +4,7 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(mg)、千千音乐(bd)、JOOX(jx)、哔哩哔哩(bl)、5sing(5s);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(ap,仅 preview 试听) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(mg)、千千音乐(bd)、JOOX(joox)、哔哩哔哩(bili)、5sing(5sing);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接) - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查 - 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
@@ -20,10 +20,10 @@
| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 | | 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
| 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) | | 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) | | 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
| JOOX | `jx` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) | | JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
| 哔哩哔哩 | `bl` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 | | 哔哩哔哩 | `bili` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 |
| 5sing | `5s` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) | | 5sing | `5sing` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) |
| Apple Music | `ap` | ✅ | preview 试听片段 | ✅(需登录) | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(media-user-token) | | Apple Music | `apple` | ✅ | preview 试听片段 | ✅(需登录) | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(media-user-token) |
| 喜马拉雅 | `xm` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | cookie | | 喜马拉雅 | `xm` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | cookie |
| 猫耳FM | `me` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | token | | 猫耳FM | `me` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | token |
| 蜻蜓FM | `qt` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | — | | 蜻蜓FM | `qt` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | — |
@@ -62,13 +62,13 @@ pnpm typecheck # 仅类型检查,不运行
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) | | `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
| `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/jx/ap | | `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple |
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/jx/ap) | | `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/joox/apple) |
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/mg/bd/jx/bl/5s/ap) | | `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple) |
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/mg/bd/jx/bl/5s/ap) | | `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple) |
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) | | `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/mg/bd/jx/bl/5s/ap;bl 支持合集/多分P) | | `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple;bili 支持合集/多分P) |
| `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5s/ap/bl/jx/bd/mg(其中 bl、5s 无 album 端点) | | `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5sing/apple/bili/joox/bd/mg(其中 bili、5sing 无 album 端点) |
| `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) | | `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) |
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) | | `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | | `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
@@ -83,8 +83,8 @@ pnpm typecheck # 仅类型检查,不运行
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) | | `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) | | `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) | | `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
| `POST /login/bl/qr` → `POST /login/bl/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) | | `POST /login/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
| `DELETE /login/{wy,kg,tx,qs,xm,me,lr,bl}` | 清除对应平台凭据(退出登录) | | `DELETE /login/{wy,kg,tx,qs,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -105,10 +105,10 @@ pnpm typecheck # 仅类型检查,不运行
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态) - **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
- **mg**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/mg` 解密代理 - **mg**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/mg` 解密代理
- **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie - **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
- **jx**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`) - **joox**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
- **bl**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>` - **bili**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
- **5s**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C` - **5sing**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C`
- **ap**:数字歌曲 id(搜索结果产出);取流固定为 `preview` 试听片段(完整曲为 DRM,不在支持范围) - **apple**:数字歌曲 id(搜索结果产出);取流固定为 `preview` 试听片段(完整曲为 DRM,不在支持范围)
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
@@ -172,7 +172,7 @@ src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源
server.ts HTTP 服务器入口(pnpm start 实际启动文件) server.ts HTTP 服务器入口(pnpm start 实际启动文件)
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 汽水扫码、 routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 汽水扫码、
bl 扫码、xm/me cookie 绑定、lr 账密) bili 扫码、xm/me cookie 绑定、lr 账密)
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK) source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载) source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载)
@@ -210,8 +210,8 @@ test/
- **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。 - **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。 - **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
- **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。 - **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
- **ap 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。 - **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
- **bl 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bl/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。 - **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
- **5s 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。 - **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。 - **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。 - 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
+5 -5
View File
@@ -3,7 +3,7 @@
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。 多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/ 共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/
`bd`(千千)/ `jx` / `bl`(哔哩哔哩)/ `5s` / `ap`(Apple Music,仅试听), `bd`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。 以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
> **本文档是人工速查版,可能滞后于代码。** > **本文档是人工速查版,可能滞后于代码。**
@@ -40,7 +40,7 @@
### 歌曲 id 形态 ### 歌曲 id 形态
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`mg` 的三元组、 各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`mg` 的三元组、
`qt`/`lr` 的复合 id、`bl` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。 `qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。 复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
## 接口速查 ## 接口速查
@@ -78,7 +78,7 @@
| GET | `/boards/:source/:boardId[/:page]` | 榜单详情(分页 path 段)。`boardId` 兼容带前缀 id(`kw__93`)与裸 `bangid`(`93`) | | GET | `/boards/:source/:boardId[/:page]` | 榜单详情(分页 path 段)。`boardId` 兼容带前缀 id(`kw__93`)与裸 `bangid`(`93`) |
| GET | `/hot-search/:source` | 热搜词 | | GET | `/hot-search/:source` | 热搜词 |
| GET | `/song-list/:source[/:page]` | 歌单列表;`?sortId=` `?tagId=` 各平台词汇不同,省略时用平台默认 | | GET | `/song-list/:source[/:page]` | 歌单列表;`?sortId=` `?tagId=` 各平台词汇不同,省略时用平台默认 |
| GET | `/playlist/:source/:id[/:page]` | 歌单详情与曲目;`?limit=`。bl 支持合集/多分P | | GET | `/playlist/:source/:id[/:page]` | 歌单详情与曲目;`?limit=`。bili 支持合集/多分P |
| GET | `/playlist/:source/search/:keyword` | 歌单搜索(`?page=` `?limit=`) | | GET | `/playlist/:source/search/:keyword` | 歌单搜索(`?page=` `?limit=`) |
| GET | `/playlist/:source/categories` | 歌单分类列表 | | GET | `/playlist/:source/categories` | 歌单分类列表 |
| GET | `/playlist/:source/categories/:categoryId` | 分类下的歌单(`?page=` `?limit=`) | | GET | `/playlist/:source/categories/:categoryId` | 分类下的歌单(`?page=` `?limit=`) |
@@ -125,8 +125,8 @@
| DELETE | `/login/xm` · `/login/me` | 退出对应平台 | | DELETE | `/login/xm` · `/login/me` | 退出对应平台 |
| POST | `/login/lr` | 懒人听书账号登录(`account` + `password`) | | POST | `/login/lr` | 懒人听书账号登录(`account` + `password`) |
| DELETE | `/login/lr` | 退出懒人听书 | | DELETE | `/login/lr` | 退出懒人听书 |
| POST | `/login/bl/qr` → `/login/bl/qr/check` | 哔哩哔哩扫码(解锁大会员音质) | | POST | `/login/bili/qr` → `/login/bili/qr/check` | 哔哩哔哩扫码(解锁大会员音质) |
| DELETE | `/login/bl` | 退出哔哩哔哩 | | DELETE | `/login/bili` | 退出哔哩哔哩 |
| POST | `/login/refresh` | 手动触发登录态续期(kg/tx) | | POST | `/login/refresh` | 手动触发登录态续期(kg/tx) |
| GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) | | GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) |
+1 -1
View File
@@ -9,7 +9,7 @@
"typecheck": "tsc", "typecheck": "tsc",
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/smoke-test.ts" "test": "pnpm run typecheck && node test/source-simple-test.ts && node test/bili-cdn-test.ts && node test/smoke-test.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
+2 -2
View File
@@ -1,12 +1,12 @@
// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块 // 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5s',上游 model.Source 为 "fivesing")。 // 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
// 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/ // 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
// 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。 // 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
// 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、 // 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
// 榜单/热搜/歌手/评论(上游均无)。 // 榜单/热搜/歌手/评论(上游均无)。
// 取流入口在 sdk/apis/5sing.ts;songmid 约定 "<songId>|<songType>"(如 "932557|yc",与上游 ID 一致)。 // 取流入口在 sdk/apis/5sing.ts;songmid 约定 "<songId>|<songType>"(如 "932557|yc",与上游 ID 一致)。
// 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益); // 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益);
// 本移植从 sdk/auth/store.ts 的 getCredentials('5s') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。 // 本移植从 sdk/auth/store.ts 的 getCredentials('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。
import musicSearch from './musicSearch.ts' import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts' import lyric from './lyric.ts'
+1 -1
View File
@@ -106,7 +106,7 @@ export const fetchSongInfo = async(songID: string, songType: string, register?:
link: buildSongLink(songType, songID), link: buildSongLink(songType, songID),
songType, songType,
typeName: kindName(songType), typeName: kindName(songType),
source: '5s' as const, source: '5sing' as const,
} }
} }
+4 -4
View File
@@ -9,7 +9,7 @@
import { formatPlayTime } from '../common/utils.ts' import { formatPlayTime } from '../common/utils.ts'
import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts' import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts'
/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5s') */ /** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing') */
export const mapSearchSong = (item: any) => { export const mapSearchSong = (item: any) => {
const songType = String(item?.typeEname ?? '') const songType = String(item?.typeEname ?? '')
const duration = estimateDuration(Number(item?.songSize) || 0) const duration = estimateDuration(Number(item?.songSize) || 0)
@@ -29,7 +29,7 @@ export const mapSearchSong = (item: any) => {
typeName: String(item?.typeName ?? kindName(songType)), typeName: String(item?.typeName ?? kindName(songType)),
size: Number(item?.songSize) || 0, size: Number(item?.songSize) || 0,
link: buildSongLink(songType, item?.songId), link: buildSongLink(songType, item?.songId),
source: '5s' as const, source: '5sing' as const,
} }
} }
@@ -37,7 +37,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim() const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1) const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20) const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5s' as const } const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5sing' as const }
if (!word) return empty if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0` const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0`
@@ -50,7 +50,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
const total = Number(pageInfo.totalCount) || list.length const total = Number(pageInfo.totalCount) || list.length
// 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages // 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo) const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo)
return { list, allPage, total, limit: pageSize, source: '5s' as const } return { list, allPage, total, limit: pageSize, source: '5sing' as const }
} }
export default { search } export default { search }
+7 -7
View File
@@ -32,7 +32,7 @@ const mapPlaylistSong = (songID: string, kind: string, name: string, artist: str
songType: kind, songType: kind,
typeName: kindName(kind), typeName: kindName(kind),
link: buildSongLink(kind, songID), link: buildSongLink(kind, songID),
source: '5s' as const, source: '5sing' as const,
}) })
/** /**
@@ -103,7 +103,7 @@ const mapPlaylistSummary = (item: any) => {
// 上游怪癖:content 为字符串 "0" 时视为空简介 // 上游怪癖:content 为字符串 "0" 时视为空简介
description: desc === '0' ? '' : desc, description: desc === '0' ? '' : desc,
link: buildPlaylistLink(userId, id), link: buildPlaylistLink(userId, id),
source: '5s' as const, source: '5sing' as const,
} }
} }
@@ -112,7 +112,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim() const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1) const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20) const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5s' as const } const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const }
if (!word) return empty if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1` const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1`
@@ -132,7 +132,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
} }
const total = Number(body?.pageInfo?.totalCount) || list.length const total = Number(body?.pageInfo?.totalCount) || list.length
return { list, total, page: pageNo, limit: pageSize, source: '5s' as const } return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const }
} }
/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */ /** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */
@@ -165,7 +165,7 @@ export const fetchPlaylistAll = async(playlistIdRaw: string) => {
description: String(data?.C ?? ''), description: String(data?.C ?? ''),
link: buildPlaylistLink(userId, playlistId), link: buildPlaylistLink(userId, playlistId),
userId, userId,
source: '5s' as const, source: '5sing' as const,
} }
// 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳) // 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳)
@@ -191,7 +191,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const list = songs.slice(start, start + pageSize) const list = songs.slice(start, start + pageSize)
const total = songs.length const total = songs.length
return { return {
source: '5s' as const, source: '5sing' as const,
info: { info: {
id: playlist.id, id: playlist.id,
name: playlist.name, name: playlist.name,
@@ -222,7 +222,7 @@ export const parsePlaylistLink = async(link: string) => {
if (!parsed) throw new Error('invalid 5sing playlist link') if (!parsed) throw new Error('invalid 5sing playlist link')
const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId) const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId)
return { return {
source: '5s' as const, source: '5sing' as const,
info: { info: {
id: playlist.id, id: playlist.id,
name: playlist.name, name: playlist.name,
+6 -6
View File
@@ -1,8 +1,8 @@
// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植) // 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植)
// 平台 id 统一为 '5s'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing"; // 平台 id 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
// 本仓库契约要求 id 为 5s,故所有对外 source 字段均为 '5s'。 // 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页), // 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5s') 读取(无则匿名)。 // 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { getCredentials } from '../auth/store.ts' import { getCredentials } from '../auth/store.ts'
@@ -65,17 +65,17 @@ export const unescapeHtml = (s: any): string =>
}) })
/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */ /** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */
export const get5singCookie = (): string => String((getCredentials('5s') as { cookie?: string } | null)?.cookie ?? '') export const get5singCookie = (): string => String((getCredentials('5sing') as { cookie?: string } | null)?.cookie ?? '')
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */ /** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
export const getStatus = () => { export const getStatus = () => {
const cred = getCredentials('5s') as { cookie?: string, updatedAt?: string } | null const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false } return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
} }
/** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */ /** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */
export const isForeignSource = (songInfo: any): boolean => export const isForeignSource = (songInfo: any): boolean =>
Boolean(songInfo?.source) && songInfo.source !== '5s' Boolean(songInfo?.source) && songInfo.source !== '5sing'
/** /**
* 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现) * 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现)
+1 -1
View File
@@ -3,7 +3,7 @@
// 完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密, // 完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密,
// 本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,并在注释/响应里明确标注,不假装是无损完整曲。 // 本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,并在注释/响应里明确标注,不假装是无损完整曲。
// 歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用,同上游 s.URL 逻辑)。 // 歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用,同上游 s.URL 逻辑)。
// 统一接口约定:apis('ap').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('apple').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type:'preview', url, ext:'m4a', preview:true, songDuration, isrc } // promise resolve → { type:'preview', url, ext:'m4a', preview:true, songDuration, isrc }
// (songDuration 是歌曲完整时长,试听片段本身约 30-90 秒) // (songDuration 是歌曲完整时长,试听片段本身约 30-90 秒)
// type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐 // type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐
+1 -1
View File
@@ -3,7 +3,7 @@
// - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL) // - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL)
// - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链 // - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链
// 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位) // 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位)
import { apiOk, firstNonEmpty, trackLink } from '../qian/util.ts' import { apiOk, firstNonEmpty, trackLink } from '../bd/util.ts'
// 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致) // 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
const QUALITY_RATES: Record<string, string[]> = { const QUALITY_RATES: Record<string, string[]> = {
+115 -38
View File
@@ -4,9 +4,14 @@
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点) // fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底; // 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。 // 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用), //
// 失败时回退原始地址,保证客户端仍能自行跟跳。 // CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
// 参考 Referer/UA 必须携带,否则 CDN 可能 403。 // 迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
// 正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
// cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
// 因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
// 逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
// 参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts' import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
@@ -22,6 +27,11 @@ const QUALITY_LABEL: Record<number, string> = {
30251: 'flac24bit', 30251: 'flac24bit',
} }
// CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
const PROBE_HOP_TIMEOUT = 8000
const PROBE_TOTAL_BUDGET = 20000
const PROBE_MAX_CANDIDATES = 3
let vipCache: { value: boolean, at: number } | null = null let vipCache: { value: boolean, at: number } | null = null
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */ /** 登录态清空/更新后调用,丢弃大会员判断缓存 */
@@ -47,34 +57,72 @@ export const isVipAccount = async(cookie: string): Promise<boolean> => {
} }
} }
const pickAudio = (data: any, type: string): { id: number, url: string, label: string } | null => { /** CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / upos-*.akamaized.net,多地节点)
* 1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方) */
const hostRank = (url: string): number => {
let host = ''
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
if (host.endsWith('.akamaized.net')) return 0
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
return 2
}
/** 节点地址收集:baseUrl + backupUrl(去重) */
const nodeUrls = (node: any): string[] => {
const backups = [
...(Array.isArray(node?.backupUrl) ? node.backupUrl : []),
...(Array.isArray(node?.backup_url) ? node.backup_url : []),
]
return [node?.baseUrl, ...backups].filter(Boolean).map(String)
}
/** 候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先) */
const rankCandidates = (urls: string[]): string[] => {
const seen = new Set<string>()
const uniq: string[] = []
for (const url of urls) {
const u = String(url || '')
if (!u || seen.has(u)) continue
seen.add(u)
uniq.push(u)
}
return uniq
.map((u, i) => ({ u, i, rank: hostRank(u) }))
.sort((a, b) => a.rank - b.rank || a.i - b.i)
.map(x => x.u)
}
const pickAudio = (data: any, type: string): { id: number, label: string, urls: string[] } | null => {
const dash = data?.dash ?? {} const dash = data?.dash ?? {}
const flac = dash?.flac?.audio const flac = dash?.flac?.audio
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? { id: 30251, url: String(flac.baseUrl) } : null const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? flac : null
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl) const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
const standards: any[] = (dash?.audio ?? []) const standards: any[] = (dash?.audio ?? [])
.filter((a: any) => a?.baseUrl) .filter((a: any) => a?.baseUrl)
.sort((a: any, b: any) => Number(b.id) - Number(a.id)) .sort((a: any, b: any) => Number(b.id) - Number(a.id))
let node: any = null
let label = ''
if (type === '64k') { if (type === '64k') {
const low = standards.find(a => Number(a.id) === 30216) const low = standards.find(a => Number(a.id) === 30216)
if (low) return { id: 30216, url: String(low.baseUrl), label: QUALITY_LABEL[30216] } if (low) { node = low; label = QUALITY_LABEL[30216] }
} }
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档 // 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if (hires) return { ...hires, label: QUALITY_LABEL[30251] } if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] }
if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] } if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] }
if (standards.length) { if (!node && standards.length) {
const best = standards[0] node = standards[0]
return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` } label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}`
} }
const durl = data?.durl?.[0]?.url if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) }
if (durl) return { id: 0, url: String(durl), label: 'durl' }
const durlNode = data?.durl?.[0]
if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) }
return null return null
} }
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */ /** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => { export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: string[] }> => {
const cookie = await getCookie() const cookie = await getCookie()
const needFlac = type === 'flac' || type === 'flac24bit' const needFlac = type === 'flac' || type === 'flac24bit'
const vip = await isVipAccount(cookie) const vip = await isVipAccount(cookie)
@@ -84,52 +132,71 @@ export const getAudioUrl = async(bvid: string, cid: string | number, type = '128
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`) if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`)
if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`) if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
const picked = pickAudio(body.data, type) const picked = pickAudio(body.data, type)
if (!picked) throw new Error('B站未返回可用音频流') if (!picked || !picked.urls.length) throw new Error('B站未返回可用音频流')
return { type: picked.label, url: picked.url } return { type: picked.label, urls: picked.urls }
} }
/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */ /**
const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise<string> => { * 探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开 body。
* HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
* 会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。
* 命中 2xx/206 返回该地址;4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
*/
const resolveUrl = async(url: string, tracker: { current: any, cancelled?: boolean }, deadline: number): Promise<string> => {
let current = String(url) let current = String(url)
for (let hop = 0; hop < 3; hop++) { for (let hop = 0; hop < 3; hop++) {
let response: any const remaining = deadline - Date.now()
if (remaining <= 0) throw new Error('CDN 探测超时(总预算耗尽)')
const controller = new AbortController()
tracker.current = controller
const timer = setTimeout(() => controller.abort(new Error('CDN 探测超时')), Math.min(PROBE_HOP_TIMEOUT, remaining))
timer.unref?.()
let resp: Response
try { try {
const request = httpFetch(current, { resp = await fetch(current, {
method: 'get', method: 'get',
headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' }, headers: { ...biliHeaders(), Range: 'bytes=0-0' },
timeout: 15000, redirect: 'manual',
signal: controller.signal,
}) })
if (tracker) tracker.current = request } catch (err: any) {
response = await request.promise if (tracker.cancelled) { err.canceled = true; throw err }
} catch { throw err
return current } finally {
clearTimeout(timer)
} }
const statusCode = Number(response?.statusCode) || 0 // 拿到响应头即断开,不消费 body
const location = response?.headers?.location await resp.body?.cancel().catch(() => {})
if (statusCode >= 300 && statusCode < 400 && location) { const status = resp.status
const location = resp.headers.get('location')
if (status >= 300 && status < 400 && location) {
try { try {
current = new URL(String(location), current).href current = new URL(location, current).href
continue continue
} catch { } catch {
return current return current
} }
} }
if (status >= 200 && status < 300) return current
if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`)
return current return current
} }
return current return current
} }
/** /**
* 统一取流接口:apis('bl').getMusicUrl(songInfo, type) → { promise, cancelHttp } * 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位) * promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。 * songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
* CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
*/ */
export const getMusicUrl = (songInfo: any, type: string) => { export const getMusicUrl = (songInfo: any, type: string) => {
const tracker: { current: any } = { current: null } const tracker: { current: any, cancelled?: boolean } = { current: null }
const requestObj: any = { const requestObj: any = {
cancelHttp() { cancelHttp() {
tracker.cancelled = true
try { try {
tracker.current?.cancelHttp?.() tracker.current?.abort?.(new Error('Canceled'))
} catch { } catch {
// ignore // ignore
} }
@@ -147,9 +214,19 @@ export const getMusicUrl = (songInfo: any, type: string) => {
} }
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)') if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
const info = await getAudioUrl(bvid, realCid, type) const info = await getAudioUrl(bvid, realCid, type)
const cookie = await getCookie()
const finalUrl = await followRedirects(info.url, cookie, tracker) const deadline = Date.now() + PROBE_TOTAL_BUDGET
return { type: info.type, url: finalUrl } for (const candidate of info.urls.slice(0, PROBE_MAX_CANDIDATES)) {
try {
const finalUrl = await resolveUrl(candidate, tracker, deadline)
return { type: info.type, url: finalUrl }
} catch (err: any) {
if (err?.canceled || tracker.cancelled) throw new Error('canceled')
if (Date.now() >= deadline) break
}
}
// 探测全败(或预算耗尽):回退分级排序后的最优候选
return { type: info.type, url: info.urls[0] }
})() })()
return requestObj return requestObj
+1 -1
View File
@@ -7,7 +7,7 @@
// wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。 // wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
// 实测:游客态返回 128k MP3(免费完整曲目)或 VIP 曲目的 preview/refrain 片段; // 实测:游客态返回 128k MP3(免费完整曲目)或 VIP 曲目的 preview/refrain 片段;
// 未登录请求 VIP 曲目时 play_url_list 为试听片段——与上游行为一致(上游同样只能拿游客流)。 // 未登录请求 VIP 曲目时 play_url_list 为试听片段——与上游行为一致(上游同样只能拿游客流)。
// 统一接口约定:apis('jx').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值) // promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
+1 -1
View File
@@ -6,7 +6,7 @@ import { httpFetch } from '../common/request.ts'
import { import {
AndroidChannel, AndroidUA, AndroidVersion, decryptZ3D, firstNonEmpty, AndroidChannel, AndroidUA, AndroidVersion, decryptZ3D, firstNonEmpty,
getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem, getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem,
} from '../migu/util.ts' } from '../mg/util.ts'
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit'] export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
+7 -73
View File
@@ -1,8 +1,9 @@
// 汽水音乐 URL 获取(直连) // 汽水音乐 URL 获取(直连)
// 两条取流通道: // 两条取流通道:
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k) // 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr) // 登录态: 官方 PC 签名 X-Helios/X-Medusa(见 sdk/qs/mssdk.ts)→ /luna/pc/track_v2
// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin) // → 加密音频(cenc-aes-ctr)→ spade key 解密 → /stream/qs/:id 代理端点回明文流
// (相对地址,路由层负责补全 origin)
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。 // 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
// 登录请求失败自动降级匿名试听。 // 登录请求失败自动降级匿名试听。
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
@@ -11,11 +12,9 @@
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { createHash, randomBytes } from 'node:crypto' import { createHash, randomBytes } from 'node:crypto'
import musicInfo from '../qs/musicInfo.ts' import musicInfo from '../qs/musicInfo.ts'
import { getDevice } from '../qs/device.ts'
import { getCookie, getSessionId } from '../qs/passport.ts' import { getCookie, getSessionId } from '../qs/passport.ts'
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts' import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
import { signRequest } from '../qs/crypto/sign.ts' import { decryptAudioBuffer, resolveAudioKey } from '../qs/audiodecrypt.ts'
import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts'
import type { HttpResponse } from '../types.ts' import type { HttpResponse } from '../types.ts'
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益); // 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
@@ -76,43 +75,7 @@ const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
return url return url
} }
// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频 // 登录态通道:官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
const buildSignedUrl = (device: Awaited<ReturnType<typeof getDevice>>): string => {
const params = new URLSearchParams({
device_platform: 'android',
os: 'android',
ssmix: 'a',
cdid: device.cdid,
channel: device.channel,
aid: '386088',
app_name: 'luna',
version_code: '100198030',
version_name: '19.8.0',
manifest_version_code: '100198030',
update_version_code: '100198030',
resolution: '1080*1920',
dpi: '480',
device_type: device.device_type,
device_brand: device.device_brand,
language: 'zh',
os_api: '35',
os_version: device.os_version,
ac: 'wifi',
device_model: device.device_model,
device_id: device.device_id,
iid: device.install_id,
tz_name: 'Asia/Shanghai',
tz_offset: '28800',
sim_region: 'cn',
package: 'com.luna.music',
luna_apk_type: 'normal_apk',
output_device_type: 'Phone',
openudid: device.openudid,
_rticket: String(Date.now()),
})
return `${TRACK_V2_URL}?${params.toString()}`
}
const pickBestEntry = (playInfoList: any) => { const pickBestEntry = (playInfoList: any) => {
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl) const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
if (!entries.length) return null if (!entries.length) return null
@@ -138,28 +101,9 @@ const fetchTrackPlayer = async (url: string, headers: Record<string, string>, re
const TRACK_V2_BODY = (trackId: string) => const TRACK_V2_BODY = (trackId: string) =>
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' }) JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
// 通道 A(默认):安卓 App 参数 + X-Argus/X-Ladon 自签
const fetchTrackPlayerByApp = async (trackId: string) => {
const device = await getDevice()
const sessionId = getSessionId()
const url = buildSignedUrl(device)
const requestBody = TRACK_V2_BODY(trackId)
const headers = await signRequest(url, requestBody)
return fetchTrackPlayer(url, {
...headers,
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
'Content-Type': 'application/json; charset=utf-8',
'X-Luna-Background-Type': 'foreground',
'X-Luna-Is-Background-Req': '0',
'X-Luna-Is-Local-User': '1',
...(sessionId ? { Cookie: getCookie() || `sessionid=${sessionId}` } : {}),
}, requestBody)
}
// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
const fetchTrackPlayerByPc = async (trackId: string) => { const fetchTrackPlayerByPc = async (trackId: string) => {
const sessionId = getSessionId() const sessionId = getSessionId()
if (!mssdkAvailable()) throw new Error('PC 签名不可用') if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)')
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}` const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
const requestBody = TRACK_V2_BODY(trackId) const requestBody = TRACK_V2_BODY(trackId)
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -177,18 +121,8 @@ const fetchTrackPlayerByPc = async (trackId: string) => {
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody) return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
} }
const resolveTrackPlayer = async (trackId: string) => {
try {
return await fetchTrackPlayerByApp(trackId)
} catch (err: any) {
if (!mssdkAvailable()) throw err
console.log(`[qs/track_v2] 安卓自签通道失败(${err?.message ?? err}),降级官方 PC 签名`)
return fetchTrackPlayerByPc(trackId)
}
}
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => { const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
const trackPlayer = await resolveTrackPlayer(trackId) const trackPlayer = await fetchTrackPlayerByPc(trackId)
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList) const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
+2 -2
View File
@@ -35,7 +35,7 @@ export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
return { return {
source: 'ap' as const, source: 'apple' as const,
info: { info: {
id: albumId, id: albumId,
name: summary.name, name: summary.name,
@@ -64,7 +64,7 @@ export const getAlbumSongs = async(id: string): Promise<any[]> => {
export const parseAlbumLink = async(link: string) => { export const parseAlbumLink = async(link: string) => {
const detail = await getAlbumDetail(link, 1, 100000) const detail = await getAlbumDetail(link, 1, 100000)
return { return {
source: 'ap' as const, source: 'apple' as const,
id: detail.info.id, id: detail.info.id,
info: detail.info, info: detail.info,
list: detail.list, list: detail.list,
+4 -4
View File
@@ -1,8 +1,8 @@
// Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: ap) // Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: apple)
// 能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到,见 lyric.ts)/ 封面 / // 能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到,见 lyric.ts)/ 封面 /
// 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/ // 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
// 链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 sdk/apis/apple.ts)。 // 链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 sdk/apis/apple.ts)。
// 凭据:上游 New(cookie) → getCredentials('ap'),cookie 形如 // 凭据:上游 New(cookie) → getCredentials('apple'),cookie 形如
// "media-user-token=xxx;storefront=cn;token=xxx",也支持 storefront/mediaUserToken/token 直填字段。 // "media-user-token=xxx;storefront=cn;token=xxx",也支持 storefront/mediaUserToken/token 直填字段。
// 未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。 // 未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
import appleApi from '../apis/apple.ts' import appleApi from '../apis/apple.ts'
@@ -72,7 +72,7 @@ const apple = {
playlist, playlist,
// 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/ap 复用 // 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/apple 复用
songList: { songList: {
sortList: [{ name: '热门', id: '1526756058' }], sortList: [{ name: '热门', id: '1526756058' }],
getTags() { getTags() {
@@ -96,7 +96,7 @@ const apple = {
/** 登录状态(media-user-token 存在性判断;无在线校验接口) */ /** 登录状态(media-user-token 存在性判断;无在线校验接口) */
export const status = () => { export const status = () => {
const cred = getCredentials('ap') as any const cred = getCredentials('apple') as any
const cfg = getAppleConfig() const cfg = getAppleConfig()
return (cfg.mediaUserToken || cred?.cookie) return (cfg.mediaUserToken || cred?.cookie)
? { logged: true, updatedAt: cred?.updatedAt } ? { logged: true, updatedAt: cred?.updatedAt }
+3 -3
View File
@@ -39,7 +39,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
allPage: hasNext ? p + 1 : p, allPage: hasNext ? p + 1 : p,
total: (p - 1) * l + list.length, total: (p - 1) * l + list.length,
limit: l, limit: l,
source: 'ap' as const, source: 'apple' as const,
} }
} }
@@ -53,7 +53,7 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
total: (p - 1) * l + list.length, total: (p - 1) * l + list.length,
page: p, page: p,
limit: l, limit: l,
source: 'ap' as const, source: 'apple' as const,
} }
} }
@@ -67,7 +67,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
total: (p - 1) * l + list.length, total: (p - 1) * l + list.length,
page: p, page: p,
limit: l, limit: l,
source: 'ap' as const, source: 'apple' as const,
} }
} }
+5 -5
View File
@@ -53,8 +53,8 @@ export const appleCuratorCategories: Array<{ id: string, name: string }> = [
/** 上游 GetPlaylistCategories:静态返回 Apple Music genre curators */ /** 上游 GetPlaylistCategories:静态返回 Apple Music genre curators */
export const getCategories = async() => ({ export const getCategories = async() => ({
source: 'ap' as const, source: 'apple' as const,
list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'ap' as const })), list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'apple' as const })),
}) })
/** 上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐) */ /** 上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐) */
@@ -88,7 +88,7 @@ export const getCategoryPlaylists = async(categoryID: string, page = 1, limit =
} }
return { return {
source: 'ap' as const, source: 'apple' as const,
list: all.slice(0, l), list: all.slice(0, l),
page: p, page: p,
limit: l, limit: l,
@@ -118,7 +118,7 @@ export const getPlaylistDetail = async(rawId: string, page = 1, limit = 100) =>
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
return { return {
source: 'ap' as const, source: 'apple' as const,
info: { info: {
id: playlistId, id: playlistId,
name: summary.name, name: summary.name,
@@ -145,7 +145,7 @@ export const getPlaylistSongs = async(id: string): Promise<any[]> => {
export const parsePlaylistLink = async(link: string) => { export const parsePlaylistLink = async(link: string) => {
const detail = await getPlaylistDetail(link, 1, 100000) const detail = await getPlaylistDetail(link, 1, 100000)
return { return {
source: 'ap' as const, source: 'apple' as const,
id: detail.info.id, id: detail.info.id,
info: detail.info, info: detail.info,
list: detail.list, list: detail.list,
+5 -5
View File
@@ -1,6 +1,6 @@
// Apple Music(music.apple.com / amp-api.music.apple.com)公共工具 // Apple Music(music.apple.com / amp-api.music.apple.com)公共工具
// 上游 music-lib/apple/apple.go 的移植基础层: // 上游 music-lib/apple/apple.go 的移植基础层:
// - New(cookie) → getCredentials('ap'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx" // - New(cookie) → getCredentials('apple'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx"
// - fetchAppleToken():从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer) // - fetchAppleToken():从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)
// 当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi({"typ":...})开头而非上游正则写的 eyJh, // 当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi({"typ":...})开头而非上游正则写的 eyJh,
// 故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取见 getAppleToken/invalidateAppleToken。 // 故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取见 getAppleToken/invalidateAppleToken。
@@ -42,7 +42,7 @@ export const parseCookieParts = (cookie: string): Record<string, string> => {
/** 读取 apple 平台运行时配置(对应上游 New(cookie)) */ /** 读取 apple 平台运行时配置(对应上游 New(cookie)) */
export const getAppleConfig = (): AppleRuntimeConfig => { export const getAppleConfig = (): AppleRuntimeConfig => {
const cred = (getCredentials('ap') as any) || {} const cred = (getCredentials('apple') as any) || {}
const parts = parseCookieParts(String(cred.cookie ?? '')) const parts = parseCookieParts(String(cred.cookie ?? ''))
const storefront = String(cred.storefront || parts.storefront || DEFAULT_STOREFRONT).trim() const storefront = String(cred.storefront || parts.storefront || DEFAULT_STOREFRONT).trim()
return { return {
@@ -297,7 +297,7 @@ export const appleSongFromCatalog = (res: any, defaultAlbumId = ''): any => {
lrc: null, lrc: null,
types: [], types: [],
_types: {}, _types: {},
source: 'ap' as const, source: 'apple' as const,
link: attr.url || '', // music.apple.com 网页地址 link: attr.url || '', // music.apple.com 网页地址
previewUrl: previewURL, // 30-90 秒试听片段直链(Apple 不提供完整曲目明文流) previewUrl: previewURL, // 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
duration: durationSec, duration: durationSec,
@@ -316,7 +316,7 @@ export const appleAlbumToPlaylist = (res: any): any => {
creator: attr.artistName || '', creator: attr.artistName || '',
description: attr.editorialNotes?.short || '', description: attr.editorialNotes?.short || '',
link: attr.url || '', link: attr.url || '',
source: 'ap' as const, source: 'apple' as const,
extra: { extra: {
release_date: attr.releaseDate || '', release_date: attr.releaseDate || '',
record_label: attr.recordLabel || '', record_label: attr.recordLabel || '',
@@ -340,7 +340,7 @@ export const applePlaylistToPlaylist = (res: any): any => {
creator: attr.curatorName || '', creator: attr.curatorName || '',
description, description,
link: attr.url || '', link: attr.url || '',
source: 'ap' as const, source: 'apple' as const,
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库) // 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} } // 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_passport_device: {...} }
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换, // 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。 // 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。 // DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
+22
View File
@@ -113,6 +113,28 @@ export const cellphoneLogin = async({ phone, password, captcha, countrycode = '8
return profile return profile
} }
/**
* 手动绑定 cookie(从已登录的 music.163.com 浏览器请求头复制,须含 MUSIC_U)。
* 绑定前用 account/get 实测校验,失效直接报 401,与 kg 绑定 token 同款语义。
*/
export const loginByCookie = async(cookieInput: string) => {
const cookie = String(cookieInput || '').trim()
const musicU = /MUSIC_U=([^;\s]+)/.exec(cookie)?.[1]
if (!musicU) throw Object.assign(new Error('cookie 需包含 MUSIC_U 字段'), { status: 422 })
const { body } = await requestWeapi('/api/w/nuser/account/get', {}, cookie)
if (!body?.account?.id) {
throw Object.assign(new Error('cookie 无效或已过期(MUSIC_U 校验未通过)'), { status: 401 })
}
const profile = {
userid: body.account.id,
nickname: body.profile?.nickname ?? null,
avatar: body.profile?.avatarUrl ?? null,
vipType: body.profile?.vipType ?? null,
}
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 当前账号资料(未登录返回 null) */ /** 当前账号资料(未登录返回 null) */
export const status = async() => { export const status = async() => {
if (!getWyCookie()) return null if (!getWyCookie()) return null
File renamed without changes.
+4 -4
View File
@@ -7,7 +7,7 @@
// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表; // - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表;
// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。 // - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选)。 // 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选)。
import qianApi from '../apis/qian.ts' import bdApi from '../apis/bd.ts'
import musicSearch from './musicSearch.ts' import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts' import lyric from './lyric.ts'
import { getSongInfo } from './musicInfo.ts' import { getSongInfo } from './musicInfo.ts'
@@ -16,11 +16,11 @@ import playlist from './playlist.ts'
import parse from './parse.ts' import parse from './parse.ts'
import { getQianCookie } from './util.ts' import { getQianCookie } from './util.ts'
const qian = { const bd = {
musicSearch, musicSearch,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return qianApi.getMusicUrl(songInfo, type) return bdApi.getMusicUrl(songInfo, type)
}, },
getLyric(songInfo) { getLyric(songInfo) {
@@ -83,4 +83,4 @@ export const status = () => {
return cookie ? { logged: true } : { logged: false } return cookie ? { logged: true } : { logged: false }
} }
export default qian export default bd
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -2,7 +2,7 @@
// 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回) // 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)
// 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid= // 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid=
// 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*id= / 裸歌单 id // 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*id= / 裸歌单 id
import { getDownloadUrl } from '../apis/qian.ts' import { getDownloadUrl } from '../apis/bd.ts'
import { getSongInfo } from './musicInfo.ts' import { getSongInfo } from './musicInfo.ts'
import { parseAlbumLink } from './album.ts' import { parseAlbumLink } from './album.ts'
import { parsePlaylistLink } from './playlist.ts' import { parsePlaylistLink } from './playlist.ts'
File renamed without changes.
View File
File renamed without changes.
+1 -1
View File
@@ -1,4 +1,4 @@
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bl) // Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili)
// 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/ // 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/
// 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts) // 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts)
// 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。 // 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。
+3 -3
View File
@@ -5,7 +5,7 @@
// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码 // 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。 // 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致; // 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
// 上游 New(cookie) 的实例化语义由 getCredentials('bl') 承担(见 util.ts getCookie)。 // 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。
// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。 // 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
@@ -125,7 +125,7 @@ export const checkQRLogin = async(key: string) => {
const cookie = cookieString(jar) const cookie = cookieString(jar)
if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试') if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试')
const profile = await fetchProfile(cookie) const profile = await fetchProfile(cookie)
setCredentials('bl', { setCredentials('bili', {
cookie, cookie,
refreshToken: String(data.refresh_token || ''), refreshToken: String(data.refresh_token || ''),
mid: profile.mid, mid: profile.mid,
@@ -148,7 +148,7 @@ export const checkQRLogin = async(key: string) => {
/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */ /** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */
export const status = () => { export const status = () => {
const cred = getCredentials('bl') as any const cred = getCredentials('bili') as any
return cred?.cookie return cred?.cookie
? { ? {
logged: true, logged: true,
+1 -1
View File
@@ -1,6 +1,6 @@
// Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串) // Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串)
// B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致: // B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bl 有响应而不是 404。 // 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bili 有响应而不是 404。
export const getLyric = (_songInfo?: any, ..._rest: any[]) => { export const getLyric = (_songInfo?: any, ..._rest: any[]) => {
const requestObj = { const requestObj = {
promise: Promise.resolve({ lyric: '', tlyric: '' }), promise: Promise.resolve({ lyric: '', tlyric: '' }),
+1 -1
View File
@@ -50,7 +50,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
allPage: numPages || p + (list.length >= l ? 1 : 0), allPage: numPages || p + (list.length >= l ? 1 : 0),
total, total,
limit: l, limit: l,
source: 'bl' as const, source: 'bili' as const,
} }
} }
+3 -3
View File
@@ -51,12 +51,12 @@ export const parseSong = async(link: string) => {
page, page,
}) })
// 上游 Parse 会立即填充音频地址(失败忽略) // 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if (cid) { if (cid) {
try { try {
const audio = await getAudioUrl(song.bvid, cid) const audio = await getAudioUrl(song.bvid, cid)
if (audio?.url) { if (audio?.urls?.[0]) {
song.url = audio.url song.url = audio.urls[0]
song.type = audio.type song.type = audio.type
} }
} catch { } catch {
+6 -6
View File
@@ -57,7 +57,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
creator: view?.owner?.name || item.author || '', creator: view?.owner?.name || item.author || '',
description: season.intro || '', description: season.intro || '',
link: `https://www.bilibili.com/video/${item.bvid}`, link: `https://www.bilibili.com/video/${item.bvid}`,
source: 'bl' as const, source: 'bili' as const,
} }
} }
const pages: any[] = view?.pages ?? [] const pages: any[] = view?.pages ?? []
@@ -73,14 +73,14 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
creator: view?.owner?.name || item.author || '', creator: view?.owner?.name || item.author || '',
description: '', description: '',
link: `https://www.bilibili.com/video/${item.bvid}`, link: `https://www.bilibili.com/video/${item.bvid}`,
source: 'bl' as const, source: 'bili' as const,
} }
} }
return null return null
}) })
const list = result.filter(Boolean) const list = result.filter(Boolean)
return { list, page: p, limit: l, total: list.length, source: 'bl' as const } return { list, page: p, limit: l, total: list.length, source: 'bili' as const }
} }
/** 由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据) */ /** 由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据) */
@@ -167,7 +167,7 @@ const pageSlice = (detail: { info: any, songs: any[] }, page: any, limit: any) =
const p = Math.max(1, Number(page) || 1) const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 100), 10000) const l = Math.min(Math.max(1, Number(limit) || 100), 10000)
return { return {
source: 'bl' as const, source: 'bili' as const,
info: detail.info, info: detail.info,
list: detail.songs.slice((p - 1) * l, p * l), list: detail.songs.slice((p - 1) * l, p * l),
page: p, page: p,
@@ -216,7 +216,7 @@ const parsePlaylistView = async(bvid: string, view: any) => {
let trackCount = countSeasonEpisodes(season.sections) let trackCount = countSeasonEpisodes(season.sections)
if (!trackCount) trackCount = detail.songs.length if (!trackCount) trackCount = detail.songs.length
return { return {
source: 'bl' as const, source: 'bili' as const,
id, id,
info: { ...detail.info, trackCount, playCount: Number(season?.stat?.view) || undefined }, info: { ...detail.info, trackCount, playCount: Number(season?.stat?.view) || undefined },
list: detail.songs, list: detail.songs,
@@ -227,7 +227,7 @@ const parsePlaylistView = async(bvid: string, view: any) => {
} }
const detail = await detailFromBvid(bvid) const detail = await detailFromBvid(bvid)
return { return {
source: 'bl' as const, source: 'bili' as const,
id: bvidKey(bvid), id: bvidKey(bvid),
info: { ...detail.info, trackCount: detail.songs.length }, info: { ...detail.info, trackCount: detail.songs.length },
list: detail.songs, list: detail.songs,
+2 -2
View File
@@ -25,7 +25,7 @@ export const biliHeaders = (cookie = ''): Record<string, string> => ({
}) })
/** 已保存的登录 Cookie(未登录返回空串) */ /** 已保存的登录 Cookie(未登录返回空串) */
export const getLoginCookie = (): string => String((getCredentials('bl') as any)?.cookie || '') export const getLoginCookie = (): string => String((getCredentials('bili') as any)?.cookie || '')
const cookieMapFromHeaders = (headers: any): Record<string, string> => { const cookieMapFromHeaders = (headers: any): Record<string, string> => {
const raw = headers?.['set-cookie'] const raw = headers?.['set-cookie']
@@ -147,7 +147,7 @@ export const makeSong = ({ bvid, cid, name, artist, album, duration, cover, page
lrc: null, lrc: null,
types: [] as any[], types: [] as any[],
_types: {} as Record<string, any>, _types: {} as Record<string, any>,
source: 'bl' as const, source: 'bili' as const,
bvid, bvid,
cid: String(cid ?? ''), cid: String(cid ?? ''),
page, page,
+16 -16
View File
@@ -7,8 +7,8 @@ import xm from './xm/index.ts'
import me from './me/index.ts' import me from './me/index.ts'
import qt from './qt/index.ts' import qt from './qt/index.ts'
import lr from './lr/index.ts' import lr from './lr/index.ts'
import migu from './migu/index.ts' import mg from './mg/index.ts'
import qian from './qian/index.ts' import bd from './bd/index.ts'
import joox from './joox/index.ts' import joox from './joox/index.ts'
import bili from './bili/index.ts' import bili from './bili/index.ts'
import fivesing from './5sing/index.ts' import fivesing from './5sing/index.ts'
@@ -22,8 +22,8 @@ import { supportQualitys as xmQualitys } from './apis/xm.ts'
import { supportQualitys as meQualitys } from './apis/me.ts' import { supportQualitys as meQualitys } from './apis/me.ts'
import { supportQualitys as qtQualitys } from './apis/qt.ts' import { supportQualitys as qtQualitys } from './apis/qt.ts'
import { supportQualitys as lrQualitys } from './apis/lr.ts' import { supportQualitys as lrQualitys } from './apis/lr.ts'
import { supportQualitys as miguQualitys } from './apis/migu.ts' import { supportQualitys as mgQualitys } from './apis/mg.ts'
import { supportQualitys as qianQualitys } from './apis/qian.ts' import { supportQualitys as bdQualitys } from './apis/bd.ts'
import { supportQualitys as jooxQualitys } from './apis/joox.ts' import { supportQualitys as jooxQualitys } from './apis/joox.ts'
import { supportQualitys as biliQualitys } from './apis/bili.ts' import { supportQualitys as biliQualitys } from './apis/bili.ts'
import { supportQualitys as fivesingQualitys } from './apis/5sing.ts' import { supportQualitys as fivesingQualitys } from './apis/5sing.ts'
@@ -53,13 +53,13 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true }, { id: 'me', name: '猫耳FM', module: me, noAggregate: true },
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true }, { id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true }, { id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
{ id: 'mg', name: '咪咕音乐', module: migu }, { id: 'mg', name: '咪咕音乐', module: mg },
{ id: 'bd', name: '千千音乐', module: qian }, { id: 'bd', name: '千千音乐', module: bd },
{ id: 'jx', name: 'JOOX', module: joox }, { id: 'joox', name: 'JOOX', module: joox },
{ id: 'bl', name: '哔哩哔哩', module: bili }, { id: 'bili', name: '哔哩哔哩', module: bili },
{ id: '5s', name: '5sing', module: fivesing }, { id: '5sing', name: '5sing', module: fivesing },
// Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配 // Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
{ id: 'ap', name: 'Apple Music', module: apple, noAggregate: true }, { id: 'apple', name: 'Apple Music', module: apple, noAggregate: true },
] ]
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module])) const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
@@ -81,12 +81,12 @@ export const supportQuality: Record<string, string[]> = {
me: meQualitys, me: meQualitys,
qt: qtQualitys, qt: qtQualitys,
lr: lrQualitys, lr: lrQualitys,
mg: miguQualitys, mg: mgQualitys,
bd: qianQualitys, bd: bdQualitys,
jx: jooxQualitys, joox: jooxQualitys,
bl: biliQualitys, bili: biliQualitys,
'5s': fivesingQualitys, '5sing': fivesingQualitys,
ap: appleQualitys, apple: appleQualitys,
} }
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string) const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
+5 -5
View File
@@ -40,7 +40,7 @@ const fetchAlbumTracks = async(albumId: string, page: number, limit: number) =>
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length) const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length)
return { return {
source: 'jx' as const, source: 'joox' as const,
info: { info: {
name: String(firstNonEmpty(body?.name, albumId)), name: String(firstNonEmpty(body?.name, albumId)),
img: pickJooxImage(body?.images) || null, img: pickJooxImage(body?.images) || null,
@@ -95,7 +95,7 @@ export const getAlbumDetailFromPage = async(rawId: string, page = 1, limit = 50)
const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length) const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length)
return { return {
source: 'jx' as const, source: 'joox' as const,
info: { info: {
name: String(firstNonEmpty(albumData?.title, albumId)), name: String(firstNonEmpty(albumData?.title, albumId)),
img: cover || null, img: cover || null,
@@ -120,7 +120,7 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}` const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
if (statusCode != 200 || !body?.section_list) { if (statusCode != 200 || !body?.section_list) {
return { list: [], total: 0, page, limit, source: 'jx' as const } return { list: [], total: 0, page, limit, source: 'joox' as const }
} }
const list: any[] = [] const list: any[] = []
@@ -142,12 +142,12 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
publishDate: String(album.publish_date ?? '').trim(), publishDate: String(album.publish_date ?? '').trim(),
trackCount: 0, trackCount: 0,
link: jooxAlbumLink(id), link: jooxAlbumLink(id),
source: 'jx' as const, source: 'joox' as const,
}) })
} }
} }
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'jx' as const } return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
} }
/** 专辑链接解析(上游 ParseAlbum) */ /** 专辑链接解析(上游 ParseAlbum) */
+3 -3
View File
@@ -7,7 +7,7 @@
// - 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(需 md5 签名,见 util.jooxSign) // - 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(需 md5 签名,见 util.jooxSign)
// - 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content) // - 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
// - 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__ // - 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
// 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/jx 绑定后从 sdk/auth/store.ts 读取, // 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/joox 绑定后从 sdk/auth/store.ts 读取,
// 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。 // 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。
// userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。 // userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
import jooxApi from '../apis/joox.ts' import jooxApi from '../apis/joox.ts'
@@ -35,7 +35,7 @@ const joox = {
album, album,
playlist, playlist,
// 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口, // 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口,
// 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/jx 路由复用。 // 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/joox 路由复用。
songList: { songList: {
sortList: [{ name: '全部', id: '' }], sortList: [{ name: '全部', id: '' }],
getTags() { getTags() {
@@ -53,7 +53,7 @@ const joox = {
/** 登录状态(凭据存在性判断;无在线校验接口) */ /** 登录状态(凭据存在性判断;无在线校验接口) */
export const status = () => { export const status = () => {
const cred = getCredentials('jx') as { cookie?: string, updatedAt?: string } | null const cred = getCredentials('joox') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie return cred?.cookie
? { logged: true, updatedAt: cred.updatedAt } ? { logged: true, updatedAt: cred.updatedAt }
: { logged: false } : { logged: false }
+2 -2
View File
@@ -14,7 +14,7 @@ export const search = async(keyword: string, _page = 1, limit = 20) => {
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}` const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
if (statusCode != 200 || !body?.section_list) { if (statusCode != 200 || !body?.section_list) {
return { list: [], allPage: 0, total: 0, limit, source: 'jx' as const } return { list: [], allPage: 0, total: 0, limit, source: 'joox' as const }
} }
const list: any[] = [] const list: any[] = []
@@ -35,7 +35,7 @@ export const search = async(keyword: string, _page = 1, limit = 20) => {
allPage: 1, allPage: 1,
total: list.length, total: list.length,
limit, limit,
source: 'jx' as const, source: 'joox' as const,
} }
} }
+8 -8
View File
@@ -39,7 +39,7 @@ const mapPlaylistSummary = (item: any) => {
creator: '', creator: '',
description: '', description: '',
link: jooxPlaylistLink(id), link: jooxPlaylistLink(id),
source: 'jx' as const, source: 'joox' as const,
} }
} }
@@ -60,7 +60,7 @@ const fetchPlaylistTracks = async(playlistId: string, page: number, limit: numbe
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length) const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length)
return { return {
source: 'jx' as const, source: 'joox' as const,
info: { info: {
name: String(firstNonEmpty(body?.name, playlistId)), name: String(firstNonEmpty(body?.name, playlistId)),
img: pickJooxImage(body?.images) || null, img: pickJooxImage(body?.images) || null,
@@ -136,7 +136,7 @@ export const getPlaylistDetailFromPage = async(rawId: string) => {
.filter(Boolean) .filter(Boolean)
if (!list.length) throw new Error('joox 歌单无歌曲') if (!list.length) throw new Error('joox 歌单无歌曲')
return { return {
source: 'jx' as const, source: 'joox' as const,
info: { info: {
name: String(parsed.name || pageId), name: String(parsed.name || pageId),
img: cover || null, img: cover || null,
@@ -185,7 +185,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}` const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
if (statusCode != 200 || !body?.section_list) { if (statusCode != 200 || !body?.section_list) {
return { list: [], total: 0, page, limit, source: 'jx' as const } return { list: [], total: 0, page, limit, source: 'joox' as const }
} }
const list: any[] = [] const list: any[] = []
@@ -200,7 +200,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
} }
} }
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'jx' as const } return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
} }
/** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */ /** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */
@@ -230,7 +230,7 @@ export const getCategories = async() => {
list.push({ id: String(index), name, group: 'JOOX', hot: false, count: items.length }) list.push({ id: String(index), name, group: 'JOOX', hot: false, count: items.length })
}) })
if (list.length === 1) throw new Error('joox 歌单分类不存在') if (list.length === 1) throw new Error('joox 歌单分类不存在')
return { source: 'jx' as const, list } return { source: 'joox' as const, list }
} }
/** 分类下歌单(上游 GetCategoryPlaylists) */ /** 分类下歌单(上游 GetCategoryPlaylists) */
@@ -282,13 +282,13 @@ export const getCategoryPlaylists = async(categoryId = '', page = 1, limit = 20)
link: jooxPlaylistLink(id), link: jooxPlaylistLink(id),
categoryId: cid, categoryId: cid,
categoryName, categoryName,
source: 'jx' as const, source: 'joox' as const,
} }
}) })
.filter(Boolean) .filter(Boolean)
if (!list.length) throw new Error('joox 分类下无歌单') if (!list.length) throw new Error('joox 分类下无歌单')
return { list, total: items.length, page, limit, source: 'jx' as const } return { list, total: items.length, page, limit, source: 'joox' as const }
} }
/** 歌单链接解析(上游 ParsePlaylist) */ /** 歌单链接解析(上游 ParsePlaylist) */
+2 -2
View File
@@ -45,7 +45,7 @@ export const jooxSongFromTrack = (
vip: Number(item?.vip_flag ?? 0) === 1, vip: Number(item?.vip_flag ?? 0) === 1,
types: [], types: [],
_types: {}, _types: {},
source: 'jx' as const, source: 'joox' as const,
} }
} }
@@ -65,7 +65,7 @@ export const jooxSongFromDetail = (detail: any, playUrl = ''): any => {
lrc: null, lrc: null,
types: [], types: [],
_types: {}, _types: {},
source: 'jx' as const, source: 'joox' as const,
} }
if (playUrl) song.url = playUrl if (playUrl) song.url = playUrl
song.vip = Number(detail?.vip_flag ?? 0) === 1 song.vip = Number(detail?.vip_flag ?? 0) === 1
+1 -1
View File
@@ -33,7 +33,7 @@ export const jooxSign = (params: Record<string, any>): string =>
/** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */ /** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */
export const jooxLoginCookie = (): string => { export const jooxLoginCookie = (): string => {
const cred = getCredentials('jx') as { cookie?: string } | null const cred = getCredentials('joox') as { cookie?: string } | null
return cred?.cookie || '' return cred?.cookie || ''
} }
File renamed without changes.
+4 -4
View File
@@ -2,17 +2,17 @@
// 上游对应:music-lib/migu/(migu.go、song.go、lyric.go、album.go、playlist.go、download.go) // 上游对应:music-lib/migu/(migu.go、song.go、lyric.go、album.go、playlist.go、download.go)
// 匿名面(2026-10-02 实测):搜索、PQ(128k mp3) 取流、歌词、封面、专辑曲目、歌单曲目均可用; // 匿名面(2026-10-02 实测):搜索、PQ(128k mp3) 取流、歌词、封面、专辑曲目、歌单曲目均可用;
// HQ/SQ/ZQ 各档需登录 Cookie(接口返回「登录后免费试听」)。 // HQ/SQ/ZQ 各档需登录 Cookie(接口返回「登录后免费试听」)。
import miguApi from '../apis/migu.ts' import mgApi from '../apis/mg.ts'
import musicSearch from './musicSearch.ts' import musicSearch from './musicSearch.ts'
import lyric, { contentIdOf } from './lyric.ts' import lyric, { contentIdOf } from './lyric.ts'
import album from './album.ts' import album from './album.ts'
import playlist from './playlist.ts' import playlist from './playlist.ts'
import { fetchSongDetail, miguSongLink } from './util.ts' import { fetchSongDetail, miguSongLink } from './util.ts'
const migu = { const mg = {
musicSearch, musicSearch,
getMusicUrl(songInfo: any, type: string) { getMusicUrl(songInfo: any, type: string) {
return miguApi.getMusicUrl(songInfo, type) return mgApi.getMusicUrl(songInfo, type)
}, },
getLyric(songInfo: any) { getLyric(songInfo: any) {
return lyric.getLyric(songInfo) return lyric.getLyric(songInfo)
@@ -60,4 +60,4 @@ export const parseSong = async(link: string) => {
return song return song
} }
export default migu export default mg
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
-152
View File
@@ -1,152 +0,0 @@
import { b64encode, getCrypto, getSubtle, hash, pkcs7Pad } from "./index.ts";
import { ProtobufWriter } from "./protobuf.ts";
import { simonEncrypt } from "./simon.ts";
import { sm3 } from "./sm3.ts";
import type { UnidbgConfig } from "./unidbgconfig.ts";
const LOW_RAND = new Uint8Array([0xf2, 0x81]);
const HIGH_RAND = new Uint8Array([0x61, 0x6f]); // 'a', 'o'
const XOR_PREFIX = new Uint8Array([0xf2, 0xf7, 0xfc, 0xff, 0xf2, 0xf7, 0xfc, 0xff]);
function sm3Prefix6(data: Uint8Array): Uint8Array<ArrayBuffer> {
return sm3(data).slice(0, 6);
}
function decodeStub(xssStub: string): Uint8Array<ArrayBuffer> {
const bytes = new Uint8Array(16);
if (xssStub.length >= 32) {
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(xssStub.slice(i * 2, i * 2 + 2), 16) & 0xff;
}
}
return bytes;
}
function calculateConstant(code: string): number {
const parts = [...code.replace(/\./g, "").padStart(6, "0")].map(Number);
const weights = [20480, 2048, 20971520, 2097152, 1342177280, 134217728];
return parts.reduce((sum, part, i) => sum + part * weights[i]!, 0);
}
function buildProtobuf(
query: string,
xssStub: string,
timestamp: number,
config: UnidbgConfig,
): Uint8Array<ArrayBuffer> {
const params = new URLSearchParams(query);
const deviceId = params.get("device_id") ?? "";
const versionName = params.get("version_name") ?? "";
const deviceType = params.get("device_type") ?? "";
const osVersion = params.get("os_version") ?? "";
const channel = params.get("channel") ?? "";
// 空 stub / 空 query 时都对 16 字节全零求 SM3
const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub));
const queryHash = sm3Prefix6(
query === "" ? new Uint8Array(16) : new TextEncoder().encode(query),
);
const rand = getCrypto().getRandomValues(new Uint32Array(1))[0]! % 0x7fffffff;
return new ProtobufWriter()
.varint(1, 0x20200929 * 2) // magic
.varint(2, 2) // version
.varint(3, rand)
.string(4, config.aid)
.string(5, deviceId)
.string(6, config.licenseId)
.string(7, versionName)
.string(8, config.sdkVersion)
.varint(9, config.sdkVersionInt)
.bytes(10, new Uint8Array(8)) // envcode
.varint(11, 0) // platform
.varint(12, timestamp * 2) // createTime
.bytes(13, bodyHash)
.bytes(14, queryHash)
.message(15, (sub) => {
sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860);
})
.string(16, config.secDeviceId ?? "") // sec_device_id
.string(20, "none") // pskVersion
.varint(21, config.callType)
.message(23, (sub) => {
sub.string(1, deviceType).string(2, osVersion).string(3, channel).varint(4, calculateConstant(osVersion));
})
.varint(25, 2)
.toBytes();
}
export async function getArgus(
query: string,
xssStub: string,
timestamp: number,
config: UnidbgConfig,
): Promise<string> {
const { signKey } = config;
if (signKey.length !== 32) {
throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`);
}
const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16);
const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length);
sm3Input.set(signKey, 0);
sm3Input.set(LOW_RAND, signKey.length);
sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length);
sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length);
const sm3Output = sm3(sm3Input);
const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength);
const simonKey: [bigint, bigint, bigint, bigint] = [
keyView.getBigUint64(0, true),
keyView.getBigUint64(8, true),
keyView.getBigUint64(16, true),
keyView.getBigUint64(24, true),
];
const encrypted = new Uint8Array(protobuf.length);
const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength);
const encView = new DataView(encrypted.buffer);
for (let offset = 0; offset < protobuf.length; offset += 16) {
const [low, high] = simonEncrypt(
[pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)],
simonKey,
);
encView.setBigUint64(offset, low, true);
encView.setBigUint64(offset + 8, high, true);
}
const data = new Uint8Array(XOR_PREFIX.length + encrypted.length);
data.set(XOR_PREFIX);
data.set(encrypted, XOR_PREFIX.length);
for (let i = XOR_PREFIX.length; i < data.length; i++) {
data[i]! ^= data[i % 8]!;
}
data.reverse();
const header = new Uint8Array([0xa6, 0x6e, 0xad, 0x9f, 0x77, 0x01, 0xd0, 0x0c, 0x18]);
const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length);
plaintext.set(header);
plaintext.set(data, header.length);
plaintext.set(HIGH_RAND, header.length + data.length);
const subtle = getSubtle();
const aesKey = await subtle.importKey(
"raw",
await hash.md5bytes(signKey.slice(0, 16)),
{ name: "AES-CBC" },
false,
["encrypt"],
);
const iv = await hash.md5bytes(signKey.slice(16));
const ciphertext = new Uint8Array(
await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext),
);
const result = new Uint8Array(LOW_RAND.length + ciphertext.length);
result.set(LOW_RAND);
result.set(ciphertext, LOW_RAND.length);
return b64encode(result.buffer);
}
-120
View File
@@ -1,120 +0,0 @@
import md5 from "./md5.ts";
import { sm3 } from "./sm3.ts";
export function getCrypto(): Crypto {
const c = globalThis.crypto as Crypto | undefined;
if (!c?.subtle) {
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
}
return c;
}
export function getSubtle(): SubtleCrypto {
return getCrypto().subtle;
}
export function b64decode(b64: string): ArrayBuffer {
const binaryString = atob(b64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export function b64encode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
const chunks: string[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(
String.fromCharCode(...bytes.subarray(i, i + chunkSize)),
);
}
return btoa(chunks.join(""));
}
export function unhex(hex: string): ArrayBuffer {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
const byte = parseInt(hex.slice(i, i + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex string");
}
bytes[i / 2] = byte;
}
return bytes.buffer;
}
export function hex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let hexString = "";
for (let i = 0; i < bytes.length; i++) {
hexString += bytes[i]!.toString(16).padStart(2, "0");
}
return hexString;
}
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
const padLength = blockSize - (data.length % blockSize);
const padded = new Uint8Array(data.length + padLength);
padded.set(data);
padded.fill(padLength, data.length);
return padded;
}
export function randomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const array = new Uint8Array(length);
getCrypto().getRandomValues(array);
for (let i = 0; i < length; i++) {
result += chars.charAt(array[i]! % chars.length);
}
return result;
}
type HashInput = ArrayBuffer | Uint8Array<ArrayBuffer> | string;
function toBytes(input: HashInput): Uint8Array<ArrayBuffer> {
if (typeof input === "string") {
return new TextEncoder().encode(input);
}
if (input instanceof Uint8Array) {
return new Uint8Array(input);
}
return new Uint8Array(input);
}
export const hash = {
sha256: async (input: HashInput): Promise<string> => {
const subtle = getSubtle();
const digest = await subtle.digest("SHA-256", toBytes(input));
return hex(digest);
},
sha256bytes: async (input: HashInput): Promise<ArrayBuffer> => {
const subtle = getSubtle();
return subtle.digest("SHA-256", toBytes(input));
},
sha512: async (input: HashInput): Promise<string> => {
const subtle = getSubtle();
const digest = await subtle.digest("SHA-512", toBytes(input));
return hex(digest);
},
sha512bytes: async (input: HashInput): Promise<ArrayBuffer> => {
const subtle = getSubtle();
return subtle.digest("SHA-512", toBytes(input));
},
md5: async (input: HashInput): Promise<string> => md5(
typeof input === "string" ? input : toBytes(input).buffer,
),
md5bytes: async (input: HashInput): Promise<ArrayBuffer> => unhex(md5(
typeof input === "string" ? input : toBytes(input).buffer,
)),
sm3: async (input: HashInput): Promise<string> => hex(sm3(toBytes(input)).buffer),
sm3bytes: async (input: HashInput): Promise<ArrayBuffer> => sm3(toBytes(input)).buffer,
};
-18
View File
@@ -1,18 +0,0 @@
import { b64encode, getCrypto } from "./index.ts";
import { generateLadonKey, speckEncrypt } from "./speck.ts";
import type { UnidbgConfig } from "./unidbgconfig.ts";
export async function getLadon(timestamp: number, config: UnidbgConfig): Promise<string> {
const randomBytes = getCrypto().getRandomValues(new Uint8Array(4));
const plaintext = new TextEncoder().encode(
`${timestamp}-${config.licenseId}-${config.aid}`,
);
const key = await generateLadonKey(randomBytes, config.aid);
const encrypted = speckEncrypt(key, plaintext);
const result = new Uint8Array(randomBytes.length + encrypted.length);
result.set(randomBytes);
result.set(encrypted, randomBytes.length);
return b64encode(result.buffer);
}
-161
View File
@@ -1,161 +0,0 @@
function rotateLeft(value: number, shiftBits: number): number {
return (value << shiftBits) | (value >>> (32 - shiftBits));
}
function addUnsigned(left: number, right: number): number {
return (left + right) >>> 0;
}
function f(x: number, y: number, z: number): number {
return (x & y) | (~x & z);
}
function g(x: number, y: number, z: number): number {
return (x & z) | (y & ~z);
}
function h(x: number, y: number, z: number): number {
return x ^ y ^ z;
}
function i(x: number, y: number, z: number): number {
return y ^ (x | ~z);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b);
}
function convertToWordArray(bytes: Uint8Array): number[] {
const wordCount = Math.ceil((bytes.length + 9) / 64) * 16;
const words = new Array<number>(wordCount).fill(0);
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
const wordIndex = Math.floor(byteIndex / 4);
const bytePosition = (byteIndex % 4) * 8;
words[wordIndex] = words[wordIndex]! | (bytes[byteIndex]! << bytePosition);
}
const paddingWordIndex = Math.floor(bytes.length / 4);
const paddingBytePosition = (bytes.length % 4) * 8;
words[paddingWordIndex] = words[paddingWordIndex]! | (0x80 << paddingBytePosition);
words[wordCount - 2] = bytes.length << 3;
words[wordCount - 1] = bytes.length >>> 29;
return words;
}
function wordToHex(value: number): string {
let hex = "";
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
const byte = (value >>> (byteIndex * 8)) & 0xff;
hex += byte.toString(16).padStart(2, "0");
}
return hex;
}
/**
* 计算 MD5 摘要。
*/
export default function md5(input: string | ArrayBuffer): string {
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
const words = convertToWordArray(bytes);
let a = 0x67452301;
let b = 0xefcdab89;
let c = 0x98badcfe;
let d = 0x10325476;
for (let k = 0; k < words.length; k += 16) {
const aa = a;
const bb = b;
const cc = c;
const dd = d;
a = ff(a, b, c, d, words[k]!, 7, 0xd76aa478);
d = ff(d, a, b, c, words[k + 1]!, 12, 0xe8c7b756);
c = ff(c, d, a, b, words[k + 2]!, 17, 0x242070db);
b = ff(b, c, d, a, words[k + 3]!, 22, 0xc1bdceee);
a = ff(a, b, c, d, words[k + 4]!, 7, 0xf57c0faf);
d = ff(d, a, b, c, words[k + 5]!, 12, 0x4787c62a);
c = ff(c, d, a, b, words[k + 6]!, 17, 0xa8304613);
b = ff(b, c, d, a, words[k + 7]!, 22, 0xfd469501);
a = ff(a, b, c, d, words[k + 8]!, 7, 0x698098d8);
d = ff(d, a, b, c, words[k + 9]!, 12, 0x8b44f7af);
c = ff(c, d, a, b, words[k + 10]!, 17, 0xffff5bb1);
b = ff(b, c, d, a, words[k + 11]!, 22, 0x895cd7be);
a = ff(a, b, c, d, words[k + 12]!, 7, 0x6b901122);
d = ff(d, a, b, c, words[k + 13]!, 12, 0xfd987193);
c = ff(c, d, a, b, words[k + 14]!, 17, 0xa679438e);
b = ff(b, c, d, a, words[k + 15]!, 22, 0x49b40821);
a = gg(a, b, c, d, words[k + 1]!, 5, 0xf61e2562);
d = gg(d, a, b, c, words[k + 6]!, 9, 0xc040b340);
c = gg(c, d, a, b, words[k + 11]!, 14, 0x265e5a51);
b = gg(b, c, d, a, words[k]!, 20, 0xe9b6c7aa);
a = gg(a, b, c, d, words[k + 5]!, 5, 0xd62f105d);
d = gg(d, a, b, c, words[k + 10]!, 9, 0x02441453);
c = gg(c, d, a, b, words[k + 15]!, 14, 0xd8a1e681);
b = gg(b, c, d, a, words[k + 4]!, 20, 0xe7d3fbc8);
a = gg(a, b, c, d, words[k + 9]!, 5, 0x21e1cde6);
d = gg(d, a, b, c, words[k + 14]!, 9, 0xc33707d6);
c = gg(c, d, a, b, words[k + 3]!, 14, 0xf4d50d87);
b = gg(b, c, d, a, words[k + 8]!, 20, 0x455a14ed);
a = gg(a, b, c, d, words[k + 13]!, 5, 0xa9e3e905);
d = gg(d, a, b, c, words[k + 2]!, 9, 0xfcefa3f8);
c = gg(c, d, a, b, words[k + 7]!, 14, 0x676f02d9);
b = gg(b, c, d, a, words[k + 12]!, 20, 0x8d2a4c8a);
a = hh(a, b, c, d, words[k + 5]!, 4, 0xfffa3942);
d = hh(d, a, b, c, words[k + 8]!, 11, 0x8771f681);
c = hh(c, d, a, b, words[k + 11]!, 16, 0x6d9d6122);
b = hh(b, c, d, a, words[k + 14]!, 23, 0xfde5380c);
a = hh(a, b, c, d, words[k + 1]!, 4, 0xa4beea44);
d = hh(d, a, b, c, words[k + 4]!, 11, 0x4bdecfa9);
c = hh(c, d, a, b, words[k + 7]!, 16, 0xf6bb4b60);
b = hh(b, c, d, a, words[k + 10]!, 23, 0xbebfbc70);
a = hh(a, b, c, d, words[k + 13]!, 4, 0x289b7ec6);
d = hh(d, a, b, c, words[k]!, 11, 0xeaa127fa);
c = hh(c, d, a, b, words[k + 3]!, 16, 0xd4ef3085);
b = hh(b, c, d, a, words[k + 6]!, 23, 0x04881d05);
a = hh(a, b, c, d, words[k + 9]!, 4, 0xd9d4d039);
d = hh(d, a, b, c, words[k + 12]!, 11, 0xe6db99e5);
c = hh(c, d, a, b, words[k + 15]!, 16, 0x1fa27cf8);
b = hh(b, c, d, a, words[k + 2]!, 23, 0xc4ac5665);
a = ii(a, b, c, d, words[k]!, 6, 0xf4292244);
d = ii(d, a, b, c, words[k + 7]!, 10, 0x432aff97);
c = ii(c, d, a, b, words[k + 14]!, 15, 0xab9423a7);
b = ii(b, c, d, a, words[k + 5]!, 21, 0xfc93a039);
a = ii(a, b, c, d, words[k + 12]!, 6, 0x655b59c3);
d = ii(d, a, b, c, words[k + 3]!, 10, 0x8f0ccc92);
c = ii(c, d, a, b, words[k + 10]!, 15, 0xffeff47d);
b = ii(b, c, d, a, words[k + 1]!, 21, 0x85845dd1);
a = ii(a, b, c, d, words[k + 8]!, 6, 0x6fa87e4f);
d = ii(d, a, b, c, words[k + 15]!, 10, 0xfe2ce6e0);
c = ii(c, d, a, b, words[k + 6]!, 15, 0xa3014314);
b = ii(b, c, d, a, words[k + 13]!, 21, 0x4e0811a1);
a = ii(a, b, c, d, words[k + 4]!, 6, 0xf7537e82);
d = ii(d, a, b, c, words[k + 11]!, 10, 0xbd3af235);
c = ii(c, d, a, b, words[k + 2]!, 15, 0x2ad7d2bb);
b = ii(b, c, d, a, words[k + 9]!, 21, 0xeb86d391);
a = addUnsigned(a, aa);
b = addUnsigned(b, bb);
c = addUnsigned(c, cc);
d = addUnsigned(d, dd);
}
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
}
-54
View File
@@ -1,54 +0,0 @@
const WIRE_VARINT = 0;
const WIRE_BYTES = 2;
const WIRE_FIXED32 = 5;
export class ProtobufWriter {
private readonly buf: number[] = [];
private writeVarint(value: number): this {
let v = value >>> 0;
while (v >= 0x80) {
this.buf.push((v & 0x7f) | 0x80);
v >>>= 7;
}
this.buf.push(v & 0x7f);
return this;
}
private writeKey(fieldNumber: number, wireType: number): this {
return this.writeVarint((fieldNumber << 3) | wireType);
}
varint(fieldNumber: number, value: number): this {
return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value);
}
fixed32(fieldNumber: number, value: number): this {
this.writeKey(fieldNumber, WIRE_FIXED32);
const v = value >>> 0;
this.buf.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
return this;
}
bytes(fieldNumber: number, data: ArrayLike<number>): this {
this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length);
for (let i = 0; i < data.length; i++) {
this.buf.push(data[i]! & 0xff);
}
return this;
}
string(fieldNumber: number, value: string): this {
return this.bytes(fieldNumber, new TextEncoder().encode(value));
}
message(fieldNumber: number, build: (writer: ProtobufWriter) => void): this {
const sub = new ProtobufWriter();
build(sub);
return this.bytes(fieldNumber, sub.toBytes());
}
toBytes(): Uint8Array<ArrayBuffer> {
return Uint8Array.from(this.buf);
}
}
-56
View File
@@ -1,56 +0,0 @@
import { hash } from "./index.ts";
import { getArgus } from "./argus.ts";
import { getLadon } from "./ladon.ts";
import { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
export interface UnidbgHeaders extends Record<string, string> {
"x-argus": string;
"x-ladon": string;
"x-khronos": string;
}
export default async function generateHeaders(
rawQuery: string,
xssStub = "",
timestamp: number = Math.floor(Date.now() / 1000),
config: UnidbgConfig = defaultUnidbgConfig,
): Promise<UnidbgHeaders> {
const [argus, ladon] = await Promise.all([
getArgus(rawQuery, xssStub, timestamp, config),
getLadon(timestamp, config),
]);
return {
"x-argus": argus,
"x-ladon": ladon,
"x-khronos": String(timestamp),
};
}
export async function signRequest(
url: string,
body?: ArrayBuffer | Uint8Array<ArrayBuffer> | string,
config: UnidbgConfig = defaultUnidbgConfig,
): Promise<Record<string, string>> {
const rawQuery = new URL(url).search.replace(/^\?/, "");
const hasBody = typeof body === "string" ? body.length > 0 : (body?.byteLength ?? 0) > 0;
const xssStub = hasBody ? await hash.md5(body!) : "";
const now = Date.now();
const headers: Record<string, string> = await generateHeaders(
rawQuery,
xssStub,
Math.floor(now / 1000),
config,
);
headers["x-ss-req-ticket"] = String(now);
if (hasBody) {
headers["X-SS-STUB"] = xssStub;
}
return headers;
}
export { getArgus } from "./argus.ts";
export { getLadon } from "./ladon.ts";
export { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
-47
View File
@@ -1,47 +0,0 @@
const ROUNDS = 72;
const MASK64 = 0xffffffffffffffffn;
const Z4 = 0x3dc94c3a046d678bn;
function getBit(value: bigint, position: number): bigint {
return (value >> BigInt(position)) & 1n;
}
function rotateLeft64(v: bigint, n: bigint): bigint {
return ((v << n) | (v >> (64n - n))) & MASK64;
}
function rotateRight64(v: bigint, n: bigint): bigint {
return ((v << (64n - n)) | (v >> n)) & MASK64;
}
function keyExpansion(key: readonly [bigint, bigint, bigint, bigint]): bigint[] {
const k: bigint[] = [key[0] & MASK64, key[1] & MASK64, key[2] & MASK64, key[3] & MASK64];
for (let i = 4; i < ROUNDS; i++) {
let tmp = rotateRight64(k[i - 1]!, 3n);
tmp ^= k[i - 3]!;
tmp ^= rotateRight64(tmp, 1n);
k.push((~k[i - 4]! ^ tmp ^ getBit(Z4, (i - 4) % 62) ^ 3n) & MASK64);
}
return k;
}
export function simonEncrypt(
plaintext: readonly [bigint, bigint],
key: readonly [bigint, bigint, bigint, bigint],
): [bigint, bigint] {
const k = keyExpansion(key);
let x = plaintext[0] & MASK64;
let y = plaintext[1] & MASK64;
for (let i = 0; i < ROUNDS; i++) {
const tmp = y;
const f = rotateLeft64(y, 1n) & rotateLeft64(y, 8n);
y = (x ^ f ^ rotateLeft64(y, 2n) ^ k[i]!) & MASK64;
x = tmp;
}
return [x, y];
}
-158
View File
@@ -1,158 +0,0 @@
// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js
type Bytes = ArrayLike<number>;
/**
* 循环左移
*/
function rotl(value: number, shift: number): number {
const normalizedShift = shift & 31;
return (value << normalizedShift) | (value >>> (32 - normalizedShift));
}
/**
* 二进制异或运算
*/
function xor(left: Bytes, right: Bytes): Uint8Array {
if (left.length !== right.length) {
throw new Error("XOR inputs must have the same length");
}
const result = new Uint8Array(left.length);
for (let i = 0; i < left.length; i++) {
result[i] = (left[i]! ^ right[i]!) & 0xff;
}
return result;
}
/**
* 压缩函数中的置换函数 P0(X) = X xor (X <<< 9) xor (X <<< 17)
*/
function p0(value: number): number {
return (value ^ rotl(value, 9)) ^ rotl(value, 17);
}
/**
* 消息扩展中的置换函数 P1(X) = X xor (X <<< 15) xor (X <<< 23)
*/
function p1(value: number): number {
return (value ^ rotl(value, 15)) ^ rotl(value, 23);
}
/**
* 计算 SM3 摘要。
*/
export function sm3(input: Bytes): Uint8Array<ArrayBuffer> {
const inputLength = input.length;
const paddedLength = Math.ceil((inputLength + 9) / 64) * 64;
const message = new Uint8Array(paddedLength);
message.set(input);
message[inputLength] = 0x80;
const bitLength = BigInt(inputLength) * 8n;
for (let i = 0; i < 8; i++) {
message[paddedLength - 1 - i] = Number((bitLength >> BigInt(i * 8)) & 0xffn);
}
const dataView = new DataView(message.buffer);
const state = new Uint32Array([
0x7380166f,
0x4914b2b9,
0x172442d7,
0xda8a0600,
0xa96f30bc,
0x163138aa,
0xe38dee4d,
0xb0fb0e4e,
]);
const words = new Uint32Array(68);
const expandedWords = new Uint32Array(64);
for (let block = 0; block < message.length / 64; block++) {
const start = block * 64;
for (let i = 0; i < 16; i++) {
words[i] = dataView.getUint32(start + i * 4, false);
}
for (let i = 16; i < 68; i++) {
words[i] = (p1((words[i - 16]! ^ words[i - 9]!) ^ rotl(words[i - 3]!, 15))
^ rotl(words[i - 13]!, 7))
^ words[i - 6]!;
}
for (let i = 0; i < 64; i++) {
expandedWords[i] = words[i]! ^ words[i + 4]!;
}
let a = state[0]!;
let b = state[1]!;
let c = state[2]!;
let d = state[3]!;
let e = state[4]!;
let f = state[5]!;
let g = state[6]!;
let h = state[7]!;
for (let i = 0; i < 64; i++) {
const t = i <= 15 ? 0x79cc4519 : 0x7a879d8a;
const ss1 = rotl(rotl(a, 12) + e + rotl(t, i), 7);
const ss2 = ss1 ^ rotl(a, 12);
const tt1 = (i <= 15 ? (a ^ b) ^ c : ((a & b) | (a & c)) | (b & c))
+ d + ss2 + expandedWords[i]!;
const tt2 = (i <= 15 ? (e ^ f) ^ g : (e & f) | (~e & g))
+ h + ss1 + words[i]!;
d = c;
c = rotl(b, 9);
b = a;
a = tt1;
h = g;
g = rotl(f, 19);
f = e;
e = p0(tt2);
}
state[0] = state[0]! ^ a;
state[1] = state[1]! ^ b;
state[2] = state[2]! ^ c;
state[3] = state[3]! ^ d;
state[4] = state[4]! ^ e;
state[5] = state[5]! ^ f;
state[6] = state[6]! ^ g;
state[7] = state[7]! ^ h;
}
const result = new Uint8Array(32);
for (let i = 0; i < state.length; i++) {
const word = state[i]!;
result[i * 4] = word >>> 24;
result[i * 4 + 1] = word >>> 16;
result[i * 4 + 2] = word >>> 8;
result[i * 4 + 3] = word;
}
return result;
}
/**
* 计算 HMAC-SM3。
*/
export function hmac(input: Bytes, key: Bytes): Uint8Array<ArrayBuffer> {
const blockLength = 64;
const normalizedKey = key.length > blockLength ? sm3(key) : Uint8Array.from(key);
const paddedKey = new Uint8Array(blockLength);
paddedKey.set(normalizedKey);
const innerPad = new Uint8Array(blockLength).fill(0x36);
const outerPad = new Uint8Array(blockLength).fill(0x5c);
const innerKey = xor(paddedKey, innerPad);
const outerKey = xor(paddedKey, outerPad);
const innerMessage = new Uint8Array(blockLength + input.length);
innerMessage.set(innerKey);
innerMessage.set(input, blockLength);
const outerMessage = new Uint8Array(blockLength + 32);
outerMessage.set(outerKey);
outerMessage.set(sm3(innerMessage), blockLength);
return sm3(outerMessage);
}
-79
View File
@@ -1,79 +0,0 @@
import { hash, pkcs7Pad } from "./index.ts";
const ROUNDS = 34;
const MASK64 = 0xffffffffffffffffn;
const WORD_SIZE = 64n;
const ALPHA = 8n;
const BETA = 3n;
function readUint64LE(view: DataView, offset: number): bigint {
return view.getBigUint64(offset, true);
}
function keySchedule(key: Uint8Array): bigint[] {
const view = new DataView(key.buffer, key.byteOffset, key.byteLength);
const ks: bigint[] = [readUint64LE(view, 0) & MASK64];
const numWords = (key.length * 8) / Number(WORD_SIZE);
const ls: bigint[] = [];
for (let i = 1; i < numWords; i++) {
ls.push(readUint64LE(view, i * 8) & MASK64);
}
for (let x = 0; x < ROUNDS - 1; x++) {
const rsX = (((ls[x]! << (WORD_SIZE - ALPHA)) + (ls[x]! >> ALPHA)) & MASK64);
const addSxy = (rsX + ks[x]!) & MASK64;
const newX = BigInt(x) ^ addSxy;
const lsY = (((ks[x]! >> (WORD_SIZE - BETA)) + (ks[x]! << BETA)) & MASK64);
ls.push(newX);
ks.push(newX ^ lsY);
}
return ks;
}
function encryptBlock(ks: readonly bigint[], block: Uint8Array, out: Uint8Array, outOffset: number): void {
const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
let y = readUint64LE(view, 0);
let x = readUint64LE(view, 8);
for (const k of ks) {
const rsX = (((x << (WORD_SIZE - ALPHA)) + (x >> ALPHA)) & MASK64);
const addSxy = (rsX + y) & MASK64;
x = k ^ addSxy;
const lsY = (((y >> (WORD_SIZE - BETA)) + (y << BETA)) & MASK64);
y = x ^ lsY;
}
const outView = new DataView(out.buffer, out.byteOffset, out.byteLength);
outView.setBigUint64(outOffset, y & MASK64, true);
outView.setBigUint64(outOffset + 8, x & MASK64, true);
}
export function speckEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array<ArrayBuffer> {
if (key.length !== 32) {
throw new Error(`Speck key must be 32 bytes, got ${key.length}`);
}
const padded = pkcs7Pad(plaintext, 16);
const ks = keySchedule(key);
const out = new Uint8Array(padded.length);
for (let i = 0; i < padded.length; i += 16) {
encryptBlock(ks, padded.subarray(i, i + 16), out, i);
}
return out;
}
export async function generateLadonKey(randomBytes: Uint8Array, aid: string): Promise<Uint8Array<ArrayBuffer>> {
const aidBytes = new TextEncoder().encode(aid);
const input = new Uint8Array(randomBytes.length + aidBytes.length);
input.set(randomBytes);
input.set(aidBytes, randomBytes.length);
const hex = await hash.md5(input);
return new TextEncoder().encode(hex);
}
-26
View File
@@ -1,26 +0,0 @@
// TT 加密(设备注册请求体):gzip + SHA512 校验 + AES-128-CBC,magic 头 "tc"
// 移植自 fanqie-assistant src/crypto/ttencrypt.ts(Node crypto 实现)
import { createCipheriv, createHash, randomBytes } from 'node:crypto'
import { gzipSync } from 'node:zlib'
// 固定盐(base64 常量,社区共知)
const FIXED_STRING = Buffer.from(
'TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==',
'base64',
)
const sha512 = (data: Buffer): Buffer => createHash('sha512').update(data).digest()
export const encryptTT = (data: Buffer): Buffer => {
const rand = randomBytes(32)
const hashValue = sha512(Buffer.concat([sha512(rand), FIXED_STRING]))
const key = hashValue.subarray(0, 16)
const iv = hashValue.subarray(16, 32)
const compressed = gzipSync(data)
const payload = Buffer.concat([sha512(compressed), compressed])
const cipher = createCipheriv('aes-128-cbc', key, iv)
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()])
return Buffer.concat([Buffer.from([116, 99, 5, 16, 0, 0]), rand, encrypted])
}
export default { encryptTT }
-27
View File
@@ -1,27 +0,0 @@
// 汽水音乐(luna, aid=386088)签名配置
// signKey/licenseId 是字节 metasec SDK 家族(v04.04.05-ov-android)共用密钥——经 ssovit(TikTok
// aid=1233)/fanqie-assistant(番茄 aid=1967)/本项目(汽水 aid=386088) 三方同源实证;per-app 的只有
// 载荷里的 msAppID(argus protobuf field 4,由 config.aid 提供)。metasec license blob(native
// SDK 运行时 init 用)不参与离线签名。
import { unhex } from "./index.ts";
export interface UnidbgConfig {
signKey: Uint8Array;
aid: string;
licenseId: string;
sdkVersion: string;
sdkVersionInt: number;
callType: number;
secDeviceId?: string;
}
export const defaultUnidbgConfig: UnidbgConfig = {
signKey: new Uint8Array(
unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"),
),
aid: "386088",
licenseId: "1611921764",
sdkVersion: "v04.04.05-ov-android",
sdkVersionInt: 134744640,
callType: 738,
};
-171
View File
@@ -1,171 +0,0 @@
// 汽水音乐设备注册 —— i.snssdk.com 统一设备服务(按 body 里的 aid 分 app)
// 接口: POST https://i.snssdk.com/service/2/device_register/?tt_data=a(TT 加密 body,免签名)
// 实测(2026-10-01)aid=386088 可注册成功,返回 device_id/install_id/device_token。
// 设备身份持久化于 data/auth.json 的 _qs_device 字段(复用注册=同一台"设备",避免重复注册)
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts'
import { encryptTT } from './crypto/ttencrypt.ts'
const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a'
// 与签名查询参数一致的设备画像(注册后不再变化)
const DEVICE_FINGERPRINT = {
device_type: 'ABR-AL80',
device_brand: 'HUAWEI',
device_model: 'ABR-AL80',
os_version: '15',
os_api: 35,
resolution: '1080*1920',
dpi: 480,
channel: 'xiaomi_8478_64',
}
const ANDROID_VERSIONS = ['10', '11', '12', '13', '14']
const HEX_LOW = '0123456789abcdef'
const pick = (list: readonly string[]) => list[Math.floor(Math.random() * list.length)]
const randomHex = (k: number) => Array.from({ length: k }, () => HEX_LOW[Math.floor(Math.random() * 16)]).join('')
const uuid = () => crypto.randomUUID()
export interface QsDevice {
device_id: string
install_id: string
device_token?: string
device_type: string
device_brand: string
device_model: string
os_version: string
channel: string
openudid: string
cdid: string
registeredAt?: string
}
const buildRequestBody = () => {
const osVersion = pick(ANDROID_VERSIONS)
const genTime = Date.now()
return {
magic_tag: 'ss_app_log',
header: {
display_name: '汽水音乐',
aid: 386088,
channel: DEVICE_FINGERPRINT.channel,
package: 'com.luna.music',
sdk_version: '3.7.0-rc.25-luna-music',
sdk_target_version: 29,
git_hash: '711d1a7',
density_dpi: DEVICE_FINGERPRINT.dpi,
display_density: 'xhdpi',
resolution: DEVICE_FINGERPRINT.resolution,
language: 'zh',
timezone: 8,
access: 'wifi',
not_request_sender: 0,
carrier: 'CHINA MOBILE',
mcc_mnc: '46000',
region: 'CN',
tz_name: 'Asia/Shanghai',
tz_offset: 28800,
sim_region: 'cn',
sim_serial_number: [],
oaid_may_support: false,
device_platform: 'android',
custom: { host_bit: 32 },
pre_installed_channel: '',
is_system_app: 0,
sdk_flavor: 'china',
guest_mode: 0,
os: 'Android',
os_version: osVersion,
os_api: DEVICE_FINGERPRINT.os_api,
device_model: DEVICE_FINGERPRINT.device_model,
device_brand: DEVICE_FINGERPRINT.device_brand,
device_manufacturer: DEVICE_FINGERPRINT.device_brand,
cpu_abi: 'arm64-v8a',
release_build: randomHex(7),
cdid: uuid(),
sig_hash: 'a4a27c2633195374c15651ffc3c4a497',
openudid: randomHex(20),
clientudid: uuid(),
req_id: uuid(),
rom: randomHex(14).toUpperCase(),
apk_first_install_time: genTime - Math.floor(Math.random() * 365) * 86_400_000,
ipv6_list: [
{ type: 'client_anpi', value: '::1' },
{ type: 'client_anpi', value: 'fd9d:8e11:8c31:2a2c:0:0:0:1' },
],
},
_gen_time: genTime,
}
}
/** 注册新设备并持久化 */
export const registerDevice = async (): Promise<QsDevice> => {
const body = buildRequestBody()
const encrypted = encryptTT(Buffer.from(JSON.stringify(body)))
const request = httpFetch(REGISTER_URL, {
method: 'post',
headers: {
'User-Agent': 'okhttp/4.10.0',
'Content-Type': 'application/octet-stream; tt-data=a',
},
body: encrypted,
timeout: 15000,
})
const { statusCode, body: resBody } = await request.promise
const data = resBody as any
if (statusCode != 200 || !data?.device_id_str || !data?.install_id_str) {
throw new Error(`设备注册失败: HTTP ${statusCode} ${JSON.stringify(data).slice(0, 120)}`)
}
const device: QsDevice = {
device_id: String(data.device_id_str),
install_id: String(data.install_id_str),
device_token: data.device_token ? String(data.device_token) : '',
device_type: DEVICE_FINGERPRINT.device_type,
device_brand: DEVICE_FINGERPRINT.device_brand,
device_model: DEVICE_FINGERPRINT.device_model,
os_version: DEVICE_FINGERPRINT.os_version,
channel: DEVICE_FINGERPRINT.channel,
openudid: body.header.openudid,
cdid: body.header.cdid,
}
setCredentials('_qs_device', device as any)
console.log(`[qs/device] 设备注册成功: device_id=${device.device_id}`)
return device
}
let cachedDevice: QsDevice | null = null
/** 取当前设备(无则注册并持久化) */
export const getDevice = async (): Promise<QsDevice> => {
if (cachedDevice) return cachedDevice
const stored = getCredentials('_qs_device') as QsDevice | null
if (stored?.device_id && stored?.install_id) {
cachedDevice = stored
return cachedDevice
}
cachedDevice = await registerDevice()
return cachedDevice
}
/** 设备是否已注册(不触发注册) */
export const hasDevice = (): boolean =>
Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id)
/**
* 设备身份(同步读,不触发注册):已注册设备优先;尚未注册时用独立生成的
* `_qs_passport_device`,避免覆盖注册记录。护照/签名模块共用,保证「同一设备」。
*/
export const getDeviceIds = (): { deviceId: string; installId: string } => {
const registered = getCredentials('_qs_device') as QsDevice | null
if (registered?.device_id && registered?.install_id) {
return { deviceId: String(registered.device_id), installId: String(registered.install_id) }
}
const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null
if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) }
const now = Date.now()
const ids = { deviceId: String(now), installId: String(now + 1) }
setCredentials('_qs_passport_device', ids)
return ids
}
export default { registerDevice, getDevice, hasDevice, getDeviceIds }
+4 -3
View File
@@ -1,7 +1,8 @@
// 汽水音乐(字节跳动)平台模块 // 汽水音乐(字节跳动)平台模块
// 匿名可用面(2026-10-01 实测):搜索 / 曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。 // 匿名可用面(2026-10-01 实测):曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。
// 登录态(/login/qs/qr 抖音扫码):设备注册 + X-Argus/X-Ladon 自签走 /luna/pc/track_v2, // 搜索与登录态取流需 PC 签名(QS_SIGN_SERVER 或固定值,见 sdk/qs/mssdk.ts 与 qs-signer 仓库):
// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测) // 登录态(/login/qs/qr 扫码)走 X-Helios/X-Medusa 签名 /luna/pc/track_v2,
// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测)
import qsApi, { getStreamBuffer } from '../apis/qs.ts' import qsApi, { getStreamBuffer } from '../apis/qs.ts'
import getLyric from './lyric.ts' import getLyric from './lyric.ts'
import getMusicInfo, { coverUrl } from './musicInfo.ts' import getMusicInfo, { coverUrl } from './musicInfo.ts'
+59 -14
View File
@@ -1,28 +1,66 @@
// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 两种供给方式,按优先级: // 汽水 PC 通道签名(X-Helios / X-Medusa)—— 三种供给方式,按优先级:
// 1) 签名服务器:环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:8899, // 1) 签名服务器(显式):环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:16889,
// 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。 // 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。
// 契约同时兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级) // 契约兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级)
// 的两个服务(请求 {url, headers, body?},响应 {ok, headers, added?}): // 的 mssdk-sign 服务(请求 {url, headers, body?},响应 {ok, headers, added?})。
// - server.mjs (Wine + bdms.node) // 2) 签名服务器(自动探测):QS_SIGN_SERVER 未配置时,进程启动探测本机 16689 端口——
// - mssdk-sign/ (metasecml.dll 独立进程,见其 README) // GET /healthz 应答 ok:true 且 service:"mssdk-sign" 才认定为汽水签名服务并自动接入
// 2) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 —— // (16689 上是别的服务则忽略,端口没人听静默跳过,均回落到 3)。
// 3) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 ——
// 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json: // 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json:
// { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" } // { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
// 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id // 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id
// (json 里带上即自动对齐 URL 参数),时效自负,被风控时走安卓通道兜底。 // (json 里带上即自动对齐 URL 参数),时效自负。
// 两者都没有:PC 通道不可用,搜索/取流自动走安卓自签通道。 // 两者都没有:PC 签名不可用 —— 搜索/取流走 track_v2 的通道直接报错(匿名试听不受影响)。
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。 // 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。 // 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { getDeviceIds } from './device.ts' import { getDeviceIds } from './passport.ts'
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到 // 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const FIXED_SIGN_FILE = join(REPO_ROOT, 'data', 'qs-sign.json') const FIXED_SIGN_FILE = join(REPO_ROOT, 'data', 'qs-sign.json')
const SIGN_TIMEOUT = 5000 const SIGN_TIMEOUT = 5000
// —— 端口探测:QS_SIGN_SERVER 未配置时,启动探测本机 16689 是不是汽水签名服务 ——
const PROBE_BASE = 'http://127.0.0.1:16689'
const PROBE_TIMEOUT = 2000
// undefined=探测未完成;null=探测过、不是签名服务;字符串=探测到的签名服务地址
let probedServer: string | null | undefined
let probing: Promise<void> | null = null
/** healthz 应答 ok:true 且 service:"mssdk-sign" 才认——16689 上可能是无关服务 */
const isSignService = (json: any): boolean => json?.ok === true && json?.service === 'mssdk-sign'
const probeOnce = async (): Promise<void> => {
try {
const res = await fetch(`${PROBE_BASE}/healthz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT) })
const json = await res.json().catch(() => null) as any
if (res.ok && isSignService(json)) {
probedServer = PROBE_BASE
console.log(`[qs/mssdk] 探测到本机 16689 是汽水签名服务(mssdk-sign v${json.version ?? '?'},dll ${json.dllVersion ?? '?'}),自动接入`)
} else {
probedServer = null
console.log(`[qs/mssdk] 本机 16689 有服务但不是汽水签名服务(healthz service=${json?.service ?? '非 JSON'}),忽略`)
}
} catch {
probedServer = null // 端口没人监听是常态,静默
}
}
/** 确保探测完成(进程内只探一次);显式配置了 QS_SIGN_SERVER 则永不探测 */
const ensureProbe = async (): Promise<void> => {
if (probedServer !== undefined || process.env.QS_SIGN_SERVER?.trim()) return
probing ??= probeOnce()
await probing
}
// 启动即探测一次(异步进行,不阻塞导入);探测期间进来的签名调用会等它出结果
void ensureProbe()
/** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */ /** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */
interface FixedSignConfig { interface FixedSignConfig {
helios: string helios: string
@@ -75,16 +113,22 @@ type SignMode =
| { kind: 'server'; server: string; token: string } | { kind: 'server'; server: string; token: string }
| { kind: 'fixed'; fixed: FixedSignConfig } | { kind: 'fixed'; fixed: FixedSignConfig }
/** 当前签名供给方式:签名服务器 > 固定值 > 不可用(null) */ /** 当前签名供给方式:显式服务器 > 探测接入的服务器 > 固定值 > 不可用(null) */
const loadSignMode = (): SignMode | null => { const loadSignMode = (): SignMode | null => {
const server = process.env.QS_SIGN_SERVER?.trim().replace(/\/+$/, '') const server = process.env.QS_SIGN_SERVER?.trim().replace(/\/+$/, '')
if (server) return { kind: 'server', server, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' } if (server) return { kind: 'server', server, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' }
if (probedServer) {
// 探测接入;签名服务开了鉴权时同样吃 QS_SIGN_TOKEN
return { kind: 'server', server: probedServer, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' }
}
const fixed = readFixedConfig() const fixed = readFixedConfig()
return fixed ? { kind: 'fixed', fixed } : null return fixed ? { kind: 'fixed', fixed } : null
} }
/** PC 签名是否可用(只看配置;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */ /** PC 签名是否可用(只看配置与探测结果;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */
export const mssdkAvailable = (): boolean => Boolean(loadSignMode()) export const mssdkAvailable = (): boolean =>
// 探测未完成时先放行——紧随其后的 signQishuiRequest 会等探测结果再定供给
Boolean(loadSignMode()) || probedServer === undefined
const signViaServer = async ( const signViaServer = async (
server: string, server: string,
@@ -125,13 +169,14 @@ const signViaServer = async (
* 给一个 qishui.com 请求签名 * 给一个 qishui.com 请求签名
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub) * @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
* @param body POST body(给了且 headers 没带 x-ss-stub 时服务端会自动补) * @param body POST body(给了且 headers 没带 x-ss-stub 时服务端会自动补)
* @returns 需追加的头;不可用时返回 null(调用方走安卓通道) * @returns 需追加的头;不可用时返回 null(调用方自行降级处理)
*/ */
export const signQishuiRequest = async ( export const signQishuiRequest = async (
url: string, url: string,
headers: Record<string, string>, headers: Record<string, string>,
body?: string, body?: string,
): Promise<Record<string, string> | null> => { ): Promise<Record<string, string> | null> => {
await ensureProbe() // 探测未完成时先等它出结果,再决定供给方式
const mode = loadSignMode() const mode = loadSignMode()
if (!mode) return null if (!mode) return null
if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa } if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa }
+27 -110
View File
@@ -1,86 +1,26 @@
// 汽水音乐搜索(App 搜索接口直连) // 汽水音乐搜索(App 搜索接口直连)
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor= // 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
// 两条通道(同一接口,差在公共参数与签名,返回结构一致): // 官方 PC 通道: luna_pc 参数 + X-Helios/X-Medusa 签名(签名服务器或固定值,见 sdk/qs/mssdk.ts)。
// 官方 PC 通道: PC 签名可用(签名服务器或固定值,见 sdk/qs/mssdk.ts)时优先—— // PC 签名不可用时搜索直接报错;被风控时上游返回 200 + 空 body(无 result_groups)。
// luna_pc 参数 + X-Helios/X-Medusa;签名失败/被风控自动降级安卓通道;
// 安卓指纹通道: 静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控(200 + 空 body);
// 被风控时需换新指纹。
import { sizeFormate, formatPlayTime } from '../index.ts' import { sizeFormate, formatPlayTime } from '../index.ts'
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { coverUrl } from './musicInfo.ts' import { coverUrl } from './musicInfo.ts'
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts' import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts'
// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新)
const SEARCH_DEVICE_PARAMS = {
device_platform: 'android',
os: 'android',
ssmix: 'a',
cdid: '46556f98-1720-4248-83da-62b74b60b46a',
channel: 'xiaomi_8478_64',
aid: '386088',
app_name: 'luna',
version_code: '100198030',
version_name: '19.8.0',
manifest_version_code: '100198030',
update_version_code: '100198030',
resolution: '1080*1920',
dpi: '480',
device_type: 'ABR-AL80',
device_brand: 'HUAWEI',
language: 'zh',
os_api: '35',
os_version: '15',
ac: 'wifi',
device_model: 'ABR-AL80',
save_power: '0',
font_size: '1.00',
luna_first_launch_apk_type: 'normal_apk',
diversion_channel_name: 'xiaomi_8478_64',
is_car_play: '0',
battery: '0.99',
network_speed: '10156',
hybrid_version_code: '100198030',
tz_name: 'Asia/Shanghai',
tz_offset: '28800',
luna_register_time: '1784311292',
package: 'com.luna.music',
charge: '0',
luna_apk_type: 'normal_apk',
output_device_type: 'Phone',
volume: '1.00',
brightness: '0.08',
need_personal_recommend: '1',
is_teen_mode: '0',
sim_region: 'cn',
android_device_type: 'default',
iid: '2204957404569386',
device_id: '2204957404565290',
}
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
const PC_PAGE_SIZE = 20 const PC_PAGE_SIZE = 20
// PC 通道必须配 X-Helios/X-Medusa(否则上游 200 + 空 body);安卓通道用静态设备指纹 const buildSearchUrl = (str: string, cursor: number): string => {
const buildSearchUrl = (str: string, cursor: number, count: number, pcChannel: boolean) => { const params = new URLSearchParams({
const params = pcChannel ...pcCommonParams(),
? new URLSearchParams({ q: str,
...pcCommonParams(), cursor: String(cursor),
q: str, search_id: '',
cursor: String(cursor), search_method: 'input',
search_id: '', debug_params: '',
search_method: 'input', from_search_id: '',
debug_params: '', search_scene: 'normal',
from_search_id: '', scene_name: 'search',
search_scene: 'normal', })
scene_name: 'search',
})
: new URLSearchParams({
...SEARCH_DEVICE_PARAMS,
_rticket: String(Date.now()),
q: str,
cursor: String(cursor),
count: String(count),
})
return `https://api.qishui.com/luna/search/track?${params.toString()}` return `https://api.qishui.com/luna/search/track?${params.toString()}`
} }
@@ -131,44 +71,21 @@ export default {
allPage: this.allPage, allPage: this.allPage,
limit: this.limit, limit: this.limit,
total, total,
source: 'qs', source: 'qs' as const,
} }
}, },
async search(str, page = 1, limit, retryNum = 0, forceAndroid = false) { async search(str, page = 1) {
if (++retryNum > 3) return Promise.reject(new Error('try max num')) if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)')
const pcChannel = !forceAndroid && mssdkAvailable() // PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
if (pcChannel) { const cursor = PC_PAGE_SIZE * (page - 1)
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠 const url = buildSearchUrl(str, cursor)
const pageSize = PC_PAGE_SIZE // 签名覆盖真实发出的头:先定头、再签、再发
const cursor = pageSize * (page - 1) const headers: Record<string, string> = { 'User-Agent': PC_UA }
const url = buildSearchUrl(str, cursor, pageSize, true) const signed = await signQishuiRequest(url, headers)
// 签名覆盖真实发出的头:先定头、再签、再发 if (!signed) throw new Error('官方签名失败')
const headers: Record<string, string> = { 'User-Agent': PC_UA } const { body } = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise
const fallbackToAndroid = (reason: string) => { // 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}})
console.log(`[qs/search] PC 通道${reason},降级安卓通道`) if (!body?.result_groups) throw new Error('搜索无结果(疑似风控)')
return this.search(str, page, limit, retryNum, true)
}
let body: any = null
try {
// 签名失败(服务器宕机/固定值被风控)与请求异常都降级安卓通道
const signed = await signQishuiRequest(url, headers)
if (!signed) return fallbackToAndroid('签名不可用')
const searchRequest = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise
body = searchRequest.body
} catch (err: any) {
return fallbackToAndroid(`请求失败: ${err?.message ?? err}`)
}
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}})
if (!body?.result_groups) return fallbackToAndroid('空结果(疑似风控)')
return this.handleSearchBody(body, cursor, page)
}
// 安卓指纹通道:无签名直发,空结果时换 _rticket 重试
const pageSize = limit ?? this.limit
const cursor = pageSize * (page - 1)
const url = buildSearchUrl(str, cursor, pageSize, false)
const headers: Record<string, string> = { 'User-Agent': SEARCH_UA }
const { body } = await httpFetch(url, { method: 'get', headers, timeout: 15000 }).promise
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
return this.handleSearchBody(body, cursor, page) return this.handleSearchBody(body, cursor, page)
}, },
} }
+105 -2
View File
@@ -11,7 +11,6 @@ import os from 'node:os'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts' import { getCredentials, setCredentials } from '../auth/store.ts'
import { getDeviceIds } from './device.ts'
const PASSPORT_HOST = 'https://api.qishui.com' const PASSPORT_HOST = 'https://api.qishui.com'
const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login' const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login'
@@ -30,6 +29,19 @@ const PASSPORT_FIXED = {
version_code: '30020100', version_code: '30020100',
} }
/**
* PC 设备身份(同步读,首次懒生成并持久化):passport 登录与 PC 签名公共参数共用,
* 持久化于 data/auth.json 的 `_qs_passport_device`,保证同一实例「同一设备」。
*/
export const getDeviceIds = (): { deviceId: string; installId: string } => {
const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null
if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) }
const now = Date.now()
const ids = { deviceId: String(now), installId: String(now + 1) }
setCredentials('_qs_passport_device', ids)
return ids
}
// 各 passport 请求共用的公共参数(设备身份 + 平台/版本) // 各 passport 请求共用的公共参数(设备身份 + 平台/版本)
const commonParams = (): Record<string, string> => { const commonParams = (): Record<string, string> => {
const { deviceId, installId } = getDeviceIds() const { deviceId, installId } = getDeviceIds()
@@ -260,7 +272,98 @@ export const status = (): { logged: boolean; updatedAt?: string } => {
/** 登录态 sessionid(未登录返回空串) */ /** 登录态 sessionid(未登录返回空串) */
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || '' export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
// ---------- 手机验证码登录(接口与官方 PC 客户端 login.asar 的 WebInterfaceSdk 同款)----------
// POST /passport/web/send_code/ { mobile: "86 13800138000", type: 24 }
// POST /passport/web/sms_login/ { mobile, code } → Set-Cookie sessionid
/** 官方 SDK 的 mobile 参数是 "区号 号码" 空格拼接(如 "86 13800138000") */
const normalizeMobile = (mobile: string, cc = '86'): string => {
const s = String(mobile || '').trim()
const withCc = s.match(/^(\d{1,3})[\s-]+(\d{4,15})$/)
if (withCc) return `${withCc[1]} ${withCc[2]}`
return `${cc} ${s.replace(/\D/g, '')}`
}
const smsUpstream = async (path: string, form: Record<string, string>, cookie = ''): Promise<{ data: Record<string, any>; message: string; headers: Record<string, any> }> => {
const query = new URLSearchParams(commonParams())
const request = httpFetch(`${PASSPORT_HOST}${path}?${query}`, {
method: 'post',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...webHeaders(cookie) },
body: new URLSearchParams(form).toString(),
timeout: 15000,
})
const { statusCode, headers, body } = await request.promise
if (statusCode != 200) throw Object.assign(new Error(`汽水接口 HTTP ${statusCode}`), { status: 502 })
return { data: (body as any)?.data ?? {}, message: String((body as any)?.message ?? ''), headers }
}
// send_code 下发的 cookie(验证码会话),sms_login 时带上
const smsCookies = new Map<string, string>()
export const sendSmsCode = async (mobile: string, cc?: string): Promise<{ sent: true; mobile: string }> => {
const norm = normalizeMobile(mobile, cc)
const { data, message, headers } = await smsUpstream('/passport/web/send_code/', {
mobile: norm,
type: '24', // 官方登录页固定 24(登录注册);16 为仅注册
})
if (message !== 'success') {
throw Object.assign(new Error(data.description || `验证码发送失败 (code=${data.error_code})`), { status: 400 })
}
smsCookies.set(norm, mergeCookies('', cookieMapFrom(headers)))
return { sent: true, mobile: norm }
}
// 官方登录页的失败文案映射(login-7a4b1afd.js catch 分支)
const smsFailMessage = (data: Record<string, any>): string => {
if (data.error_code === 1075) return '账号注销中,请前往抖音放弃注销'
if (data.error_code === 2046) return '需要二次验证,请在汽水 App 完成安全验证后重试'
return data.description || `登录失败 (code=${data.error_code})`
}
export const smsLogin = async (mobile: string, code: string, cc?: string): Promise<Record<string, any>> => {
const norm = normalizeMobile(mobile, cc)
const { data, message, headers } = await smsUpstream('/passport/web/sms_login/', {
mobile: norm,
code: String(code ?? '').trim(),
}, smsCookies.get(norm) || '')
let cookie = mergeCookies(smsCookies.get(norm) || '', cookieMapFrom(headers))
smsCookies.delete(norm)
// 成功但 sessionid 未随本响应下发时补跳 redirect_url(与扫码确认同款)
if (message === 'success' && !sessionIdOf(cookie) && data.redirect_url) {
try {
const hop = await httpFetch(String(data.redirect_url), { method: 'get', headers: webHeaders(cookie), timeout: 15000 }).promise
cookie = mergeCookies(cookie, cookieMapFrom(hop.headers))
} catch {
// 补跳失败不致命:sessionid 缺失时按失败处理
}
}
const sessionid = sessionIdOf(cookie)
if (message === 'success' && sessionid) {
setCredentials('qs', { sessionid, cookie })
return { code: 803, message: '登录成功', errorCode: 0 }
}
throw Object.assign(new Error(message === 'success' ? '登录响应缺少 sessionid' : smsFailMessage(data)), { status: 400 })
}
/**
* 手动绑定 sessionid(汽水 App / 官方 PC 客户端抓包,或已登录浏览器 Cookie)。
* 接受裸 sessionid 值或完整 cookie 串(须含 sessionid= 字段);完整串优先整体保存,
* sid_guard 等伴随字段有利于延长登录态寿命。passport 无可用的免签校验接口
* (/passport/web/user_info/ 对本 aid 恒报 20016 无权限),不做上游验证,失效会在取流时暴露。
*/
export const loginBySessionId = async(input: string): Promise<{ logged: true; sessionid: string }> => {
const raw = String(input || '').trim()
const sessionid = sessionIdOf(raw) || (/^[\w-]{10,}$/.test(raw) ? raw : '')
if (!sessionid) throw Object.assign(new Error('需提供 sessionid 值或含 sessionid= 字段的 cookie'), { status: 422 })
const cookie = raw.includes('=') ? raw : `sessionid=${sessionid}`
setCredentials('qs', { sessionid, cookie })
return { logged: true, sessionid }
}
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */ /** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || '' export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus } export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus, sendSmsCode, smsLogin, loginBySessionId }
+1 -1
View File
@@ -4,7 +4,7 @@
/** 音乐平台标识 */ /** 音乐平台标识 */
export type Source = export type Source =
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
| 'mg' | 'bd' | 'jx' | 'bl' | '5s' | 'ap' | 'mg' | 'bd' | 'joox' | 'bili' | '5sing' | 'apple'
| 'xm' | 'me' | 'qt' | 'lr' | 'xm' | 'me' | 'qt' | 'lr'
/** 音质标识(wav 仅部分接口声明使用) */ /** 音质标识(wav 仅部分接口声明使用) */
File renamed without changes.
+96 -14
View File
@@ -1,4 +1,5 @@
// 登录路由 —— wy 扫码/手机号,kg 扫码/短信/token,tx 扫码/cookie 绑定,凭据持久化于 data/auth.json // 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie,qs 扫码/短信/sessionid,
// xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data } // 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
import * as wyAuth from '../sdk/auth/wy.ts' import * as wyAuth from '../sdk/auth/wy.ts'
@@ -8,7 +9,7 @@ import * as qsPassport from '../sdk/qs/passport.ts'
import * as biliAuth from '../sdk/bili/login.ts' import * as biliAuth from '../sdk/bili/login.ts'
import { status as xmStatus } from '../sdk/xm/index.ts' import { status as xmStatus } from '../sdk/xm/index.ts'
import { status as meStatus } from '../sdk/me/index.ts' import { status as meStatus } from '../sdk/me/index.ts'
import { status as qianStatus } from '../sdk/qian/index.ts' import { status as bdStatus } from '../sdk/bd/index.ts'
import { status as jooxStatus } from '../sdk/joox/index.ts' import { status as jooxStatus } from '../sdk/joox/index.ts'
import { status as appleStatus } from '../sdk/apple/index.ts' import { status as appleStatus } from '../sdk/apple/index.ts'
import { status as fivesingStatus } from '../sdk/5sing/index.ts' import { status as fivesingStatus } from '../sdk/5sing/index.ts'
@@ -75,6 +76,26 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/wy', {
schema: {
summary: '绑定网易云 cookie',
description: 'cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。' +
'绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。',
tags: ['登录'],
body: {
type: 'object',
properties: { cookie: { type: 'string' } },
required: ['cookie'],
},
},
}, async(request, reply) => {
try {
return await wyAuth.loginByCookie(request.body.cookie)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/wy', { fastify.delete('/login/wy', {
schema: { summary: '退出网易云登录', tags: ['登录'] }, schema: { summary: '退出网易云登录', tags: ['登录'] },
}, async() => { }, async() => {
@@ -323,6 +344,67 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/qs/sms', {
schema: {
summary: '发送汽水音乐登录短信验证码',
description: '手机号验证码登录第一步(官方 PC 客户端同款 /passport/web/send_code/,type=24)。' +
'mobile 支持 "13800138000" 或自带区号 "86 13800138000";海外号码用 cc 参数指定区号。' +
'发送成功后用 /login/qs/sms/login 提交验证码完成登录。',
tags: ['登录'],
body: {
type: 'object',
properties: { mobile: { type: 'string' }, cc: { type: 'string' } },
required: ['mobile'],
},
},
}, async(request, reply) => {
try {
return await qsPassport.sendSmsCode(request.body.mobile, request.body.cc)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs/sms/login', {
schema: {
summary: '汽水音乐短信验证码登录',
description: '提交 /login/qs/sms 发送的验证码完成登录,成功自动保存凭据并返回 803。' +
'失败经 msg 返回官方文案(1200-1300 为验证码/手机号错误;2046 需去汽水 App 完成安全验证;1075 账号注销中)。',
tags: ['登录'],
body: {
type: 'object',
properties: { mobile: { type: 'string' }, code: { type: 'string' }, cc: { type: 'string' } },
required: ['mobile', 'code'],
},
},
}, async(request, reply) => {
try {
return await qsPassport.smsLogin(request.body.mobile, request.body.code, request.body.cc)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs', {
schema: {
summary: '绑定汽水音乐 sessionid',
description: 'sessionid 从汽水 App / 官方 PC 客户端抓包获取(登录态 Cookie 的 sessionid 字段),' +
'也可传完整 cookie 串(须含 sessionid= 字段,整体保存)。无上游校验接口,不做有效性验证,' +
'失效会在取流时报错。推荐优先用 /login/qs/qr 扫码或 /login/qs/sms 短信登录。',
tags: ['登录'],
body: {
type: 'object',
properties: { sessionid: { type: 'string' }, cookie: { type: 'string' } },
},
},
}, async(request, reply) => {
try {
return await qsPassport.loginBySessionId(request.body.sessionid || request.body.cookie)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/qs', { fastify.delete('/login/qs', {
schema: { summary: '退出汽水音乐登录', tags: ['登录'] }, schema: { summary: '退出汽水音乐登录', tags: ['登录'] },
}, async() => { }, async() => {
@@ -425,10 +507,10 @@ export const loginRoutes = async(fastify) => {
// ---------- 哔哩哔哩 ---------- // ---------- 哔哩哔哩 ----------
fastify.post('/login/bl/qr', { fastify.post('/login/bili/qr', {
schema: { schema: {
summary: '获取哔哩哔哩扫码登录二维码', summary: '获取哔哩哔哩扫码登录二维码',
description: '用 B站 App 扫码后轮询 /login/bl/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。', description: '用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。',
tags: ['登录'], tags: ['登录'],
}, },
}, async(_request, reply) => { }, async(_request, reply) => {
@@ -439,7 +521,7 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/bl/qr/check', { fastify.post('/login/bili/qr/check', {
schema: { schema: {
summary: '轮询哔哩哔哩扫码状态', summary: '轮询哔哩哔哩扫码状态',
description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' + description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' +
@@ -455,10 +537,10 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.delete('/login/bl', { fastify.delete('/login/bili', {
schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] }, schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] },
}, async() => { }, async() => {
clearCredentials('bl') clearCredentials('bili')
return { logged: false } return { logged: false }
}) })
@@ -476,8 +558,8 @@ export const loginRoutes = async(fastify) => {
fastify.get('/login/status', { fastify.get('/login/status', {
schema: { schema: {
summary: '查看登录状态', summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bl 为已存凭据(含 VIP 等级与最近更新时间),' + description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' +
'qs/xm/me/lr/bd/jx/ap/5s 为已存凭据。', 'qs/xm/me/lr/bd/joox/apple/5sing 为已存凭据。',
tags: ['登录'], tags: ['登录'],
}, },
}, async() => { }, async() => {
@@ -493,11 +575,11 @@ export const loginRoutes = async(fastify) => {
xm: xmStatus(), xm: xmStatus(),
me: meStatus(), me: meStatus(),
lr: lrAuth.status(), lr: lrAuth.status(),
bl: biliAuth.status(), bili: biliAuth.status(),
bd: qianStatus(), bd: bdStatus(),
jx: jooxStatus(), joox: jooxStatus(),
ap: appleStatus(), apple: appleStatus(),
'5s': fivesingStatus(), '5sing': fivesingStatus(),
} }
}) })
} }
+11 -11
View File
@@ -5,7 +5,7 @@
import { notFound, upstream } from './http/api-error.ts' import { notFound, upstream } from './http/api-error.ts'
import { signStream } from './http/stream-sign.ts' import { signStream } from './http/stream-sign.ts'
import { registerMiguStream, takeMiguStream } from './http/migu-stream.ts' import { registerMiguStream, takeMiguStream } from './http/mg-stream.ts'
import { getMusicInfo } from '../sdk/kg/musicInfo.ts' import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit'] const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
@@ -300,8 +300,8 @@ export const musicRoutes = async(fastify: any) => {
if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/mg 取流)') if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/mg 取流)')
// 流式下载 + 逐块解密:咪咕 Z3D 密钥流按 32 字节循环,块内保持全局偏移即可, // 流式下载 + 逐块解密:咪咕 Z3D 密钥流按 32 字节循环,块内保持全局偏移即可,
// 避免把上百 MB 的 wav/flac 整段读进内存(httpFetch 有 64MB 缓冲上限) // 避免把上百 MB 的 wav/flac 整段读进内存(httpFetch 有 64MB 缓冲上限)
const { miguZ3DStreamKey, isMiguAudioMagic } = await import('../sdk/migu/util.ts') const { miguZ3DStreamKey, isMiguAudioMagic } = await import('../sdk/mg/util.ts')
const { pipeDecryptedMiguStream, miguStreamContentType } = await import('./http/migu-stream.ts') const { pipeDecryptedMiguStream, miguStreamContentType } = await import('./http/mg-stream.ts')
const streamKey = miguZ3DStreamKey(ticket.fileKey) const streamKey = miguZ3DStreamKey(ticket.fileKey)
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
'Content-Type': miguStreamContentType(ticket.url), 'Content-Type': miguStreamContentType(ticket.url),
@@ -513,7 +513,7 @@ export const musicRoutes = async(fastify: any) => {
const albumSchema = (withPage: boolean) => ({ const albumSchema = (withPage: boolean) => ({
summary: '获取专辑详情与曲目列表', summary: '获取专辑详情与曲目列表',
description: '支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/jx/ap(见 /sources 的 album 能力位)。' + description: '支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple(见 /sources 的 album 能力位)。' +
'kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。', 'kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
tags: ['歌手专辑'], tags: ['歌手专辑'],
params: params({ params: params({
@@ -538,7 +538,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/album/:source/search/:keyword', { fastify.get('/album/:source/search/:keyword', {
schema: { schema: {
summary: '搜索专辑', summary: '搜索专辑',
description: '支持 kg/kw/tx/wy/mg/bd/jx/ap(见 /sources 的 albumSearch 能力位)。', description: '支持 kg/kw/tx/wy/mg/bd/joox/apple(见 /sources 的 albumSearch 能力位)。',
tags: ['歌手专辑'], tags: ['歌手专辑'],
params: params({ source: pSource('album.searchAlbum'), keyword: { type: 'string', description: '专辑名关键词', example: '魔杰座' } }), params: params({ source: pSource('album.searchAlbum'), keyword: { type: 'string', description: '专辑名关键词', example: '魔杰座' } }),
querystring: qs({ page: pPage, limit: pLimit }), querystring: qs({ page: pPage, limit: pLimit }),
@@ -563,8 +563,8 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/playlist/:source/search/:keyword', { fastify.get('/playlist/:source/search/:keyword', {
schema: { schema: {
summary: '搜索歌单', summary: '搜索歌单',
description: '支持 kg/kw/tx/wy/mg/bd/jx/bl/5s/ap(见 /sources 的 playlistSearch 能力位)。' + description: '支持 kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。' +
'部分平台该能力受限(如 bd 歌单搜索接口已下线、bl 需登录),失败时返回 502。', '部分平台该能力受限(如 bd 歌单搜索接口已下线、bili 需登录),失败时返回 502。',
tags: ['歌单'], tags: ['歌单'],
params: params({ source: pSource('playlist.searchPlaylist'), keyword: { type: 'string', example: '华语' } }), params: params({ source: pSource('playlist.searchPlaylist'), keyword: { type: 'string', example: '华语' } }),
querystring: qs({ page: pPage, limit: pLimit }), querystring: qs({ page: pPage, limit: pLimit }),
@@ -586,7 +586,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/playlist/:source/categories', { fastify.get('/playlist/:source/categories', {
schema: { schema: {
summary: '获取歌单分类', summary: '获取歌单分类',
description: '支持 kw/kg/tx/wy/mg/bd/jx/bl/5s/ap(见 /sources 的 playlistCategories 能力位)。', description: '支持 kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。',
tags: ['歌单'], tags: ['歌单'],
params: params({ source: pSource('playlist.getCategories') }), params: params({ source: pSource('playlist.getCategories') }),
response: responses('PlaylistCategoriesResult'), response: responses('PlaylistCategoriesResult'),
@@ -630,8 +630,8 @@ export const musicRoutes = async(fastify: any) => {
const playlistSchema = (withPage: boolean) => ({ const playlistSchema = (withPage: boolean) => ({
summary: '获取歌单详情与曲目列表', summary: '获取歌单详情与曲目列表',
description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bl 的 season:<id>:<mid>:<bvid>、' + description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:<id>:<mid>:<bvid>、' +
'jx/bd/mg 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。', 'joox/bd/mg 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。',
tags: ['歌单'], tags: ['歌单'],
params: params({ params: params({
source: pSource('playlist'), source: pSource('playlist'),
@@ -701,7 +701,7 @@ export const musicRoutes = async(fastify: any) => {
schema: { schema: {
summary: `解析${kind === 'song' ? '歌曲' : kind === 'album' ? '专辑' : '歌单'}链接`, summary: `解析${kind === 'song' ? '歌曲' : kind === 'album' ? '专辑' : '歌单'}链接`,
description: '把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;' + description: '把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;' +
'5s 歌曲/歌单、apple/bl/jx/bd/mg 三类、kw/kg/tx/wy 部分支持。', '5sing 歌曲/歌单、apple/bili/joox/bd/mg 三类、kw/kg/tx/wy 部分支持。',
tags: ['链接解析'], tags: ['链接解析'],
params: params({ source: pSource('parse.parseSong') }), params: params({ source: pSource('parse.parseSong') }),
body: parseBodySchema, body: parseBodySchema,
+1 -1
View File
@@ -49,7 +49,7 @@ const DOCS_PREFIX = '/docs'
const API_TITLE = 'MusicServer 聚合音乐 API' const API_TITLE = 'MusicServer 聚合音乐 API'
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' + const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' + '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' +
'jx / bl(哔哩哔哩)/ 5s / ap(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。' 'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。'
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')) const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
+57
View File
@@ -0,0 +1,57 @@
// bili 取流 CDN 分级选址回归 —— SDK 层直调(不依赖服务器实例,含真实 B 站上游请求)
// 运行:node test/bili-cdn-test.ts(已并入 pnpm test)
//
// 覆盖:getMusicUrl 返回地址不落在 PCDN(mcdn 无线宝/mountaintoys 迅雷/szbdyd/nexusedgeio),
// parse 路径填充的 url 同样校验。
// 判定:命中 PCDN 即失败——fail-soft 兜底意味着 upos/cn 候选 20s 探测全败,是真实的选址退化信号
// (smoke 的「URL bili」项走 HTTP 层、只软警告;本文件是 SDK 层的硬断言)。
import biliApi from '../sdk/apis/bili.ts'
import { parseSong } from '../sdk/bili/parse.ts'
/** 与 sdk/apis/bili.ts 的 hostRank 同构:0=正规 CDN 1=B站系 2=PCDN */
const hostRank = (url: string): number => {
let host = ''
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
if (host.endsWith('.akamaized.net')) return 0
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
return 2
}
const LABEL = ['正规CDN', 'B站系', 'PCDN(坏)']
const results: { name: string; ok: boolean; detail?: any }[] = []
const test = async(name: string, fn: () => Promise<any>) => {
try {
const result = await fn()
results.push({ name, ok: true, detail: result })
console.log(`✅ ${name}${result ? `: ${result}` : ''}`)
} catch (err: any) {
results.push({ name, ok: false, detail: err.message })
console.log(`❌ ${name}: ${err.message}`)
}
}
// 固定样本(2026-10-04 实测均有多分P候选与 upos 备源);换样本时优先选多分P、热度中等的稿件
const CASES = ['BV1BZbSzZEGT', 'BV1GJ411x7h7', 'BV1d4411N7zD', 'BV1EC41147U1', 'BV19F5k6VEEp']
for (const bvid of CASES) {
await test(`取流 ${bvid} 落正规 CDN`, async() => {
const { promise } = biliApi.getMusicUrl({ songmid: bvid }, '128k')
const res = await promise
const rank = hostRank(res.url)
if (rank === 2) throw new Error(`命中 PCDN:${new URL(res.url).host}(backupUrl 探测疑似全败)`)
return `${LABEL[rank]} ${new URL(res.url).host}`
})
}
await test('parse 填充 url 落正规 CDN', async() => {
const song = await parseSong('https://www.bilibili.com/video/BV1GJ411x7h7')
if (!song.url) throw new Error('parse 未填充 url')
const rank = hostRank(String(song.url))
if (rank === 2) throw new Error(`命中 PCDN:${new URL(String(song.url)).host}`)
return `${LABEL[rank]} ${new URL(String(song.url)).host}`
})
const failed = results.filter(r => !r.ok).length
console.log(`\n===== 结果: ${results.length - failed}/${results.length} 通过 =====`)
process.exit(failed ? 1 : 0)
+82 -22
View File
@@ -514,11 +514,11 @@ await test('专辑详情 /album/lr', async() => {
return `${r.data.info.total} 节` return `${r.data.info.total} 节`
}) })
// ---- 新平台(migu/bd/jx/bl/5s/ap 移植回归) ---- // ---- 新平台(mg/bd/joox/bili/5sing/apple 移植回归) ----
for (const source of ['mg', 'bd', 'jx', 'bl', '5s', 'ap']) { for (const source of ['mg', 'bd', 'joox', 'bili', '5sing', 'apple']) {
await test(`搜索 ${source} 直查`, async() => { await test(`搜索 ${source} 直查`, async() => {
const r = await get(`/search/${source}/${encodeURIComponent(source === '5s' ? '董贞' : '周杰伦')}?limit=5`) const r = await get(`/search/${source}/${encodeURIComponent(source === '5sing' ? '董贞' : '周杰伦')}?limit=5`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表') if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 条,首条 ${r.data.list[0].name}` return `${r.data.list.length} 条,首条 ${r.data.list[0].name}`
@@ -547,14 +547,25 @@ await test('URL bd 免费曲', async() => {
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}` return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
}) })
await test('URL 5s(档位 sq/hq/lq)', async() => { await test('URL 5sing(档位 sq/hq/lq)', async() => {
const r = await get(`/search/5s/${encodeURIComponent('董贞')}?limit=5`) const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`)
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
const u = await get(`/url/5s/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`) const u = await get(`/url/5sing/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`)
if (u.code != 0) throw new Error(u.msg) if (u.code != 0) throw new Error(u.msg)
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}` return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
}) })
await test('URL bili(CDN 分级选址)', async() => {
const r = await get(`/search/bili/${encodeURIComponent('周杰伦')}?limit=3`)
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
const u = await get(`/url/bili/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`)
if (u.code != 0) throw new Error(u.msg)
// 返回地址应落在正规 CDN(upos-*.bilivideo.com 等);命中 PCDN 说明备源探测全挂,软警告不判失败
const host = new URL(String(u.data.url)).hostname
const pcdn = /\.(mcdn\.bilivideo\.cn|mountaintoys\.cn|szbdyd\.com|nexusedgeio\.com)$/.test(host)
return `实际 ${u.data.type},${host}${pcdn ? '(警告:命中 PCDN,请检查 backupUrl 探测)' : ''}`
})
await test('歌词 mg', async() => { await test('歌词 mg', async() => {
const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`) const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
const song = r.data?.list?.[0] const song = r.data?.list?.[0]
@@ -565,11 +576,11 @@ await test('歌词 mg', async() => {
return l.data.lyric.slice(0, 40) return l.data.lyric.slice(0, 40)
}) })
await test('歌词 5s', async() => { await test('歌词 5sing', async() => {
const r = await get(`/search/5s/${encodeURIComponent('董贞')}?limit=5`) const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`)
const song = r.data?.list?.[0] const song = r.data?.list?.[0]
if (!song) throw new Error('搜索失败') if (!song) throw new Error('搜索失败')
const l = await get(`/lyric/5s/${encodeURIComponent(song.songmid)}`) const l = await get(`/lyric/5sing/${encodeURIComponent(song.songmid)}`)
if (l.code != 0) throw new Error(l.msg) if (l.code != 0) throw new Error(l.msg)
if (!l.data?.lyric) throw new Error('空歌词') if (!l.data?.lyric) throw new Error('空歌词')
return l.data.lyric.slice(0, 40) return l.data.lyric.slice(0, 40)
@@ -585,7 +596,7 @@ await test('封面 mg', async() => {
return String(p.data.url).slice(0, 60) return String(p.data.url).slice(0, 60)
}) })
for (const source of ['mg', 'bd', 'jx', 'ap']) { for (const source of ['mg', 'bd', 'joox', 'apple']) {
await test(`专辑搜索 /album/${source}/search`, async() => { await test(`专辑搜索 /album/${source}/search`, async() => {
const r = await get(`/album/${source}/search/${encodeURIComponent('Hello')}?limit=3`) const r = await get(`/album/${source}/search/${encodeURIComponent('Hello')}?limit=3`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
@@ -604,7 +615,7 @@ await test('专辑详情 /album/mg', async() => {
return `${a.data.info.name}(${a.data.total} 首)` return `${a.data.info.name}(${a.data.total} 首)`
}) })
for (const source of ['mg', 'bd', 'jx', 'ap']) { for (const source of ['mg', 'bd', 'joox', 'apple']) {
await test(`歌单分类 /playlist/${source}/categories`, async() => { await test(`歌单分类 /playlist/${source}/categories`, async() => {
const r = await get(`/playlist/${source}/categories`) const r = await get(`/playlist/${source}/categories`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
@@ -635,11 +646,11 @@ await test('歌单详情 /playlist/mg', async() => {
return `${d.data.total} 首,本页 ${d.data.list.length} 首` return `${d.data.total} 首,本页 ${d.data.list.length} 首`
}) })
await test('歌单详情 /playlist/bl(合集)', async() => { await test('歌单详情 /playlist/bili(合集)', async() => {
const s = await get(`/playlist/bl/search/${encodeURIComponent('周杰伦')}?limit=3`) const s = await get(`/playlist/bili/search/${encodeURIComponent('周杰伦')}?limit=3`)
const id = s.data?.list?.[0]?.id const id = s.data?.list?.[0]?.id
if (!id) throw new Error('搜索无歌单') if (!id) throw new Error('搜索无歌单')
const d = await get(`/playlist/bl/${encodeURIComponent(id)}?limit=5`) const d = await get(`/playlist/bili/${encodeURIComponent(id)}?limit=5`)
if (d.code != 0) throw new Error(d.msg) if (d.code != 0) throw new Error(d.msg)
if (!d.data?.list?.length) throw new Error('歌单无曲目') if (!d.data?.list?.length) throw new Error('歌单无曲目')
return `${d.data.total} 首` return `${d.data.total} 首`
@@ -659,8 +670,8 @@ await test('链接解析 /parse/playlist/bd', async() => {
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)` return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
}) })
await test('链接解析 /parse/album/ap', async() => { await test('链接解析 /parse/album/apple', async() => {
const r = await postJson('/parse/album/ap', { link: 'https://music.apple.com/us/album/the-life-of-a-showgirl-the-encore/6814997249' }) const r = await postJson('/parse/album/apple', { link: 'https://music.apple.com/us/album/the-life-of-a-showgirl-the-encore/6814997249' })
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
if (!r.body.data?.list?.length) throw new Error('无曲目') if (!r.body.data?.list?.length) throw new Error('无曲目')
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)` return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
@@ -682,24 +693,24 @@ await test('链接解析 /parse/song/kw(正向)', async() => {
return `kw 解析成功: ${r.body.data.song.name}` return `kw 解析成功: ${r.body.data.song.name}`
}) })
await test('扫码二维码创建 POST /login/bl/qr', async() => { await test('扫码二维码创建 POST /login/bili/qr', async() => {
const r = await postRaw('/login/bl/qr') const r = await postRaw('/login/bili/qr')
const body = JSON.parse(r.body) const body = JSON.parse(r.body)
if (r.status !== 200 || body.code != 0 || !body.data?.key) throw new Error(r.body.slice(0, 120)) if (r.status !== 200 || body.code != 0 || !body.data?.key) throw new Error(r.body.slice(0, 120))
return `key=${String(body.data.key).slice(0, 12)}…` return `key=${String(body.data.key).slice(0, 12)}…`
}) })
await test('扫码轮询 POST /login/bl/qr/check', async() => { await test('扫码轮询 POST /login/bili/qr/check', async() => {
const created = JSON.parse((await postRaw('/login/bl/qr')).body) const created = JSON.parse((await postRaw('/login/bili/qr')).body)
const key = created.data?.key const key = created.data?.key
if (!key) throw new Error('未取到 key') if (!key) throw new Error('未取到 key')
const r = await postJson('/login/bl/qr/check', { key }) const r = await postJson('/login/bili/qr/check', { key })
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
return `status=${r.body.data.status} code=${r.body.data.code}` return `status=${r.body.data.status} code=${r.body.data.code}`
}) })
await test('个人歌单 未支持的平台 → 502', async() => { await test('个人歌单 未支持的平台 → 502', async() => {
const r = await getRaw('/user-playlist/5s') const r = await getRaw('/user-playlist/5sing')
if (r.status !== 502 && r.status !== 404) throw new Error(`期望 502/404,实际 ${r.status}`) if (r.status !== 502 && r.status !== 404) throw new Error(`期望 502/404,实际 ${r.status}`)
return JSON.parse(r.body).msg?.slice(0, 60) return JSON.parse(r.body).msg?.slice(0, 60)
}) })
@@ -821,6 +832,20 @@ await test('扫码二维码创建 POST /login/wy/qr', async() => {
return r.body.data.key.slice(0, 18) + '…' return r.body.data.key.slice(0, 18) + '…'
}) })
// wy cookie 绑定校验(缺 MUSIC_U 直接 422,不打上游)
await test('wy 坏 cookie 拒绝 POST /login/wy', async() => {
const r = await postJson('/login/wy', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
// wy 失效 MUSIC_U 会被 account/get 实测拒绝(401,与 kg 假 token 同款语义)
await test('wy 假 cookie 拒绝 POST /login/wy', async() => {
const r = await postJson('/login/wy', { cookie: 'MUSIC_U=smoke_fake_music_u' })
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
return r.body?.msg
})
await test('扫码二维码创建 POST /login/qs/qr', async() => { await test('扫码二维码创建 POST /login/qs/qr', async() => {
const r = await postJson('/login/qs/qr', {}) const r = await postJson('/login/qs/qr', {})
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`) if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
@@ -830,6 +855,41 @@ await test('扫码二维码创建 POST /login/qs/qr', async() => {
return `scanApp=${r.body.data.scanApp || '?'}` return `scanApp=${r.body.data.scanApp || '?'}`
}) })
// 短信发码:无效手机号应被上游业务拒绝(400 + 官方文案),而非 502 网关错
await test('sms 坏手机号拒绝 POST /login/qs/sms', async() => {
const r = await postJson('/login/qs/sms', { mobile: '10000000000' })
if (r.status !== 400 || !r.body?.msg) throw new Error(`期望 400+msg,实际 ${r.status} ${JSON.stringify(r.body)}`)
return r.body.msg
})
// 短信登录:未发码直接提交,应被上游业务拒绝(400 + 官方文案)
await test('sms 错误验证码拒绝 POST /login/qs/sms/login', async() => {
const r = await postJson('/login/qs/sms/login', { mobile: '10000000000', code: '000000' })
if (r.status !== 400 || !r.body?.msg) throw new Error(`期望 400+msg,实际 ${r.status} ${JSON.stringify(r.body)}`)
return r.body.msg
})
// qs 手动绑定:裸 sessionid 与完整 cookie 串两种形态均可绑,登录态生效;
// 无上游校验接口,测完解绑恢复匿名(避免假 sessionid 影响后续 qs 用例)
await test('qs sessionid 绑定 POST /login/qs', async() => {
const bare = await postJson('/login/qs', { sessionid: 'smoke_fake_sessionid_0123456789abcdef' })
if (bare.status !== 200 || bare.body?.code !== 0 || bare.body.data?.logged !== true) throw new Error(`HTTP ${bare.status} ${JSON.stringify(bare.body)}`)
const full = await postJson('/login/qs', { cookie: 'sid_guard=smoke; sessionid=smoke_fake_sessionid_0123456789abcdef' })
if (full.status !== 200 || full.body?.code !== 0) throw new Error(`HTTP ${full.status} ${JSON.stringify(full.body)}`)
const st = await get('/login/status')
if (st.code != 0 || st.data?.qs?.logged !== true) throw new Error('qs 登录状态未生效')
const del = await fetch(`${BASE}/login/qs`, { method: 'DELETE' })
if (del.status !== 200) throw new Error(`解绑失败 HTTP ${del.status}`)
return 'bare+cookie 两种形态可绑,已解绑'
})
// qs cookie 绑定校验(缺 sessionid 字段拒绝)
await test('qs 坏 cookie 拒绝 POST /login/qs', async() => {
const r = await postJson('/login/qs', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
await test('kg 假 token 拒绝 POST /login/kg', async() => { await test('kg 假 token 拒绝 POST /login/kg', async() => {
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 }) const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)