Compare commits

...
2 Commits
Author SHA1 Message Date
snowy-yang 1b972ec4dd refactor(api)!: 平台 id 收尾统一——migu→mg、apple→ap
补上最后两个多字母 id,至此 15 个平台对外 id 全部为两字母:
kw kg tx wy qs xm me qt lr mg bd jx bl 5s ap

**破坏性变更**:id 出现在所有 URL、/sources 返回以及 data/auth.json 的
凭据键里,咪咕与 Apple Music 已存的登录态会失效,需重新登录。

- sdk/index.ts:id 与 supportQuality 映射键(展示名 `咪咕音乐` /
  `Apple Music` 保持不动)
- sdk/types.ts:Source 联合类型
- src/routes.ts:/stream 的 enum ['qs','migu'] → ['qs','mg'],以及
  /url/mg、/stream/mg 等路径与 schema 描述
- src/http/migu-stream.ts、src/routes-login.ts、src/server.ts
- test/smoke-test.ts、README.md、docs/API.md 同步

有意未改:域名与 API 路径(music.migu.cn、music.apple.com、
amp-api.music.apple.com、/v1/catalog/cn/apple-curators/…)、
标识符(registerMiguStream、appleAmpGet、APPLE_AMP_API_URL 等)、
sdk/migu 与 sdk/apple 的目录与文件名、错误文案里的平台名。

验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources
(15/15 平台 id 与展示名全对)与 /login/status,旧 id 零残留。
2026-10-03 11:07:21 +08:00
snowy-yang 3381e9b906 refactor(api)!: 平台 id 改两字母短码——qian→bd、joox→jx、5sing→5s、bili→bl
对外 id 统一为两字母短码,与既有的 kw/kg/tx/wy/qs/xm/me/qt/lr 对齐;
migu 与 apple 保持原样(Apple Music 没有自然的短码)。

**破坏性变更**:id 出现在所有 URL、/sources 返回、OpenAPI 示例以及
data/auth.json 的凭据键里,这四个平台已存的登录态会失效,需重新登录。

- sdk/index.ts:id 与 supportQuality 映射键
- sdk/types.ts:Source 联合类型
- src/routes.ts 与 routes-login.ts:路由路径与 schema 描述
  (/login/bili/qr → /login/bl/qr 等)、/login/status 的响应键
- test/smoke-test.ts、README.md、docs/API.md 同步

