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 零残留。
This commit is contained in:
1 parent
3381e9b906
commit
1b972ec4dd
20 files changed
+106
-106
No files matched your search
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(migu)、千千音乐(bd)、JOOX(jx)、哔哩哔哩(bl)、5sing(5s);有声平台:喜马拉雅(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(解锁高档位) |
|
||||||
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
||||||
| JOOX | `jx` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
| JOOX | `jx` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
||||||
| 哔哩哔哩 | `bl` | ✅ | 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 | `5s` | ✅ | 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/bd/jx/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/bd/jx/apple) |
|
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/jx/ap) |
|
||||||
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/migu/bd/jx/bl/5s/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/bd/jx/bl/5s/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/bd/jx/bl/5s/apple;bl 支持合集/多分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/5s/apple/bl/jx/bd/migu(其中 bl、5s 无 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) |
|
||||||
@@ -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` 解密代理
|
||||||
- **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
|
- **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
|
||||||
- **jx**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
|
- **jx**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
|
||||||
- **bl**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
|
- **bl**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
|
||||||
- **5s**:`<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` 查询参数。
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@ 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`,不要当作完整曲。
|
||||||
- **bl 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bl/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
- **bl 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bl/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
||||||
- **5s 歌单详情**:依赖 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。
|
||||||
|
|||||||
+4
-4
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
||||||
|
|
||||||
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `migu`(咪咕)/
|
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/
|
||||||
`bd`(千千)/ `jx` / `bl`(哔哩哔哩)/ `5s` / `apple`(Apple Music,仅试听),
|
`bd`(千千)/ `jx` / `bl`(哔哩哔哩)/ `5s` / `ap`(Apple Music,仅试听),
|
||||||
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
||||||
|
|
||||||
> **本文档是人工速查版,可能滞后于代码。**
|
> **本文档是人工速查版,可能滞后于代码。**
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
### 歌曲 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、`bl` 的 `<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 需要) |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -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: 'bd', name: '千千音乐', module: qian },
|
{ id: 'bd', name: '千千音乐', module: qian },
|
||||||
{ id: 'jx', name: 'JOOX', module: joox },
|
{ id: 'jx', name: 'JOOX', module: joox },
|
||||||
{ id: 'bl', name: '哔哩哔哩', module: bili },
|
{ id: 'bl', name: '哔哩哔哩', module: bili },
|
||||||
{ id: '5s', 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,
|
||||||
bd: qianQualitys,
|
bd: qianQualitys,
|
||||||
jx: jooxQualitys,
|
jx: jooxQualitys,
|
||||||
bl: biliQualitys,
|
bl: biliQualitys,
|
||||||
'5s': 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)
|
||||||
|
|||||||
+3
-3
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
/** 音乐平台标识 */
|
/** 音乐平台标识 */
|
||||||
export type Source =
|
export type Source =
|
||||||
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
|
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
|
||||||
| 'migu' | 'bd' | 'jx' | 'bl' | '5s' | 'apple'
|
| 'mg' | 'bd' | 'jx' | 'bl' | '5s' | 'ap'
|
||||||
| 'xm' | 'me' | 'qt' | 'lr'
|
| 'xm' | 'me' | 'qt' | 'lr'
|
||||||
|
|
||||||
/** 音质标识(wav 仅部分接口声明使用) */
|
/** 音质标识(wav 仅部分接口声明使用) */
|
||||||
|
|||||||
@@ -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, '')
|
||||||
|
|||||||
+2
-2
@@ -477,7 +477,7 @@ export const loginRoutes = async(fastify) => {
|
|||||||
schema: {
|
schema: {
|
||||||
summary: '查看登录状态',
|
summary: '查看登录状态',
|
||||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bl 为已存凭据(含 VIP 等级与最近更新时间),' +
|
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bl 为已存凭据(含 VIP 等级与最近更新时间),' +
|
||||||
'qs/xm/me/lr/bd/jx/apple/5s 为已存凭据。',
|
'qs/xm/me/lr/bd/jx/ap/5s 为已存凭据。',
|
||||||
tags: ['登录'],
|
tags: ['登录'],
|
||||||
},
|
},
|
||||||
}, async() => {
|
}, async() => {
|
||||||
@@ -496,7 +496,7 @@ export const loginRoutes = async(fastify) => {
|
|||||||
bl: biliAuth.status(),
|
bl: biliAuth.status(),
|
||||||
bd: qianStatus(),
|
bd: qianStatus(),
|
||||||
jx: jooxStatus(),
|
jx: jooxStatus(),
|
||||||
apple: appleStatus(),
|
ap: appleStatus(),
|
||||||
'5s': fivesingStatus(),
|
'5s': fivesingStatus(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+16
-16
@@ -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/bd/jx/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/bd/jx/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,7 +563,7 @@ 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/bd/jx/bl/5s/apple(见 /sources 的 playlistSearch 能力位)。' +
|
description: '支持 kg/kw/tx/wy/mg/bd/jx/bl/5s/ap(见 /sources 的 playlistSearch 能力位)。' +
|
||||||
'部分平台该能力受限(如 bd 歌单搜索接口已下线、bl 需登录),失败时返回 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: '华语' } }),
|
||||||
@@ -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/bd/jx/bl/5s/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'),
|
||||||
@@ -631,7 +631,7 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
const playlistSchema = (withPage: boolean) => ({
|
const playlistSchema = (withPage: boolean) => ({
|
||||||
summary: '获取歌单详情与曲目列表',
|
summary: '获取歌单详情与曲目列表',
|
||||||
description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bl 的 season:<id>:<mid>:<bvid>、' +
|
description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bl 的 season:<id>:<mid>:<bvid>、' +
|
||||||
'jx/bd/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 能力位;' +
|
||||||
'5s 歌曲/歌单、apple/bl/jx/bd/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
@@ -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(咪咕)/ bd(千千)/ ' +
|
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' +
|
||||||
'jx / bl(哔哩哔哩)/ 5s / 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'))
|
||||||
|
|
||||||
|
|||||||
+26
-26
@@ -514,9 +514,9 @@ await test('专辑详情 /album/lr', async() => {
|
|||||||
return `${r.data.info.total} 节`
|
return `${r.data.info.total} 节`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- 新平台(migu/bd/jx/bl/5s/apple 移植回归) ----
|
// ---- 新平台(migu/bd/jx/bl/5s/ap 移植回归) ----
|
||||||
|
|
||||||
for (const source of ['migu', 'bd', 'jx', 'bl', '5s', '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 === '5s' ? '董贞' : '周杰伦')}?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)
|
||||||
@@ -525,14 +525,14 @@ for (const source of ['migu', 'bd', 'jx', 'bl', '5s', 'apple']) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -555,11 +555,11 @@ await test('URL 5s(档位 sq/hq/lq)', async() => {
|
|||||||
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('歌词 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)
|
||||||
@@ -575,17 +575,17 @@ await test('歌词 5s', async() => {
|
|||||||
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', 'bd', 'jx', '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', 'bd', 'jx', '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', 'bd', 'jx', '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,13 +623,13 @@ for (const source of ['migu', 'bd', 'jx', '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} 首`
|
||||||
@@ -645,8 +645,8 @@ await test('歌单详情 /playlist/bl(合集)', async() => {
|
|||||||
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}`
|
||||||
@@ -659,8 +659,8 @@ await test('链接解析 /parse/playlist/bd', async() => {
|
|||||||
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
|
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('链接解析 /parse/album/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} 首)`
|
||||||
@@ -705,7 +705,7 @@ await test('个人歌单 未支持的平台 → 502', async() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user