有意未改:sdk 目录与文件名仍用平台名(sdk/qian/、sdk/apis/joox.ts …),
规则定为「目录=平台名,id=对外短码」,映射只在 SUPPORTED_SOURCES 一处声明。
域名与正则(joox.com、5sing.kugou.com、/joox\.com\//)、jooxSign 等标识符、
错误文案里的中文平台名、以及 kg 榜单名(含「joox本地热歌榜」)全部保持原样——
它们只是恰好含平台名,并不是 id。

验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources 与
/login/status 的新 id 与展示名,新旧 id 均无残留。
2026-10-03 11:03:06 +08:00
45 changed files with 239 additions and 239 deletions

No files matched your search

+27 -27
View File
@@ -4,7 +4,7 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(migu)、千千音乐(qian)、JOOX(joox)、哔哩哔哩(bili)、5sing;有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(mg)、千千音乐(bd)、JOOX(jx)、哔哩哔哩(bl)、5sing(5s);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(ap,仅 preview 试听)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接) - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查 - 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
@@ -18,12 +18,12 @@
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie | | QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 | | 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 | | 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
| 咪咕音乐 | `migu` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) | | 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
| 千千音乐 | `qian` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) | | 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) | | JOOX | `jx` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
| 哔哩哔哩 | `bili` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 | | 哔哩哔哩 | `bl` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 |
| 5sing | `5sing` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) | | 5sing | `5s` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) |
| Apple Music | `apple` | ✅ | preview 试听片段 | ✅(需登录) | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(media-user-token) | | Apple Music | `ap` | ✅ | 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 | — | ✅ | — | ✅ 详情 | — | — |
@@ -52,7 +52,7 @@ pnpm typecheck # 仅类型检查,不运行
| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 | | `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
| `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) | | `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`migu`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 | | `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`mg`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | | `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
| `GET /pic/:source/:id` | 获取封面 | | `GET /pic/:source/:id` | 获取封面 |
| `GET /boards/:source` | 榜单列表 | | `GET /boards/:source` | 榜单列表 |
@@ -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/migu/qian/joox/apple | | `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/jx/ap |
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/migu/qian/joox/apple) | | `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/jx/ap) |
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/migu/qian/joox/bili/5sing/apple) | | `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/mg/bd/jx/bl/5s/ap) |
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple) | | `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/mg/bd/jx/bl/5s/ap) |
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) | | `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple;bili 支持合集/多分P) | | `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/mg/bd/jx/bl/5s/ap;bl 支持合集/多分P) |
| `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5sing/apple/bili/joox/qian/migu(其中 bili、5sing 无 album 端点) | | `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 端点) |
| `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/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) | | `POST /login/bl/qr` → `POST /login/bl/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
| `DELETE /login/{wy,kg,tx,qs,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) | | `DELETE /login/{wy,kg,tx,qs,xm,me,lr,bl}` | 清除对应平台凭据(退出登录) |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -103,12 +103,12 @@ pnpm typecheck # 仅类型检查,不运行
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流 - **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目 - **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态) - **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
- **migu**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/migu` 解密代理 - **mg**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/mg` 解密代理
- **qian**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie - **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
- **joox**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`) - **jx**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
- **bili**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>` - **bl**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
- **5sing**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C` - **5s**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C`
- **apple**:数字歌曲 id(搜索结果产出);取流固定为 `preview` 试听片段(完整曲为 DRM,不在支持范围) - **ap**:数字歌曲 id(搜索结果产出);取流固定为 `preview` 试听片段(完整曲为 DRM,不在支持范围)
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
@@ -159,7 +159,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 汽水扫码、
bili 扫码、xm/me cookie 绑定、lr 账密) bl 扫码、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 进程内直载)
@@ -196,9 +196,9 @@ test/
- **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。 - **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
- **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)。
- **migu 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/migu` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。 - **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。 - **ap 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。 - **bl 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bl/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。 - **5s 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。 - **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。 - 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
+8 -8
View File
@@ -2,8 +2,8 @@
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。 多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `migu`(咪咕)/ 共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/
`qian`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听), `bd`(千千)/ `jx` / `bl`(哔哩哔哩)/ `5s` / `ap`(Apple Music,仅试听),
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。 以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
> **本文档是人工速查版,可能滞后于代码。** > **本文档是人工速查版,可能滞后于代码。**
@@ -39,8 +39,8 @@
### 歌曲 id 形态 ### 歌曲 id 形态
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`migu` 的三元组、 各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`mg` 的三元组、
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。 `qt`/`lr` 的复合 id、`bl` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。 复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
## 接口速查 ## 接口速查
@@ -66,7 +66,7 @@
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) | | GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
| GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `migu` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 | | GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) | | GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) | | GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
@@ -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=`。bili 支持合集/多分P | | GET | `/playlist/:source/:id[/:page]` | 歌单详情与曲目;`?limit=`。bl 支持合集/多分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/bili/qr` → `/login/bili/qr/check` | 哔哩哔哩扫码(解锁大会员音质) | | POST | `/login/bl/qr` → `/login/bl/qr/check` | 哔哩哔哩扫码(解锁大会员音质) |
| DELETE | `/login/bili` | 退出哔哩哔哩 | | DELETE | `/login/bl` | 退出哔哩哔哩 |
| POST | `/login/refresh` | 手动触发登录态续期(kg/tx) | | POST | `/login/refresh` | 手动触发登录态续期(kg/tx) |
| GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) | | GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) |
+2 -2
View File
@@ -1,12 +1,12 @@
// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块 // 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。 // 上游 music-lib/fivesing 移植(平台 id 统一为 '5s',上游 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('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。 // 本移植从 sdk/auth/store.ts 的 getCredentials('5s') 读取 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: '5sing' as const, source: '5s' 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 统一 '5sing') */ /** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5s') */
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: '5sing' as const, source: '5s' 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: '5sing' as const } const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5s' 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: '5sing' as const } return { list, allPage, total, limit: pageSize, source: '5s' 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: '5sing' as const, source: '5s' 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: '5sing' as const, source: '5s' 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: '5sing' as const } const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5s' 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: '5sing' as const } return { list, total, page: pageNo, limit: pageSize, source: '5s' 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: '5sing' as const, source: '5s' 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: '5sing' as const, source: '5s' 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: '5sing' as const, source: '5s' 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 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing"; // 平台 id 统一为 '5s'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
// 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。 // 本仓库契约要求 id 为 5s,故所有对外 source 字段均为 '5s'。
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页), // 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。 // 这里按约定从 sdk/auth/store.ts 的 getCredentials('5s') 读取(无则匿名)。
import { httpFetch } from '../request.ts' import { httpFetch } from '../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('5sing') as { cookie?: string } | null)?.cookie ?? '') export const get5singCookie = (): string => String((getCredentials('5s') as { cookie?: string } | null)?.cookie ?? '')
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */ /** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
export const getStatus = () => { export const getStatus = () => {
const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null const cred = getCredentials('5s') 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 !== '5sing' Boolean(songInfo?.source) && songInfo.source !== '5s'
/** /**
* 带 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('apple').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('ap').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
@@ -120,7 +120,7 @@ const followRedirects = async(url: string, cookie: string, tracker?: { current:
} }
/** /**
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp } * 统一取流接口:apis('bl').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。
*/ */
+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('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('jx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值) // promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { httpFetch } from '../request.ts' import { httpFetch } from '../request.ts'
+1 -1
View File
@@ -298,7 +298,7 @@ const MAX_PROBES = 10
export interface MiguUrlResult { export interface MiguUrlResult {
type: string type: string
url: string url: string
/** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/migu 代理) */ /** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/mg 代理) */
encrypted?: boolean encrypted?: boolean
fileKey?: string fileKey?: string
ext?: string ext?: string
+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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' 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: apple) // Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: ap)
// 能力:搜索 / 歌词(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('apple'),cookie 形如 // 凭据:上游 New(cookie) → getCredentials('ap'),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/apple 复用 // 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/ap 复用
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('apple') as any const cred = getCredentials('ap') 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' as const,
list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'apple' as const })), list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'ap' 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' 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('apple'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx" // - New(cookie) → getCredentials('ap'):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('apple') as any) || {} const cred = (getCredentials('ap') 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' 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: 'apple' as const, source: 'ap' as const,
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili) // Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bl)
// 能力:搜索 / 取流(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('bili') 承担(见 util.ts getCookie)。 // 上游 New(cookie) 的实例化语义由 getCredentials('bl') 承担(见 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('bili', { setCredentials('bl', {
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('bili') as any const cred = getCredentials('bl') 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/bili 有响应而不是 404。 // 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bl 有响应而不是 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: 'bili' as const, source: 'bl' as const,
} }
} }
+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: 'bili' as const, source: 'bl' 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: 'bili' as const, source: 'bl' 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: 'bili' as const } return { list, page: p, limit: l, total: list.length, source: 'bl' 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: 'bili' as const, source: 'bl' 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: 'bili' as const, source: 'bl' 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: 'bili' as const, source: 'bl' 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('bili') as any)?.cookie || '') export const getLoginCookie = (): string => String((getCredentials('bl') 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: 'bili' as const, source: 'bl' as const,
bvid, bvid,
cid: String(cid ?? ''), cid: String(cid ?? ''),
page, page,
+12 -12
View File
@@ -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: 'migu', name: '咪咕音乐', module: migu }, { id: 'mg', name: '咪咕音乐', module: migu },
{ id: 'qian', name: '千千音乐', module: qian }, { id: 'bd', name: '千千音乐', module: qian },
{ id: 'joox', name: 'JOOX', module: joox }, { id: 'jx', name: 'JOOX', module: joox },
{ id: 'bili', name: '哔哩哔哩', module: bili }, { id: 'bl', name: '哔哩哔哩', module: bili },
{ id: '5sing', name: '5sing', module: fivesing }, { id: '5s', name: '5sing', module: fivesing },
// Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配 // Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
{ id: 'apple', name: 'Apple Music', module: apple, noAggregate: true }, { id: 'ap', 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,
migu: miguQualitys, mg: miguQualitys,
qian: qianQualitys, bd: qianQualitys,
joox: jooxQualitys, jx: jooxQualitys,
bili: biliQualitys, bl: biliQualitys,
'5sing': fivesingQualitys, '5s': fivesingQualitys,
apple: appleQualitys, ap: 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: 'joox' as const, source: 'jx' 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: 'joox' as const, source: 'jx' 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: 'joox' as const } return { list: [], total: 0, page, limit, source: 'jx' 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: 'joox' as const, source: 'jx' as const,
}) })
} }
} }
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const } return { list: list.slice(0, limit), total: list.length, page, limit, source: 'jx' 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/joox 绑定后从 sdk/auth/store.ts 读取, // 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/jx 绑定后从 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/joox 路由复用。 // 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/jx 路由复用。
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('joox') as { cookie?: string, updatedAt?: string } | null const cred = getCredentials('jx') 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: 'joox' as const } return { list: [], allPage: 0, total: 0, limit, source: 'jx' 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: 'joox' as const, source: 'jx' 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: 'joox' as const, source: 'jx' 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: 'joox' as const, source: 'jx' 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: 'joox' as const, source: 'jx' 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: 'joox' as const } return { list: [], total: 0, page, limit, source: 'jx' 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: 'joox' as const } return { list: list.slice(0, limit), total: list.length, page, limit, source: 'jx' 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: 'joox' as const, list } return { source: 'jx' 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: 'joox' as const, source: 'jx' 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: 'joox' as const } return { list, total: items.length, page, limit, source: 'jx' 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: 'joox' as const, source: 'jx' as const,
} }
} }
@@ -65,7 +65,7 @@ export const jooxSongFromDetail = (detail: any, playUrl = ''): any => {
lrc: null, lrc: null,
types: [], types: [],
_types: {}, _types: {},
source: 'joox' as const, source: 'jx' 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('joox') as { cookie?: string } | null const cred = getCredentials('jx') as { cookie?: string } | null
return cred?.cookie || '' return cred?.cookie || ''
} }
+3 -3
View File
@@ -29,10 +29,10 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
creator: String(item.singer ?? '').trim(), creator: String(item.singer ?? '').trim(),
description: firstNonEmpty(item.desc, item.publishDate), description: firstNonEmpty(item.desc, item.publishDate),
link: miguAlbumLink(albumID), link: miguAlbumLink(albumID),
source: 'migu' as const, source: 'mg' as const,
} }
}).filter(Boolean) }).filter(Boolean)
return { list, source: 'migu' as const, page, limit, total: list.length } return { list, source: 'mg' as const, page, limit, total: list.length }
} }
/** 专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs) */ /** 专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs) */
@@ -101,7 +101,7 @@ export const getAlbumDetail = async(id: string, page = 1, limit = 50) => {
const start = (page - 1) * limit const start = (page - 1) * limit
return { return {
source: 'migu' as const, source: 'mg' as const,
info, info,
list: songs.slice(start, start + limit), list: songs.slice(start, start + limit),
page, page,
+1 -1
View File
@@ -37,7 +37,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
allPage: Math.max(1, Math.ceil(total / limit)), allPage: Math.max(1, Math.ceil(total / limit)),
total, total,
limit, limit,
source: 'migu' as const, source: 'mg' as const,
} }
} }
+5 -5
View File
@@ -31,7 +31,7 @@ const toPlaylist = (item: MiguPlaylistItem, categoryId?: string) => {
playCount: parseInt(String(item.playNum ?? ''), 10) || 0, playCount: parseInt(String(item.playNum ?? ''), 10) || 0,
creator: firstNonEmpty(item.userName, item.ownerName), creator: firstNonEmpty(item.userName, item.ownerName),
link: miguPlaylistLink(playlistID), link: miguPlaylistLink(playlistID),
source: 'migu' as const, source: 'mg' as const,
...(categoryId ? { categoryId } : {}), ...(categoryId ? { categoryId } : {}),
} }
} }
@@ -41,7 +41,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const body = await searchAll(keyword, 'songlist', page, limit) const body = await searchAll(keyword, 'songlist', page, limit)
const result: MiguPlaylistItem[] = body?.songListResultData?.result ?? [] const result: MiguPlaylistItem[] = body?.songListResultData?.result ?? []
const list = result.map(item => toPlaylist(item)).filter(Boolean) const list = result.map(item => toPlaylist(item)).filter(Boolean)
return { list, source: 'migu' as const, page, limit, total: list.length } return { list, source: 'mg' as const, page, limit, total: list.length }
} }
/** 静态分类(上游 miguPlaylistCategoryKeywords) */ /** 静态分类(上游 miguPlaylistCategoryKeywords) */
@@ -67,7 +67,7 @@ const CATEGORY_KEYWORDS = [
] ]
export const getCategories = () => ({ export const getCategories = () => ({
source: 'migu' as const, source: 'mg' as const,
list: [ list: [
{ id: '', name: '全部', group: '全部' }, { id: '', name: '全部', group: '全部' },
...CATEGORY_KEYWORDS.map(c => ({ id: c.name, name: c.name, group: c.group, hot: Boolean(c.hot) })), ...CATEGORY_KEYWORDS.map(c => ({ id: c.name, name: c.name, group: c.group, hot: Boolean(c.hot) })),
@@ -77,7 +77,7 @@ export const getCategories = () => ({
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => { export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const keyword = String(categoryId ?? '').trim() || '华语' const keyword = String(categoryId ?? '').trim() || '华语'
const { list } = await searchPlaylist(keyword, page, limit) const { list } = await searchPlaylist(keyword, page, limit)
return { list: list.map(p => toPlaylist(p as any, keyword)).filter(Boolean), source: 'migu' as const, page, limit } return { list: list.map(p => toPlaylist(p as any, keyword)).filter(Boolean), source: 'mg' as const, page, limit }
} }
/** 歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs) */ /** 歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs) */
@@ -123,7 +123,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const { songs, total } = await fetchPlaylistSongs(playlistID) const { songs, total } = await fetchPlaylistSongs(playlistID)
const start = (page - 1) * limit const start = (page - 1) * limit
return { return {
source: 'migu' as const, source: 'mg' as const,
info: { name: playlistID, img: songs[0]?.img ?? '', desc: '', author: '' }, info: { name: playlistID, img: songs[0]?.img ?? '', desc: '', author: '' },
list: songs.slice(start, start + limit), list: songs.slice(start, start + limit),
page, page,
+3 -3
View File
@@ -38,7 +38,7 @@ const ENCRYPTED_APP_CODE = Buffer.from([
]) ])
export const getMiguCookie = (): string => { export const getMiguCookie = (): string => {
const cred = getCredentials('migu') as { cookie?: string } | null const cred = getCredentials('mg') as { cookie?: string } | null
return String(cred?.cookie ?? '').trim() return String(cred?.cookie ?? '').trim()
} }
@@ -269,7 +269,7 @@ export interface MiguSong {
payPlay?: boolean payPlay?: boolean
/** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */ /** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */
extra: Record<string, string> extra: Record<string, string>
source: 'migu' source: 'mg'
[key: string]: unknown [key: string]: unknown
} }
@@ -369,7 +369,7 @@ export const convertItemToSong = (item: MiguSongItem, allowPaid = true): MiguSon
payPlay: isVIP, payPlay: isVIP,
// 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里 // 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里
extra: { ...extra, link: miguSongLink(linkID) }, extra: { ...extra, link: miguSongLink(linkID) },
source: 'migu', source: 'mg',
} }
} }
+4 -4
View File
@@ -29,7 +29,7 @@ const mapAlbumSummary = (item: QianAlbumSearchItem) => {
genre: String(item?.genre ?? '').trim(), genre: String(item?.genre ?? '').trim(),
lang: String(item?.lang ?? '').trim(), lang: String(item?.lang ?? '').trim(),
link: id ? albumLink(id) : '', link: id ? albumLink(id) : '',
source: 'qian' as const, source: 'bd' as const,
} }
} }
@@ -74,7 +74,7 @@ export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Pr
/** 搜索专辑(返回歌单/专辑统一摘要形状) */ /** 搜索专辑(返回歌单/专辑统一摘要形状) */
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => { export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
const word = String(keyword ?? '').trim() const word = String(keyword ?? '').trim()
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit } const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
if (!word) return empty if (!word) return empty
const items = await searchAlbumItems(word, page, limit) const items = await searchAlbumItems(word, page, limit)
@@ -82,7 +82,7 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
.map(mapAlbumSummary) .map(mapAlbumSummary)
.filter((album) => album.id) .filter((album) => album.id)
.slice(0, limit) .slice(0, limit)
return { source: 'qian' as const, list, total: items.length, page, limit } return { source: 'bd' as const, list, total: items.length, page, limit }
} }
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */ /** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
@@ -146,7 +146,7 @@ export const getAlbumDetail = async(id: string, page = 1, limit = 30) => {
} }
return { return {
source: 'qian' as const, source: 'bd' as const,
info: albumInfo, info: albumInfo,
list: paged, list: paged,
page, page,
+1 -1
View File
@@ -6,7 +6,7 @@
// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay; // - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay;
// - 歌单关键词搜索(/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('qian') 读取(可选)。 // 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选)。
import qianApi from '../apis/qian.ts' import qianApi from '../apis/qian.ts'
import musicSearch from './musicSearch.ts' import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts' import lyric from './lyric.ts'
+2 -2
View File
@@ -6,7 +6,7 @@ import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
export const search = async(keyword: string, page = 1, limit = 20) => { export const search = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim() const word = String(keyword ?? '').trim()
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'qian' as const } const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'bd' as const }
if (!word) return empty if (!word) return empty
const body = await apiGet('/v1/search', { const body = await apiGet('/v1/search', {
@@ -28,7 +28,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1), allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1),
total, total,
limit, limit,
source: 'qian' as const, source: 'bd' as const,
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export const parseSong = async(link: string) => {
} }
delete song.lyricUrl delete song.lyricUrl
delete song.raw delete song.raw
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'qian' as const } return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'bd' as const }
} }
export const parseAlbum = async(link: string) => parseAlbumLink(link) export const parseAlbum = async(link: string) => parseAlbumLink(link)
+6 -6
View File
@@ -19,14 +19,14 @@ const mapPlaylistSummary = (item: any) => {
creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName), creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName),
description: rawDesc || tagList.join('、'), description: rawDesc || tagList.join('、'),
link: id ? playlistLink(id) : '', link: id ? playlistLink(id) : '',
source: 'qian' as const, source: 'bd' as const,
} }
} }
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */ /** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => { export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
const word = String(keyword ?? '').trim() const word = String(keyword ?? '').trim()
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit } const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
if (!word) return empty if (!word) return empty
const body = await apiGet('/v1/search', { const body = await apiGet('/v1/search', {
@@ -40,7 +40,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : [] const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : []
const list = items.map(mapPlaylistSummary).filter((item: any) => item.id) const list = items.map(mapPlaylistSummary).filter((item: any) => item.id)
return { source: 'qian' as const, list, total: Number(body?.data?.total) || list.length, page, limit } return { source: 'bd' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
} }
/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */ /** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */
@@ -69,7 +69,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const total = Number(data?.trackCount) || list.length const total = Number(data?.trackCount) || list.length
return { return {
source: 'qian' as const, source: 'bd' as const,
info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) }, info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) },
list, list,
page, page,
@@ -105,7 +105,7 @@ export const getCategories = async() => {
list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 }) list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 })
} }
} }
return { source: 'qian' as const, list } return { source: 'bd' as const, list }
} }
/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */ /** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */
@@ -125,7 +125,7 @@ export const getCategoryPlaylists = async(categoryId: string | number, page = 1,
.map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId })) .map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId }))
.filter((item: any) => item.id) .filter((item: any) => item.id)
return { return {
source: 'qian' as const, source: 'bd' as const,
list, list,
total: Number(body?.data?.total) || list.length, total: Number(body?.data?.total) || list.length,
page: pageNo, page: pageNo,
+3 -3
View File
@@ -2,7 +2,7 @@
// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign: // 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign:
// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret) // sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源, // 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选,未登录留空)。 // MusicServer 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选,未登录留空)。
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { httpFetch } from '../request.ts' import { httpFetch } from '../request.ts'
import { getCredentials } from '../auth/store.ts' import { getCredentials } from '../auth/store.ts'
@@ -18,7 +18,7 @@ export const RATE_ORDER = ['3000', '320', '128', '64']
/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */ /** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */
export const getQianCookie = (): string => { export const getQianCookie = (): string => {
const cred = getCredentials('qian') as { cookie?: string } | null const cred = getCredentials('bd') as { cookie?: string } | null
return cred?.cookie ? String(cred.cookie) : '' return cred?.cookie ? String(cred.cookie) : ''
} }
@@ -196,7 +196,7 @@ export const mapTrack = (item: any, opts: MapTrackOptions = {}) => {
lrcx: '', lrcx: '',
types, types,
_types, _types,
source: 'qian' as const, source: 'bd' as const,
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@
/** 音乐平台标识 */ /** 音乐平台标识 */
export type Source = export type Source =
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
| 'migu' | 'qian' | 'joox' | 'bili' | '5sing' | 'apple' | 'mg' | 'bd' | 'jx' | 'bl' | '5s' | 'ap'
| 'xm' | 'me' | 'qt' | 'lr' | 'xm' | 'me' | 'qt' | 'lr'
/** 音质标识(wav 仅部分接口声明使用) */ /** 音质标识(wav 仅部分接口声明使用) */
+3 -3
View File
@@ -1,5 +1,5 @@
// 咪咕加密流票据 —— /url/migu 命中 Z3D/I3D 加密候选时登记,/stream/migu/:id 凭票解密 // 咪咕加密流票据 —— /url/mg 命中 Z3D/I3D 加密候选时登记,/stream/mg/:id 凭票解密
// (统一入口为 /stream/:source/:id,source=migu 时 id 即此处返回的票据)。 // (统一入口为 /stream/:source/:id,source=mg 时 id 即此处返回的票据)。
// 不把上游 URL 塞进播放地址(Fastify path 参数限长,且 URL 含时效签名与中文路径), // 不把上游 URL 塞进播放地址(Fastify path 参数限长,且 URL 含时效签名与中文路径),
// 票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。 // 票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
@@ -29,7 +29,7 @@ const prune = () => {
} }
} }
/** 登记一条加密流,返回可直接放入 /stream/migu/:id 的短票据 id */ /** 登记一条加密流,返回可直接放入 /stream/mg/:id 的短票据 id */
export const registerMiguStream = (url: string, fileKey: string): string => { export const registerMiguStream = (url: string, fileKey: string): string => {
prune() prune()
const id = randomUUID().replace(/-/g, '') const id = randomUUID().replace(/-/g, '')
+12 -12
View File
@@ -425,10 +425,10 @@ export const loginRoutes = async(fastify) => {
// ---------- 哔哩哔哩 ---------- // ---------- 哔哩哔哩 ----------
fastify.post('/login/bili/qr', { fastify.post('/login/bl/qr', {
schema: { schema: {
summary: '获取哔哩哔哩扫码登录二维码', summary: '获取哔哩哔哩扫码登录二维码',
description: '用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。', description: '用 B站 App 扫码后轮询 /login/bl/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。',
tags: ['登录'], tags: ['登录'],
}, },
}, async(request, reply) => { }, async(request, reply) => {
@@ -439,7 +439,7 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/bili/qr/check', { fastify.post('/login/bl/qr/check', {
schema: { schema: {
summary: '轮询哔哩哔哩扫码状态', summary: '轮询哔哩哔哩扫码状态',
description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' + description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' +
@@ -455,10 +455,10 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.delete('/login/bili', { fastify.delete('/login/bl', {
schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] }, schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] },
}, async() => { }, async() => {
clearCredentials('bili') clearCredentials('bl')
return { logged: false } return { logged: false }
}) })
@@ -476,8 +476,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/bili 为已存凭据(含 VIP 等级与最近更新时间),' + description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bl 为已存凭据(含 VIP 等级与最近更新时间),' +
'qs/xm/me/lr/qian/joox/apple/5sing 为已存凭据。', 'qs/xm/me/lr/bd/jx/ap/5s 为已存凭据。',
tags: ['登录'], tags: ['登录'],
}, },
}, async() => { }, async() => {
@@ -493,11 +493,11 @@ export const loginRoutes = async(fastify) => {
xm: xmStatus(), xm: xmStatus(),
me: meStatus(), me: meStatus(),
lr: lrAuth.status(), lr: lrAuth.status(),
bili: biliAuth.status(), bl: biliAuth.status(),
qian: qianStatus(), bd: qianStatus(),
joox: jooxStatus(), jx: jooxStatus(),
apple: appleStatus(), ap: appleStatus(),
'5sing': fivesingStatus(), '5s': fivesingStatus(),
} }
}) })
} }
+18 -18
View File
@@ -178,10 +178,10 @@ export const musicRoutes = async(fastify: any) => {
// 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配) // 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配)
const finish = (res: any, via: string) => { const finish = (res: any, via: string) => {
if (!res?.url) throw new Error('获取播放地址失败') if (!res?.url) throw new Error('获取播放地址失败')
// 加密流(migu 的 Z3D/I3D):客户端无法自行解密,改指向服务端解密代理 /stream/migu/<票据> // 加密流(migu 的 Z3D/I3D):客户端无法自行解密,改指向服务端解密代理 /stream/mg/<票据>
let rawUrl = String(res.url) let rawUrl = String(res.url)
if (res.encrypted && res.fileKey) { if (res.encrypted && res.fileKey) {
rawUrl = `/stream/migu/${registerMiguStream(rawUrl, String(res.fileKey))}` rawUrl = `/stream/mg/${registerMiguStream(rawUrl, String(res.fileKey))}`
} }
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器); // qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身) // 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
@@ -260,23 +260,23 @@ export const musicRoutes = async(fastify: any) => {
}, },
}, urlHandler) }, urlHandler)
// 加密流解密代理(qs 汽水解密流 / migu 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。 // 加密流解密代理(qs 汽水解密流 / mg 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。
// - qs:/url/qs 登录态取流返回相对地址 /stream/qs/<trackId>,本端点完成"签名 track_v2 → // - qs:/url/qs 登录态取流返回相对地址 /stream/qs/<trackId>,本端点完成"签名 track_v2 →
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流" // 密音频下载 → spade key + AES-CTR 解密 → 回明文流"
// - migu:/url/migu 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/migu/<票据>,本端点凭票下载密流逐字节解密 // - mg:/url/mg 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/mg/<票据>,本端点凭票下载密流逐字节解密
// 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装 // 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装
fastify.get('/stream/:source/:id', { fastify.get('/stream/:source/:id', {
config: { raw: true }, config: { raw: true },
schema: { schema: {
summary: '加密流解密代理(qs / migu)', summary: '加密流解密代理(qs / mg)',
description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/migu 命中加密流时的地址),' + description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/mg 命中加密流时的地址),' +
'服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' + '服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' +
'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' + 'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' +
'source=migu 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/migu 取流)。', 'source=mg 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。',
tags: ['歌曲资源'], tags: ['歌曲资源'],
params: params({ params: params({
source: { type: 'string', enum: ['qs', 'migu'], description: '平台 id:qs=汽水音乐解密流 / migu=咪咕加密流', example: 'qs' }, source: { type: 'string', enum: ['qs', 'mg'], description: '平台 id:qs=汽水音乐解密流 / mg=咪咕加密流', example: 'qs' },
id: { type: 'string', description: 'qs: 汽水 track id;migu: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' }, id: { type: 'string', description: 'qs: 汽水 track id;mg: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
}), }),
}, },
}, async(request, reply) => { }, async(request, reply) => {
@@ -297,7 +297,7 @@ export const musicRoutes = async(fastify: any) => {
// 咪咕:票据 → 逐块解密流 // 咪咕:票据 → 逐块解密流
const ticket = takeMiguStream(String(id)) const ticket = takeMiguStream(String(id))
if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/migu 取流)') 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/migu/util.ts')
@@ -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/migu/qian/joox/apple(见 /sources 的 album 能力位)。' + description: '支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/jx/ap(见 /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/migu/qian/joox/apple(见 /sources 的 albumSearch 能力位)。', description: '支持 kg/kw/tx/wy/mg/bd/jx/ap(见 /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/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。' + description: '支持 kg/kw/tx/wy/mg/bd/jx/bl/5s/ap(见 /sources 的 playlistSearch 能力位)。' +
'部分平台该能力受限(如 qian 歌单搜索接口已下线、bili 需登录),失败时返回 502。', '部分平台该能力受限(如 bd 歌单搜索接口已下线、bl 需登录),失败时返回 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/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。', description: '支持 kw/kg/tx/wy/mg/bd/jx/bl/5s/ap(见 /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,如 bili 的 season:<id>:<mid>:<bvid>、' + description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bl 的 season:<id>:<mid>:<bvid>、' +
'joox/qian/migu 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。', 'jx/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 能力位;' +
'5sing 歌曲/歌单、apple/bili/joox/qian/migu 三类、kw/kg/tx/wy 部分支持。', '5s 歌曲/歌单、apple/bl/jx/bd/mg 三类、kw/kg/tx/wy 部分支持。',
tags: ['链接解析'], tags: ['链接解析'],
params: params({ source: pSource('parse.parseSong') }), params: params({ source: pSource('parse.parseSong') }),
body: parseBodySchema, body: parseBodySchema,
+3 -3
View File
@@ -20,7 +20,7 @@
// GET /playlist/:source/categories[/:categoryId] 歌单分类 // GET /playlist/:source/categories[/:categoryId] 歌单分类
// POST /parse/{song|album|playlist}/:source 链接解析 // POST /parse/{song|album|playlist}/:source 链接解析
// GET /user-playlist/:source 个人歌单 // GET /user-playlist/:source 个人歌单
// GET /stream/:source/:id 解密流代理(source: qs 汽水 / migu 咪咕) // GET /stream/:source/:id 解密流代理(source: qs 汽水 / mg 咪咕)
// GET /sources /health GET /docs /redoc /openapi.json // GET /sources /health GET /docs /redoc /openapi.json
// //
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
@@ -48,8 +48,8 @@ const PORT = process.env.PORT || 16688
const DOCS_PREFIX = '/docs' 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(汽水音乐)/ migu(咪咕)/ qian(千千)/ ' + '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' +
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。' 'jx / bl(哔哩哔哩)/ 5s / ap(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'))
+48 -48
View File
@@ -514,78 +514,78 @@ await test('专辑详情 /album/lr', async() => {
return `${r.data.info.total} 节` return `${r.data.info.total} 节`
}) })
// ---- 新平台(migu/qian/joox/bili/5sing/apple 移植回归) ---- // ---- 新平台(migu/bd/jx/bl/5s/ap 移植回归) ----
for (const source of ['migu', 'qian', 'joox', 'bili', '5sing', 'apple']) { for (const source of ['mg', 'bd', 'jx', 'bl', '5s', 'ap']) {
await test(`搜索 ${source} 直查`, async() => { await test(`搜索 ${source} 直查`, async() => {
const r = await get(`/search/${source}/${encodeURIComponent(source === '5sing' ? '董贞' : '周杰伦')}?limit=5`) const r = await get(`/search/${source}/${encodeURIComponent(source === '5s' ? '董贞' : '周杰伦')}?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}`
}) })
} }
await test('URL migu(多档候选链)', async() => { await test('URL mg(多档候选链)', async() => {
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
const song = r.data.list.find((s: any) => !s.payPlay) ?? r.data.list[0] const song = r.data.list.find((s: any) => !s.payPlay) ?? r.data.list[0]
const u = await get(`/url/migu/${encodeURIComponent(song.songmid)}?quality=128k`) const u = await get(`/url/mg/${encodeURIComponent(song.songmid)}?quality=128k`)
if (u.code != 0) throw new Error(u.msg) if (u.code != 0) throw new Error(u.msg)
const url = String(u.data.url) const url = String(u.data.url)
if (!url.startsWith('http') && !url.includes('/stream/migu/')) throw new Error('url 非法') if (!url.startsWith('http') && !url.includes('/stream/mg/')) throw new Error('url 非法')
return `实际 ${u.data.type},${url.slice(0, 60)}` return `实际 ${u.data.type},${url.slice(0, 60)}`
}) })
await test('URL qian 免费曲', async() => { await test('URL bd 免费曲', async() => {
// qian 免费曲目少,用「SSR」(上游测试同款厂牌)搜到带直链的曲目 // bd 免费曲目少,用「SSR」(上游测试同款厂牌)搜到带直链的曲目
const r = await get(`/search/qian/${encodeURIComponent('SSR')}?limit=10`) const r = await get(`/search/bd/${encodeURIComponent('SSR')}?limit=10`)
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
const song = r.data.list.find((s: any) => !s.payPlay) const song = r.data.list.find((s: any) => !s.payPlay)
if (!song) throw new Error('未搜到免费曲目') if (!song) throw new Error('未搜到免费曲目')
const u = await get(`/url/qian/${encodeURIComponent(song.songmid)}?quality=128k`) const u = await get(`/url/bd/${encodeURIComponent(song.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 5sing(档位 sq/hq/lq)', async() => { await test('URL 5s(档位 sq/hq/lq)', async() => {
const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`) const r = await get(`/search/5s/${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/5sing/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`) const u = await get(`/url/5s/${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('歌词 migu', async() => { await test('歌词 mg', async() => {
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
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/migu/${encodeURIComponent(song.songmid)}`) const l = await get(`/lyric/mg/${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)
}) })
await test('歌词 5sing', async() => { await test('歌词 5s', async() => {
const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`) const r = await get(`/search/5s/${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/5sing/${encodeURIComponent(song.songmid)}`) const l = await get(`/lyric/5s/${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)
}) })
await test('封面 migu', async() => { await test('封面 mg', async() => {
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
const song = r.data?.list?.[0] const song = r.data?.list?.[0]
if (!song) throw new Error('搜索失败') if (!song) throw new Error('搜索失败')
const p = await get(`/pic/migu/${encodeURIComponent(song.songmid)}`) const p = await get(`/pic/mg/${encodeURIComponent(song.songmid)}`)
if (p.code != 0) throw new Error(p.msg) if (p.code != 0) throw new Error(p.msg)
if (!String(p.data?.url ?? '').startsWith('http')) throw new Error('封面 url 非法') if (!String(p.data?.url ?? '').startsWith('http')) throw new Error('封面 url 非法')
return String(p.data.url).slice(0, 60) return String(p.data.url).slice(0, 60)
}) })
for (const source of ['migu', 'qian', 'joox', 'apple']) { for (const source of ['mg', 'bd', 'jx', 'ap']) {
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)
@@ -594,17 +594,17 @@ for (const source of ['migu', 'qian', 'joox', 'apple']) {
}) })
} }
await test('专辑详情 /album/migu', async() => { await test('专辑详情 /album/mg', async() => {
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
const albumId = r.data?.list?.find((s: any) => s.albumId)?.albumId const albumId = r.data?.list?.find((s: any) => s.albumId)?.albumId
if (!albumId) throw new Error('搜索结果未携带 albumId') if (!albumId) throw new Error('搜索结果未携带 albumId')
const a = await get(`/album/migu/${albumId}`) const a = await get(`/album/mg/${albumId}`)
if (a.code != 0) throw new Error(a.msg) if (a.code != 0) throw new Error(a.msg)
if (!a.data?.list?.length) throw new Error('专辑无曲目') if (!a.data?.list?.length) throw new Error('专辑无曲目')
return `${a.data.info.name}(${a.data.total} 首)` return `${a.data.info.name}(${a.data.total} 首)`
}) })
for (const source of ['migu', 'qian', 'joox', 'apple']) { for (const source of ['mg', 'bd', 'jx', 'ap']) {
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)
@@ -623,44 +623,44 @@ for (const source of ['migu', 'qian', 'joox', 'apple']) {
}) })
} }
await test('歌单详情 /playlist/migu', async() => { await test('歌单详情 /playlist/mg', async() => {
const c = await get('/playlist/migu/categories') const c = await get('/playlist/mg/categories')
const cat = c.data.list[0] const cat = c.data.list[0]
const pl = await get(`/playlist/migu/categories/${encodeURIComponent(cat.id)}?limit=3`) const pl = await get(`/playlist/mg/categories/${encodeURIComponent(cat.id)}?limit=3`)
const id = pl.data?.list?.[0]?.id const id = pl.data?.list?.[0]?.id
if (!id) throw new Error('未取到歌单 id') if (!id) throw new Error('未取到歌单 id')
const d = await get(`/playlist/migu/${id}?limit=5`) const d = await get(`/playlist/mg/${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} 首,本页 ${d.data.list.length} 首` return `${d.data.total} 首,本页 ${d.data.list.length} 首`
}) })
await test('歌单详情 /playlist/bili(合集)', async() => { await test('歌单详情 /playlist/bl(合集)', async() => {
const s = await get(`/playlist/bili/search/${encodeURIComponent('周杰伦')}?limit=3`) const s = await get(`/playlist/bl/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/bili/${encodeURIComponent(id)}?limit=5`) const d = await get(`/playlist/bl/${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} 首`
}) })
await test('链接解析 /parse/song/migu', async() => { await test('链接解析 /parse/song/mg', async() => {
const r = await postJson('/parse/song/migu', { link: 'https://music.migu.cn/v3/music/song/600913000002601091' }) const r = await postJson('/parse/song/mg', { link: 'https://music.migu.cn/v3/music/song/600913000002601091' })
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?.song?.songmid) throw new Error('缺少 song') if (!r.body.data?.song?.songmid) throw new Error('缺少 song')
return `${r.body.data.song.name} / ${r.body.data.song.singer}` return `${r.body.data.song.name} / ${r.body.data.song.singer}`
}) })
await test('链接解析 /parse/playlist/qian', async() => { await test('链接解析 /parse/playlist/bd', async() => {
const r = await postJson('/parse/playlist/qian', { link: 'https://music.91q.com/songlist/309735' }) const r = await postJson('/parse/playlist/bd', { link: 'https://music.91q.com/songlist/309735' })
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} 首)`
}) })
await test('链接解析 /parse/album/apple', async() => { await test('链接解析 /parse/album/ap', async() => {
const r = await postJson('/parse/album/apple', { link: 'https://music.apple.com/us/album/the-life-of-a-showgirl-the-encore/6814997249' }) const r = await postJson('/parse/album/ap', { 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,30 +682,30 @@ await test('链接解析 /parse/song/kw(正向)', async() => {
return `kw 解析成功: ${r.body.data.song.name}` return `kw 解析成功: ${r.body.data.song.name}`
}) })
await test('扫码二维码创建 POST /login/bili/qr', async() => { await test('扫码二维码创建 POST /login/bl/qr', async() => {
const r = await postRaw('/login/bili/qr') const r = await postRaw('/login/bl/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/bili/qr/check', async() => { await test('扫码轮询 POST /login/bl/qr/check', async() => {
const created = JSON.parse((await postRaw('/login/bili/qr')).body) const created = JSON.parse((await postRaw('/login/bl/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/bili/qr/check', { key }) const r = await postJson('/login/bl/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/5sing') const r = await getRaw('/user-playlist/5s')
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)
}) })
await test('咪咕解密流代理 无票据 → 404', async() => { await test('咪咕解密流代理 无票据 → 404', async() => {
const r = await getRaw('/stream/migu/ffffffffffffffffffffffffffffffff') const r = await getRaw('/stream/mg/ffffffffffffffffffffffffffffffff')
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`) if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
return JSON.parse(r.body).msg?.slice(0, 50) return JSON.parse(r.body).msg?.slice(0, 50)
}) })