Compare commits

...
47 Commits
Author SHA1 Message Date
snowy-yang 800871ef6e refactor(qs)!: 汽水拆分为独立服务端 SodaServer
BREAKING CHANGE: /search/qs、/url/qs、/stream/qs、/login/qs/* 不再由本服务提供,
改由独立服务端 SodaServer(gitea.4i.hk/hhhge/SodaServer)承载,API 路径兼容。

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

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

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

验证:typecheck ✓、pnpm test 112/112 ✓
2026-10-03 21:04:55 +08:00
snowy-yang 8c02fb5c15 refactor(sdk): 审计 P2 卫生项清理——请求层收敛与死代码清除
- 删除旧 sdk/request.ts,全部 import 收敛到 sdk/common/request.ts(85 文件)
- 注释死代码 630+ 行、裸 console.log 吞错 6 处、未使用符号 26 处清零
- bili 状态校验去重;utils DNS 预解析失败日志改 warn 带上下文
- CODE-AUDIT.md:第四节 P2 清理记录 + 第八节线索逐条复核结论(kw/kg/tx/wy parse 骨架相似实质各异,不抽公共层)
2026-10-03 20:48:54 +08:00
snowy-yang ecca1b8458 feat(qs): PC 签名改双模式供给,签名工具链迁出独立仓库 qs-signer
- mssdk.ts:进程内 bdms.node 加载移除;优先 QS_SIGN_SERVER 调签名服务
  (兼容 qs-signer 仓库 server.mjs 与 mssdk-sign 两个实现,/sign 契约互通),
  无服务时 QS_HELIOS/QS_MEDUSA 或 data/qs-sign.json 固定值兜底,都没有走安卓自签
- musicSearch:PC 通道签名失败/请求异常/风控空结果自动降级安卓通道(原为同通道重试)
- signer/、tools/mssdk-sign/、scripts/qs-local-login.mjs 迁至 qs-signer
  (gitea.4i.hk/hhhge/qs-signer),soda-mssdk/ 二进制目录一并挪走
2026-10-03 20:45:22 +08:00
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
snowy-yang 14f7295fd4 chore(tools): 入库汽水签名工具链(独立签名服务与逆向记录)
- tools/mssdk-sign/:C++ 实现的签名服务,附 mssdk 内部结构记录、MinGW 与
  MSVC 两套构建脚本、调用示例 client.mjs 与自检入口
- signer/server.mjs:Node 实现的等价签名服务,面向「主服务不在 Windows 上」
  的场景——bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node 加载,
  故把「加载官方模块」关进独立进程,主服务经 HTTP 取 X-Helios / X-Medusa

两者都不被 src/sdk 引用:运行期走 sdk/qs/mssdk.ts 直接加载官方 bdms.node,
它们是组件升级、签名失效时重新定位入口并验证的手段,放在仓库里才能跟着
代码一起维护。

专有二进制(mssdk-sign.exe、metasecml.dll)由 tools/mssdk-sign/.gitignore
排除,不随仓库分发。
2026-10-03 10:50:34 +08:00
snowy-yang dd28ba157c docs: 文档与实现对齐,并收录全量代码检查报告
- README:平台能力表按实际能力位修正(qs 歌单/专辑改 —、kw/kg/tx/wy 的
  parse 改 ✅、qt/lr 封面改 ✅),接口表补 album 分页位、parse/playlist
  支持平台、user-playlist 仅 tx/wy、DELETE 登录端点、qs 扫码方式;
  项目结构补 http/ 下 4 个文件与 routes-login 的 bili/lr
- docs/API.md:整篇改为指向 /openapi.json 的速查版,把唯一的接口事实
  来源收敛到 JSON Schema;平台数按实际 15 个更新
- CODE-AUDIT.md:新增,记录 2026-10-03 全量检查的 P0/P1/P2 与文档不一致项,
  顶部标注修复状态(已修 P0/P1,未动项明确列出)

为什么改写而不是逐条打补丁:文档与实现双向失配的地方太多,逐条修补会留下
自相矛盾的段落;改成「能力以 /sources 与 /openapi.json 为准,文档只做索引」
后,漂移的结构性来源就被消除了。
2026-10-03 00:27:30 +08:00
snowy-yang de2dd311ee feat(qs): 接入官方签名模块、修复扫码登录链路、新增本机登录脚本
- passport:响应自带的 qrcode 图指向已 404 的旧扫码页
  (ucenter_web/app/sdk-next),抖音扫码会提示「无法访问页面」;改为用
  qrcode_index_url 的 token 重建官方 bff-pc.qishui.com/light/invoke/scan_login
  地址,与客户端 renderer 的 getHandleScanCodeH5Img 算法一致。同时状态
  映射按官方 renderer 语义对齐(1/new 待扫码、2/scanned 已扫码待确认、
  3/confirmed 已确认),并采集轮询下发的 Set-Cookie、响应带 redirect_url
  时补跳一次,否则拿不到 sessionid
- qs/mssdk:进程内加载官方 bdms.node 生成 X-Helios / X-Medusa 签名,
  不另起 HTTP 签名服务;缺原生模块时整体降级,不影响匿名可用
- scripts/qs-local-login.mjs:直接读本机官方客户端的 Cookies 与 DeviceV1
  写入 data/auth.json,免扫码(独立脚本,不参与服务运行)

为什么需要 mssdk 签名:汽水对未签名请求回 HTTP 200 + 0 字节,把失败伪装
成空成功,没有签名就分不清「接口异常」与「风控拦截」。
2026-10-03 00:27:19 +08:00
snowy-yang 68e071b166 feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力
四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse
(tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。

- kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`),
  `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声;
  统一还原成 `=` 再对外返回
- wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐
- kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单

为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
2026-10-03 00:26:51 +08:00
snowy-yang 33b96f92c4 feat(sdk): 新增咪咕/千千/JOOX/哔哩哔哩/5sing/Apple Music 六平台
平台注册表由 9 个扩到 15 个,同时补齐 sdk/types.ts 的类型与
OpenAPI 文档里的能力位声明。

- migu:命中 Z3D/I3D 加密候选时不返回直链,改由服务端解密代理出流,
  SDK 侧只负责登记票据,避免客户端拿到无法播放的密文
- apple:只提供 preview 试听片段(完整曲为 DRM),标 noAggregate
  排除在跨平台精确匹配之外,防止用它顶掉其它平台的可播结果
- 其余平台按既有 PlatformModule 约定实现搜索/专辑/歌单/歌词

为什么合并成一次提交:六平台共用同一份注册表与能力位声明,
拆开会产生互相引用、单独无法通过类型检查的中间态。
2026-10-03 00:26:32 +08:00
snowy-yang 47ce651f46 fix: 修复代码审查发现的 P0/P1 缺陷(含测试加固)
审查明细见 CODE-AUDIT.md。本提交覆盖其中的代码级修复:

P0-2 migu 解密流异常逃逸 → 进程崩溃
  src/http/migu-stream.ts 把 transform/onChunk 就地 try/catch,转 reject 并 res.destroy()。
  为什么:二者在 res 的 data 事件回调里同步执行,抛出的异常既不在 Promise 链上、
  也不在路由层 try/catch 内,会逃逸成 uncaughtException(全项目无全局处理器)直接杀进程。
  已用最小复现验证:修复前 exit 9,修复后正常 reject。

P0-1 /login/lr 缺失 → lr 取流 100% 不可用
  src/routes-login.ts 补 POST/DELETE /login/lr。
  为什么:sdk/lr/auth.ts 早已实现 login、该文件也已 import lrAuth,只差路由注册;
  lr 取流强制要求登录态,缺此入口等于整个平台不可用,而 README 已将其文档化。

P0-3 + P1-7 凭据存储
  sdk/auth/store.ts 写入改 0600 + 「临时文件 + rename」原子替换;解析失败改为改名备份而非静默清空;
  新增 DATA_DIR 环境变量(测试隔离用)。
  为什么:原先直接覆写明文 JSON(0644)且解析失败静默置空,写入中途被杀会留下截断 JSON,
  下次启动解析失败后,任意一次 setCredentials 都会以空表覆盖,导致全部平台凭据丢失。

P1-9 / P1-10 / P1-11 / P1-12 平台模块缺陷
  sdk/kg/krcDecode.ts:空歌词显式 resolve('')(原先裸 return 让 Promise 永久 pending,请求挂死)
  sdk/kg/lyric.ts:重试改调 getLyricDownload(原先误调 getLyric,参数语义不同导致重试恒失败)
  sdk/kw/songList.ts:重试改自递归(原先误调 getListDetail,返回类型错位且把 tryNum 当 page)
  sdk/kw/songList.ts:删除 URL 模板中多余的右花括号

P1-6 /song-list 参数契约
  src/routes.ts 移除不生效的 limit 声明。
  为什么:各平台 getList 签名不一致(如 kw 为 (sortId, tagId, page, tryNum)),无法统一透传,
  保留声明只会让文档与实现不符。

P1-4/5/13/15 冒烟测试加固
  test/smoke-test.ts:子实例统一登记 + 进程退出回收(原先异常路径留下孤儿实例,曾累积 5 个);
  启动超时 10s→30s 且失败时附子进程输出、提前退出立即报错;显式 SOURCE_ENABLED='' 与 DATA_DIR
  隔离(原先继承 .env,取流用例走外部音源掩盖 SDK 回归,且 /login/refresh 会改写真实 data/auth.json);
  AUTH 断言移入 test()(原先裸抛错会跳过实例回收);修正 642 行过期断言并补 kw parse 正向用例。

P1-8 src/auth/store.ts 删除引用已不存在命令 `pnpm build` 的过期注释。
src/server.ts 头注释的接口清单改为 path 风格,与实际路由一致。

注:src/routes.ts、src/routes-login.ts、src/server.ts、test/smoke-test.ts 同时承载了工作区
既有的在途改动(parse/playlist/album 路由接入与相应用例),一并随本提交入库。
2026-10-03 00:23:25 +08:00
snowy-yang ece9c5fc9a chore(git): 忽略 agent 工作目录、取消跟踪本机配置
- .gitignore 增加 .workbuddy/ 与 .claude/:二者都是本机产物(记忆日志、工具权限配置),
  其中 .claude/settings.local.json 内容含本机绝对路径,已 git rm --cached 停止跟踪
  (为什么:避免把本机环境细节提交进公开仓库,也避免 git add . 误收)
- pnpm-workspace.yaml 补注释说明 allowBuilds 的用途
  (为什么:isolated-vm 的原生构建脚本必须显式放行,否则 pnpm 静默跳过 postinstall,
   node_modules/isolated-vm 无 build/Release/*.node,加载失败后 ivm 沙箱音源会静默降级)
2026-10-03 00:21:02 +08:00
snowy-yang cca7e63ec3 fix(dev): pnpm dev 容忍 sources/ 缺失——scripts/dev.mjs 仅为存在的目录注册 --watch-path 2026-10-02 21:09:20 +08:00
snowy-yang 436b102ae2 feat(api): 列表接口分页统一 path 段——boards/song-list/singer songs与albums/album 支持可选 /:page,弃用 ?page= 2026-10-02 20:57:31 +08:00
snowy-yang baa34a501c feat(api): search/find 改 path 风格——/search/:source/:keyword[/:page] 与 /url 同构,find 支持单平台/all 2026-10-02 20:51:50 +08:00
snowy-yang cf0874fc15 feat(auth): 流代理升级短时签名 URL——?exp=&sig=(HMAC 派生 10 分钟有效)替代裸 ?key=,秘钥不再落入 URL/日志 2026-10-02 20:39:25 +08:00
snowy-yang 415a04dea8 feat(auth): 统一 X-Server-Key 全路径鉴权(防扫描探测)+ per-IP 滑动窗口限流 2026-10-02 20:31:59 +08:00
snowy-yang 649ebbe12b feat(auth): 调用秘钥鉴权——AUTH_KEY 环境变量启用,Bearer/X-Api-Key/?key= 三种携带方式,流地址自动附带 key 2026-10-02 20:15:36 +08:00
snowy-yang bc1d23cbba fix(test): smoke 默认随机端口自起实例——避免连到 16688 常驻旧实例导致新用例误报 2026-10-02 20:08:35 +08:00
snowy-yang cd5d9b1c91 fix(me): 搜索对齐网页版——dramaapi/search 正剧置顶,d:<剧id> 条目自动取首个免费集 2026-10-02 20:08:35 +08:00
snowy-yang 1fec828547 feat: 平台注册与路由收尾——9 平台(含 xm/me/qt/lr 有声)、复合 id 约定、64k 档位、测试与文档 2026-10-02 19:43:21 +08:00
snowy-yang 6fb866e343 feat(lr): 懒人听书平台接入——App 通道搜索、web 章节列表、账密登录取流 2026-10-02 19:43:21 +08:00
snowy-yang 83d3eb3b49 feat(qt): 蜻蜓FM平台接入——audiostream HMAC-MD5 签名直链、SSR 搜索/频道节目列表 2026-10-02 19:43:20 +08:00
snowy-yang 020e9bbb2e feat(me): 猫耳FM平台接入——App API HMAC 签名取明文直链、匿名搜索/剧集、token 绑定 2026-10-02 19:43:20 +08:00
snowy-yang 5a9915b74c feat(xm): 喜马拉雅平台接入——匿名搜索/专辑/免费曲直链(hdaa xm-sign+AES-ECB 解密)、cookie 绑定解锁付费 2026-10-02 19:43:09 +08:00
snowy-yang 44852864e2 feat(tx): flac24bit 高阶音质候选链——RS01(Hi-Res)/Q000(大师)/AI00(臻品母带) 逐级回退 2026-10-02 19:43:08 +08:00
snowy-yang 3b40ba4c8f feat(kg): 账密登录与 20028 验证码处理——/v9/login_by_pwd、ssa-code 挑战链、行为指纹模拟、dfid 正式注册 2026-10-02 19:43:08 +08:00
snowy-yang e796e622f2 docs(readme): 补齐 qs 平台与双通道后的过期内容
- 平台列表/接口表补 qs(抖音扫码登录、/stream/qs 解密流代理)与取流链说明
- 歌曲 ID 约定补 qs,删掉已失效的 kg 风控告示(2026-10-01 /v5/url 已修复,
  VIP 歌改为需登录的准确表述)
- 外部音源章节说明候选链只覆盖 kw/kg/tx/wy、失败回落 SDK 与酷我兜底
- 已知限制重写:kg 登录取流、kw 严格档位校验与降级链、qs 试听/解密代理
- 项目结构注释补 qs 目录与 auth 续期描述
2026-10-01 22:54:05 +08:00
snowy-yang b3e1cc5f67 feat(url): 取流链兜底——外部音源→SDK 直连→酷我跨平台匹配
/url/:source/:id 顺序尝试:外部音源脚本(SOURCE_ENABLED 启用时)→
SDK 官方直连→都失败时用 findMusic 仅在 kw 内按歌名/歌手/时长匹配
同一首歌后走 kw 通道(匿名可拿无损)。kg 的 hash 自动反查歌曲信息,
其它平台可传 name/singer/interval 查询参数启用兜底;
kw 自身不再二次兜底。响应新增 via 字段标记实际命中通道
(script/平台 id/kw,直接命中不返回),type 改为实际音质(降级链生效);
全部失败返回聚合各阶段错误的 502。findMusic/searchMusic 增加 only 参数。
2026-10-01 22:54:00 +08:00
snowy-yang 0f9cff2a08 feat(qs): 汽水音乐平台接入——匿名搜索/试听、抖音扫码登录、解密流代理
- sdk/qs 平台模块(安卓参数指纹匿名搜索)+ sdk/apis/qs 取流:
  匿名 30s 试听,登录态走签名 track_v2 + spade key AES-CTR 解密
- /login/qs/qr 抖音扫码登录(授权后 sessionid 解锁完整曲目)
- /stream/qs/:id 解密流代理,/url 返回的相对地址按请求 host 补全
- 回归测试覆盖 kw 双通道(VIP 真音频/降级链)与 qs 试听;start.bat 去掉
  SOURCE_ENABLED 直启
2026-10-01 22:43:49 +08:00
snowy-yang aa4f2eec67 feat(kw): 取流双通道重构——mobi.s convert_url_with_sign 主通道
主通道 mobi.s(keluze 指纹,user=0 匿名):VIP/付费歌放行真音频,
晴天 320k mp3/2000k flac 与 N_MINFO 元数据逐字节吻合,hi-res 可用;
档位缺失会静默降级甚至吐 mgg/mflac 加密,必须严格校验
「返回 bitrate==请求档位 且格式为明文」否则沿降级链换档重试。
回退通道 web playUrl(Hm_Iuvt cookie 异或哈希 Secret 签名):匿名恒 128k,
VIP 歌明确拒绝,仅作 mobi 网络故障兜底。旧 antiserver 链移除。
2026-10-01 22:43:43 +08:00
snowy-yang ddc44a276d fix(kg): /v5/url 成功响应按顶层 url/backupUrl 数组解析
网关成功(status:1)时播放地址在顶层 url: string[] 与 backupUrl: string[],
并非旧 data:[{url}] 结构,导致 VIP 登录后所有取流仍报"获取失败";
旧结构保留为兜底。album_audio_id/album_id 加 Number.isFinite 守卫——
/url 路由把 hash 传给 songmid 时 Number(hash)=NaN 会被网关拒答。
实测珊瑚海四档(VIP)全通,320k/flac/hi-res 与曲库大小逐字节吻合。
2026-10-01 22:43:29 +08:00
snowy-yang 0f9989b26f feat(login): kg 扫码/短信验证码登录、QQ 扫码登录
- kg:sdk/auth 新增 qrKey/qrCheck 扫码与 sendCaptcha/cellphoneLogin 短信登录,路由接线
- tx:新增 /login/tx/qr(/check)(移植 sansenjian/qq-music-api):ptlogin 扫码 →
  check_sig 换 p_skey → OAuth authorize 换 code → QQConnectLogin 换凭据;
  状态码对齐 wy(800 过期/801 等待/802 已扫/803 成功),qrsig 失效 403 映射 800;
  成功凭据入库(通常含 openid/refresh_key,可走自动续期)
2026-10-01 16:35:08 +08:00
snowy-yang 96f6a7fe91 feat(source): 外部音源迁 src/ 并新增 source-simple 进程内直载
- apis/source(.ivm).ts → src/source(.ivm).ts(音源属服务端职责,不入 SDK)
- 新增 src/source-simple.ts:sources/*.mjs 进程内直载(getMusicUrl 极简契约),
  与 *.js 的 IVM 沙箱(globalThis.lx 契约)按扩展名混排候选链,失败降权冷却
- server 挂载决策:SOURCE_ENABLED 非 0/false 启用,/url 取流分流到外部音源;
  音源模式下登录态不参与取流,自动续期不启动(手动 /login/refresh 仍可用)
- 新增 src/env.ts(.env 最先加载)与 test/source-simple-test.ts(并入 pnpm test)
2026-10-01 16:35:08 +08:00
snowy-yang 66d06ef48a refactor(sdk): tipSearch 全删、歌词解码器归位、IPC 桥移除、api-source 层拆除
- 四平台 tipSearch 全部删除(永不做搜索联想),types 同步去掉 tipSearch 字段
- 歌词解码器归位:kg/krcDecode、tx/qrcDecode、kw/lyricDecode(原 common/ 下三处)
- rendererIpc/ipcNames IPC 桥删除,SDK 纯服务端化
- supportQuality 由各平台 apis 内联导出(sdk/index 汇总),kg/kw/tx/wy 改直连 apis/*,api-source 旧层移除
2026-10-01 16:33:53 +08:00
snowy-yang 3c51e4d06a feat(dev): 热重载开发模式(node --watch 限定 src/sdk/sources 目录)
- package.json 新增 dev 脚本;--watch-path 限定监视范围,
  规避 @fastify/swagger-ui 启动时写 node_modules 触发的多余重启
- start.bat 支持 dev 参数(保留 SOURCE_ENABLED 外部音源开关)
- README 快速开始补充 pnpm dev
2026-09-29 20:24:40 +08:00
snowy-yang aca7928019 feat(login): kg/tx 登录态自动续期(移植 lx-music-api-server 方案)
- kg: login_by_token 续期(AES-256-CBC + RSA 裸加密,参考其 refresh/kg.py),新 token 回写 data/auth.json
- tx: 新增 sdk/auth/tx.ts —— cookie 解析、VIP 检测(vip_login_base)、
  music.login.LoginServer 续期(走既有 musicu.fcg 未签名通道,最佳努力)
- 调度:启动 10s 后首刷 + 每 24h 一次(对齐参考项目 86400s + next_run_time=now)
- /login/tx 支持可选 App 抓包凭据(openId/accessToken/refreshToken/refreshKey)
- 新增 POST /login/refresh 手动续期;/login/status 增补 tx 字段与更新时间
- 补充 /login/tx 文档行与 smoke 测试
2026-09-29 20:16:33 +08:00
snowy-yang 9d7cb6f39a fix: /search 的 name 参数示例与描述改为纯歌名(拼入歌手会被翻唱版顶掉原曲) 2026-09-29 20:09:21 +08:00
snowy-yang 354df2e739 refactor: 架构整固——分层单向化、全量 TypeScript、直接运行
- 移除 bd/mg 两平台及磁盘残留产物;清理 api-test 死脚本、musicSdk/message+options 死模块、infSign vendor
- musicSdk/ 改名 sdk/;src/common、src/auth、src/apis 整体移入 sdk/,依赖方向定为 src → sdk 严格单向
- src/ 手写 JS 全量转 .ts,删除 JS 类型垫片 modules.d.ts;源码 import 统一 .ts 后缀
- Node 原生类型剥离直接运行 .ts(engines >=22.18),取消 tsc 发射产物,tsc 仅类型检查(pnpm typecheck)
- 删除 kwdes 备用实现与 src/index.ts 转发垫片;测试移至 test/smoke-test.ts
- common/request.ts 补显式导出类型;修复 set-cookie 单值形态、ApiError 类字段等隐患

验证:tsc 零错误,smoke-test 41/41
2026-09-25 23:06:23 +08:00
snowy-yang c8c939aa32 feat(kg): 取流切换到手机客户端通道(gateway /v5/url,android 签名) 2026-09-18 21:28:43 +08:00
snowy-yang 45f3a4b989 feat(login): 新增网易云扫码/手机号登录与酷狗 token 绑定 2026-09-18 19:15:11 +08:00
snowy-yang fd5e2122d3 chore: 还原被 tmp 下临时依赖安装波及的 pnpm-lock.yaml 2026-09-18 18:45:57 +08:00
snowy-yang 41b5d20b1e feat(wy): 歌手歌曲列表填充专辑封面 2026-09-18 18:45:06 +08:00
241 changed files with 16114 additions and 5333 deletions

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ls -la \"C:\\\\Users\\\\Administrator\\\\.claude\\\\projects\\\\d--MusicServer\\\\memory\" 2>/dev/null; cat \"C:\\\\Users\\\\Administrator\\\\.claude\\\\projects\\\\d--MusicServer\\\\memory\\\\MEMORY.md\" 2>/dev/null; ls /d/MusicServer/*.md 2>/dev/null)",
"Read(//c/Users/Administrator/.claude/projects/d--MusicServer/memory/**)",
"Bash(grep -rn \"from '\\\\.\\\\./\\\\.\\\\./request'\" /d/MusicServer/musicSdk --include=\"*.js\" -l)",
"Bash(grep -v \"from '\\\\./\\\\|from '\\\\.\\\\./\\\\|from '\\\\.\\\\./\\\\.\\\\./\")"
]
}
}
+9 -7
View File
@@ -5,12 +5,12 @@ yarn.lock
# 音源脚本(第三方、不可审计,不入库;放入 sources/ 自动加载) # 音源脚本(第三方、不可审计,不入库;放入 sources/ 自动加载)
sources/ sources/
lx-music-source-v5.js
# Runtime # Runtime
*.log *.log
logs/ logs/
tmp/ tmp/
data/
.env .env
.env.local .env.local
.env.*.local .env.*.local
@@ -22,6 +22,10 @@ Thumbs.db
# ZCode 本地目录 # ZCode 本地目录
.zcode/ .zcode/
# Agent 工作目录(本机记忆日志、工具权限配置;含本机绝对路径,不入库)
.workbuddy/
.claude/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
@@ -34,9 +38,7 @@ dist/
coverage/ coverage/
.nyc_output/ .nyc_output/
# TypeScript in-place emit (musicSdk source is .ts) # TypeScript:源码全部 .ts、由 Node 直接运行(类型剥离),不再生成 .js 产物。
musicSdk/**/*.js # 以下规则仅作保险,防止旧的发射产物被误生成后提交。
!musicSdk/**/api-test.js src/**/*.js
!musicSdk/**/api-temp.js sdk/**/*.js
!musicSdk/**/temp/*.js
!musicSdk/**/vendors/*.js
+336
View File
@@ -0,0 +1,336 @@
# MusicServer 全量代码检查报告
**检查时间**:2026-10-03
**后续变更**:2026-10-21 汽水平台(qs)整体拆分至独立服务端 SodaServer(`gitea.4i.hk/hhhge/SodaServer`),文中 `sdk/qs`、`sdk/apis/qs.ts`、`/login/qs`、`/stream/qs` 及 README qs 行等表述已随拆分失效。
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
第四节 P2 卫生项已于 2026-10-03 清理完毕(注释死代码 630+ 行、裸 `console.log` 吞错 6 处、未使用符号 26 处清零、bili 状态校验去重);
第八节线索逐条复核:qian 导出名、migu 异常消息、Quality `preview`、bili 重试重复均已过时(早前重写时已解决),kw/kg/tx/wy parse/playlist 仅有骨架相似、实质各异,评估后不抽公共层。
第五节「文档与代码不一致」已按 `/sources` 与路由实现逐条校准。
各条的修复要点见下文对应条目。
**检查范围**:`src/`(15 文件 / 3,103 行)、`sdk/`(173 文件 / 21,919 行)、`test/`(2 文件 / 1,081 行),共约 26,100 行 TypeScript
**检查方法**:
1. 静态门禁:`tsc`、`tsc --noUnusedLocals --noUnusedParameters`、离线音源测试、冒烟测试
2. 全量模式扫描:`any` 滥用、`@ts-ignore`、TODO/FIXME、调试输出、死代码、硬编码敏感值
3. 四组分区深度审查:HTTP 层 / SDK 核心(认证+取流)/ SDK 平台模块 / 测试与文档
4. 针对性复现验证:对判定为 P0 的问题编写最小复现脚本确认
---
## 一、基线状态
| 检查项 | 结果 |
| --- | --- |
| `pnpm typecheck`(tsc strict) | ✅ 通过 |
| 未使用符号(`--noUnusedLocals`) | ⚠️ 26 处 |
| 离线测试 `test/source-simple-test.ts` | ✅ 8/8 通过 |
| 冒烟测试 `test/smoke-test.ts` | ⚠️ **不稳定**:一次 110/111,一次直接崩溃(见 P1-4) |
| `@ts-ignore` / `@ts-expect-error` | ✅ 0 处(纪律良好) |
| TODO / FIXME / HACK | ✅ 0 处 |
| lint / CI 配置 | ❌ 均无(质量门禁仅 tsc + 冒烟测试) |
---
## 二、P0 阻断级
### P0-1 `POST /login/lr` 路由缺失 —— 懒人听书取流 100% 不可用
**证据链**:
- `README.md:86` 与 `README.md:105` 明确承诺:懒人听书需 `POST /login/lr` 账密登录,且「取流必须登录(免费章节同样要求登录态)」
- `sdk/lr/auth.ts:12` **已完整实现** `login()`(base64 密码 + 会话 Cookie 持久化)
- 但 `sdk/lr/index.ts:53` 只写了 `export { status } from './auth.ts'` —— **没有导出 `login`**
- `src/routes-login.ts` 注册的 28 条路由中**没有任何 lr 相关条目**(逐条列举验证)
**影响**:sdk 层实现了登录能力,却没有任何 HTTP 入口。而 lr 取流强制要求登录态 → **该平台的取流功能完全无法使用**,README 的承诺是空头支票。
**修复**:`sdk/lr/index.ts` 补导出 `login`;`src/routes-login.ts` 参照 `/login/xm` 补 `POST /login/lr`(body: `{account, password}`)与 `DELETE /login/lr`;`/login/status` 加入 `lr` 字段。
---
### P0-2 咪咕解密流异常逃逸 —— 可导致整个服务进程崩溃
**证据链**:
- `src/http/migu-stream.ts:88-92`:`res.on('data', chunk => { ...; onChunk(plain, offset) })` —— `onChunk` 在 EventEmitter 回调中**同步调用**
- `src/routes.ts:317`:该回调内部有 `throw new Error('解密结果不是有效音频')`
- 外层 `src/routes.ts:311-324` 的 `try/catch` 只能捕获 Promise 链上的 reject,**捕不到** EventEmitter 回调抛出的异常
- 全项目**没有任何** `uncaughtException` / `unhandledRejection` 处理器(grep 无匹配)
**复现验证**(`tmp/repro-migu-uncaught.mjs`,已实测):
```
❌ uncaughtException 逃逸(进程会崩溃): 解密结果不是有效音频
exit code 9
```
**影响**:当咪咕返回的密流与票据 `fileKey` 不匹配(解密结果非法),或客户端中途断开导致 `reply.raw.write` 抛错时,异常逃逸 → **整个 Node 进程崩溃**,所有在线用户连接同时中断。这是一个远程可触发的单点崩溃。
**修复**:`onChunk` 内不要 `throw`,改为置错误标志 + `res.destroy()` / `reply.raw.destroy()`;或让 `pipeDecryptedMiguStream` 把 `transform`/`onChunk` 的调用包进 `try/catch` 并 `reject`。另建议补一个全局 `uncaughtException` 兜底(至少记日志后优雅退出,而非静默崩溃)。
---
### P0-3 登录凭据明文落盘且权限过宽
**证据链**:
- `sdk/auth/store.ts:88`:`writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))` —— 明文 JSON,且**未传 `{ mode: 0o600 }`**
- 实际文件:`data/auth.json` 权限 `-rw-r--r--`(0644),内容为各平台 cookie/token 明文
- 文件覆盖 wy / kg / tx / xm / me / lr / bili / qs 等全部平台凭据
**影响**:任何能读取该目录的本地用户或进程可直接窃取全部平台登录态(等价于账号取流权限)。Windows 上 POSIX 位不完全生效,风险取决于 ACL,但**明文存储**这一点在任意平台都成立。
**修复**:`writeFileSync(..., { mode: 0o600 })`(Windows 侧另设 ACL);如需更高保障再考虑加密存储(密钥来自环境变量)。
---
## 三、P1 应修
### P1-4 冒烟测试异常路径不清理子实例 → 僵尸进程累积
**证据**:
- `test/smoke-test.ts:81-110` `startLocalServer()` 超时后 `proc.kill()` 再 `reject`
- 主实例之外还有两个**额外实例**:`authServer`(:835)、`rlServer`(:887)
- `:898-899` 的 `rlServer.kill()` / `authServer.kill()` 位于顶层顺序执行,**不在 try/finally 内** —— 一旦中间抛错就不会执行
- `:911-915` 顶层只清理了 `mainServer`(`localServer`),**不涉及额外实例**
**实测后果**:当前机器上有 **6 个 MusicServer 实例在监听**:`16688`(常驻)+ `16690/16691/16692/16693/16790`(测试泄漏,连续端口说明是多轮泄漏累积)。本次审查中冒烟测试崩溃后即留下新实例。
**影响**:每次测试异常就泄漏一个监听端口的 node 进程,长期累积占用内存/端口,且会干扰后续测试判定。
**修复**:用 `try/finally` 或 `process.on('exit')` 统一 kill 所有已启动实例。
---
### P1-5 冒烟测试启动超时 10s 硬编码,且失败时无任何诊断信息
**证据**:`test/smoke-test.ts:91-94`
```js
const timer = setTimeout(() => {
proc.kill()
reject(new Error('启动本地服务器超时')) // ← 未附 stdout
}, 10000)
```
`stdout` 变量已收集子进程输出(:87-89),但 reject 时完全丢弃。
**影响**:本次审查中同一份代码**一次通过(110/111)、一次崩溃**,说明该超时在并发/负载下很脆弱 —— 属于典型的 flaky 测试。而失败信息只有一句「启动本地服务器超时」,无法判断是端口被占、TS 报错还是单纯慢。
**修复**:超时提到 30s;错误信息附带 `stdout` 尾部;区分「进程已退出」与「进程存活但未就绪」。
---
### P1-6 `/song-list/:source` 的 `?limit=` 被文档承诺却静默忽略
**证据**:
- `src/routes.ts:450`:querystring schema 声明了 `limit: pLimit`
- `src/routes.ts:436`:handler 签名为 `(source, page, sortId, tagId)` —— **没有 limit 参数**
- `src/routes.ts:458`:调用时也未传 `request.query.limit`
**影响**:OpenAPI 文档告诉客户端可以传 `?limit=`,实际被丢弃 —— API 契约与实现不符。
**修复**:要么把 limit 透传到 `songList.getList()`,要么从 schema 移除。
---
### P1-7 凭据文件写入非原子 + 解析失败静默清空
**证据**(`sdk/auth/store.ts`):
- `:84-89` `save()` 直接 `writeFileSync` 覆盖原文件,无 temp+rename。写入过程中进程被杀 → 文件损坏
- `:73-82` `load()` 用 `catch { cache = {} }` 静默吞掉 JSON 解析错误;随后任何 `setCredentials()` 都会用空数据**覆盖重写**损坏文件 → **所有平台凭据一次性丢失**,且无任何日志
- 无跨进程锁:当前环境并存 6 个实例,写操作会互相覆盖
**修复**:写临时文件 + `rename()` 原子替换;解析失败时先备份原文件再重置,并打印告警。
---
### P1-8 过期/错误注释误导维护者
**证据**:`sdk/auth/store.ts:3`
```
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
```
但:`tsconfig.json` 已是 `noEmit: true`,`package.json` **没有 `build` 脚本**,项目已全面改为「无编译产物、改完即跑」。磁盘上也确认无 `.js` 残留。
**影响**:按注释执行 `pnpm build` 会失败,且误导开发者以为存在发射产物。
**修复**:删掉该行注释。
---
### P1-9 酷狗 krc 歌词解码:Promise 永不 settle,请求挂死
**证据**:`sdk/kg/krcDecode.ts:9-10`
```js
const decodeLyric = str => new Promise((resolve, reject) => {
if (!str.length) return // ← 既未 resolve 也未 reject
```
`return` 只结束了执行器函数,Promise 永久停在 pending。
**影响**:上游返回空 krc 歌词时,调用方的 `await` 永久挂起 —— 连接与内存无法释放,客户端只能等自己超时。
**修复**:`if (!str.length) return resolve('')`
---
### P1-10 酷狗歌词重试调错方法
**证据**:`sdk/kg/lyric.ts:72` —— 在 `getLyricDownload(id, accessKey, fmt, tryNum)` 内部重试时调用的是
```js
let tryRequestObj = (this.getLyric as any)(id, accessKey, fmt, ++tryNum)
```
应为 `this.getLyricDownload(...)`。`getLyric` 与 `getLyricDownload` 参数语义不同,且 `as any` 恰好掩盖了这个错误。
**影响**:HTTP 非 200 时的重试实际走了另一个方法,重试逻辑失效(还会掩盖类型错误)。
**修复**:改为 `this.getLyricDownload(id, accessKey, fmt, ++tryNum)`;顺带移除 `as any`。
---
### P1-11 酷我歌单详情:重试参数错位(序号被当成页码)
**证据**:
- `sdk/kw/songList.ts:196`:`return this.getListDetail(id, ++tryNum)`
- `sdk/kw/songList.ts:373`:`getListDetail(id, page, retryNum = 0)` —— 第二个参数是 **page**
同一文件 `:175`、`:206` 都正确写成 `getListDetail(id, page, ++tryNum)`,唯独 `:196` 漏了 `page`。
**影响**:重试时请求的页码被替换成 `tryNum`(1/2/3…),而不是用户请求的页;同时重试计数没有真正传递。
**修复**:补上 `page`,与 `:206` 对齐。
---
### P1-12 酷我歌单详情 URL 模板多余 `}`
**证据**:`sdk/kw/songList.ts:203`
```
...&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&...
↑ 多了一个右花括号
```
**影响**:请求 URL 携带非法字符,目前依赖上游容错;上游一旦收紧解析该接口即失效。
**修复**:删掉多余的 `}`。
---
### P1-13 冒烟测试未做环境隔离:取流用例实际走外部音源,且会改写真实凭据
**证据**:
- `test/smoke-test.ts:84` 以 `env: { ...process.env, PORT, ...envExtra }` 启动子实例 —— 完整继承当前进程环境
- `.env:1` 为 `SOURCE_ENABLED=true` → 主实例以**音源模式**启动,`/url` 用例优先走 `sources/` 脚本链
- 子实例工作目录即项目根,`data/auth.json` 是**真实凭据文件**(无测试专用路径)
**影响**:① `/url` 用例并未验证 SDK 官方直连通道,SDK 侧回归可被外部音源掩盖(**测试假通过**);② `/login/refresh` 等用例会读写真实 `data/auth.json` —— 本地跑一次测试就可能覆盖真实登录态。
**修复**:测试显式传 `SOURCE_ENABLED: ''`;新增 `DATA_DIR` 环境变量让测试实例使用独立数据文件。
---
### P1-14 `pnpm-workspace.yaml` 使用非官方键 → isolated-vm 沙箱实际不可用
**证据**:
- `pnpm-workspace.yaml` 全文仅两行:`allowBuilds:` / `isolated-vm: true`
- `pnpm --version` = 12.4.1;放行构建脚本的字段应为 `onlyBuiltDependencies`,`allowBuilds` 不生效
- `node_modules/isolated-vm` 目录存在,但 `out/*.node` **不存在** → 原生模块从未构建
- 离线测试实测输出:`未安装 isolated-vm,无法启用隔离执行音源…切换下一候选脚本`
**影响**:`src/source-ivm.ts` 的「硬隔离沙箱」在部署时静默失效 —— 社区 `.js` 音源脚本全部被跳过,而 README 把它描述为可用的第一类音源。安全隔离能力名存实亡,且无显著告警。
**修复**:改为 `onlyBuiltDependencies: [isolated-vm]` 并确认构建产物就位;ivm 不可用时应在启动日志告警。
---
### P1-15 冒烟测试断言游离在 `test()` 之外,抛错会跳过清理
**证据**:`test/smoke-test.ts:855-858` 的「6 个路径均须 401」断言写成裸 `for` 循环置于顶层,而对应的 `await test(...)` 在 `:859`。任一路径返回非 401 即顶层抛错。
**影响**:抛错直接终止脚本,**跳过结果汇总与末尾的实例清理** —— 与 P1-4 同源,都会留下孤儿 node 进程。
**修复**:把断言包进 `test()`,或统一用 `try/finally` 收尾。
---
### P1-16 `.claude/settings.local.json` 已入库且含本机绝对路径
**证据**:`git ls-files .claude/` 返回 `.claude/settings.local.json`;内容含
`C:\Users\Administrator\.claude\projects\d--MusicServer\memory` 等本机路径。
**影响**:把本机目录结构与本地工具配置写进(README 声明为公开的)仓库,对他人无意义。
**修复**:`git rm --cached .claude/settings.local.json`,并把 `.claude/` 加入 `.gitignore`。
---
## 四、P2 可维护性与卫生
| 类别 | 证据 | 说明 |
| --- | --- | --- |
| 注释掉的死代码 | `sdk/wy/lyric.ts:49-51,240`、`sdk/kw/album.ts:242-246`、`sdk/kg/songList.ts:75-109,778`、`sdk/kw/index.ts:33-37` 等 100+ 处 | 大量整段注释代码(含被注释的 `console.log` 调试行),建议清理 |
| 裸 `console.log(err)` 吞错 | `sdk/wy/songList.ts:103`、`sdk/wy/leaderboard.ts:188`、`sdk/kw/lyric.ts:142`(还会打印完整歌词)、`sdk/kg/songList.ts:279`、`sdk/kg/util.ts:35`、`sdk/tx/utils/index.ts:39`、`sdk/utils.ts:23` | 吞掉异常且无上下文,排障困难 |
| 未使用符号 26 处 | `sdk/index.ts`(3)、`sdk/tx/leaderboard.ts`(2)、`sdk/lr/index.ts`(2)、`sdk/joox/parse.ts`(2)、`sdk/apis/migu.ts`(2) 等 | `tsc --noUnusedLocals` 可复现 |
| `.gitignore` 漏 `.workbuddy/` | `.gitignore` 内容 vs `git status` 显示 `?? .workbuddy/` | 工作记忆目录会被 `git add .` 收进仓库 |
| `tmp/` 占 297 MB / 4,992 文件 | 内含 1753 js / 720 ts / 339 py / 287 tsx / 122 vue | 已 gitignore,但混有大量与本项目无关的代码样本,建议清理磁盘 |
| 无 lint / 无 CI | `.eslintrc`/`.prettierrc`/`.github` 均不存在 | 建议引入 ESLint + 一条 CI 跑类型检查与离线测试 |
---
## 五、文档与代码不一致清单
| 文档位置 | 文档声称 | 代码实际 |
| --- | --- | --- |
| `README.md:86`、`:105` | `POST /login/lr` 存在 | **不存在**(P0-1) |
| `README.md:20`(qs 行) | 歌单 ✅、专辑 ✅ | `sdk/qs/index.ts` 无 `album`/`playlist` 方法,`/sources` 能力位也无 |
| `README.md:20` vs `:66` | 内部自相矛盾 | `:20` 给 qs 专辑打勾,`:66` 的专辑详情平台列表却**不含 qs** |
| `README.md:71` | parse 支持 `5sing/apple/bili/joox/qian/migu` | 实际还支持 **kw / kg / tx / wy**(`sdk/{kw,kg,tx,wy}/parse.ts` 存在,`/sources` 已暴露 `parse` 位) |
| `README.md:68` | 歌单分类仅 `migu/qian/joox/apple` | 实际 **kw / kg / tx / wy** 也有 `playlistCategories` 能力位 |
| `README.md` 登录接口表 | 只列了 POST 登录 | 实际还有 7 个 `DELETE /login/{wy,kg,tx,qs,xm,me,bili}` 退出端点未文档化 |
| `test/smoke-test.ts:642` | `/parse/song/kw` 期望 404 | 实际 200(kw parse 已实现)→ 导致冒烟测试唯一的失败项 |
| `sdk/auth/store.ts:3` | 改完需 `pnpm build` | 无 build 脚本,项目无编译产物(P1-8) |
| `README.md:24` | bili 音质 `64k/128k/192k` | `/sources` 实际为 `64k/128k/320k/flac/flac24bit` |
| `README.md:65` | `/album/:source/:id?page=1`(query 分页) | 分页是 path 段 `/album/{source}/{id}/{page}` |
| `README.md:29-30` | qt / lr 封面标「—」 | `/sources` 显示两者 `pic=true`(实际支持) |
| `README.md:72` | 个人歌单支持 kg/tx/wy/joox/bili/apple | 实际仅 **tx / wy** |
| `README.md:21-26` | migu/qian/joox/apple/5sing 登录列标「cookie」 | 均**没有** `/login/{migu,qian,joox,apple,5sing}` 路由(与 P0-1 同类问题) |
| `docs/API.md` | 与 README 同源的平台/接口描述 | 同样滞后(parse 支持平台、user-playlist 支持集等) |
---
## 六、环境与仓库卫生
- **6 个 MusicServer 实例在监听**:`16688`(常驻)、`16690`~`16693`、`16790`(测试泄漏,见 P1-4)。建议清理泄漏实例,`16688` 请确认是否为常用常驻服务后再决定。
- **未追踪且会入库的目录**:`signer/`、`tools/`、`.workbuddy/`、`scripts/qs-local-login.mjs` 以及大量新增的 `sdk/**` 模块(5sing/apple/bili/joox/migu/qian/kw·kg·tx·wy 的 parse/playlist 等)。
- `signer/server.mjs` 本身实现规范(强制 `QS_SIGNER_TOKEN`、设备号脱敏、敏感头不落盘),入库无碍;
- `tools/mssdk-sign/` 含 `.exe` 与逆向笔记(`REVERSE.md`),**是否入库请自行判断**;
- `.workbuddy/` 建议加进 `.gitignore`。
- `.gitignore` 已正确覆盖:`node_modules/`、`sources/`、`data/`、`tmp/`、`*.log`、`soda-mssdk/`、`.env`。
---
## 七、建议修复顺序
1. **P0-1**(lr 登录)—— 一行导出 + 一个路由,成本极低,收益是解锁整个平台
2. **P0-2**(migu 崩溃)—— 改 3 行错误处理 + 加全局兜底,消除远程可触发的进程崩溃
3. **P1-4 / P1-5**(测试稳定性)—— 不修则后续每次 CI/回归都可能留下僵尸进程并产生假失败
4. **P1-8 / 第五节文档**(文档同步)—— 顺手即可,避免误导
5. **P0-3 / P1-7**(凭据存储)—— 视部署环境决定优先级
6. P1-6、P2 各项按需清理
---
## 八、附:尚未逐条复核的线索
以下来自分区审查组的摘要,**本轮未逐条复核到行号**,建议作为下一轮排查入口。
**SDK 核心组**(`sdk/index.ts`、`sdk/types.ts`、`sdk/common/request.ts`、`sdk/auth/*`、`sdk/apis/*`):
- wy 首次登录时账号资料获取失败(cookie 时序问题)
- 请求取消语义失效;migu 候选链的多次探测请求不可取消
- qs 解密时把整段音频读入内存(未走流式)
- `Quality` 类型契约不一致:apple 实际返回 `preview`,但该值不在联合类型内
- 各平台 `cancelHttp` 实现不一致;部分平台把 token 拼进 GET query
**SDK 平台模块组**(本轮已核实 4 条,见 P1-9 ~ P1-12)其余线索:
- `sdk/migu/util.ts:375-378` 解密失败时把函数引用直接拼进异常消息
- `sdk/joox/` 单曲接口未校验上游 `errorCode`(错误探针外泄、响应未解析)
- `sdk/qian/util.ts:82` 附近三个导出函数名与 JSDoc 描述完全对调
- `sdk/bili/util.ts` 内约 6 处结构相同的 AJAX 重试代码
**跨文件重复逻辑**:kw / kg / tx / wy 的 `parse.ts` 与 `playlist.ts` 存在大段相似实现,可考虑抽出公共解析工具。
> 报告前三节的 P0 与 P1-4 ~ P1-12 均有明确代码位置与实际验证依据(其中 P0-2 附最小复现脚本);本节线索建议复核后再动手。第四组「测试与文档」专项审查仍在进行,其领域(测试有效性、覆盖缺口、文档一致性)本报告已通过独立核对覆盖主要结论。
+158 -44
View File
@@ -4,37 +4,86 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、咪咕(mg) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、咪咕音乐(mg)、千千音乐(bd)、JOOX(joox)、哔哩哔哩(bili)、5sing(5sing);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单 - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
## 平台能力速查
| 平台 | id | 搜索 | 取流音质 | 歌词 | 封面 | 歌单 | 专辑 | 链接解析 | 登录 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 酷我音乐 | `kw` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | — |
| 酷狗音乐 | `kg` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/短信/账密/token |
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
| 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
| 哔哩哔哩 | `bili` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 |
| 5sing | `5sing` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) |
| Apple Music | `apple` | ✅ | preview 试听片段 | ✅(需登录) | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(media-user-token) |
| 喜马拉雅 | `xm` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | cookie |
| 猫耳FM | `me` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | token |
| 蜻蜓FM | `qt` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | — |
| 懒人听书 | `lr` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | 账密 |
> 能力位以 `GET /sources` 返回为准(含 `albumSearch`/`playlist`/`parse` 等新增能力位)。
## 快速开始 ## 快速开始
```bash ```bash
pnpm install pnpm install
pnpm start # 默认端口 16688,可用 PORT 环境变量修改 pnpm start # 默认端口 16688,可用 PORT 环境变量修改(需 Node 22.18+,直接运行 .ts)
pnpm test # 运行冒烟测试(自动启动/关闭本地服务器) pnpm dev # 开发模式:node --watch 热重载,改动 src/、sdk/ 代码自动重启
pnpm test # 类型检查(tsc)+ 冒烟测试(自动启动/关闭本地服务器)
pnpm typecheck # 仅类型检查,不运行
``` ```
环境变量(`PORT`、`SOURCE_*` 等)可写入项目根目录的 `.env` 文件(已 gitignore),启动时自动加载;已存在的真实环境变量优先于 `.env` 文件值。
## HTTP 接口 ## HTTP 接口
接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。 接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) | | `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | | `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `GET /stream/:source/:id` | 加密流解密代理(`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` | 榜单列表 |
| `GET /boards/:source/:boardId` | 榜单详情 | | `GET /boards/:source/:boardId[/:page]` | 榜单详情(分页 path 段,第 1 页省略) |
| `GET /hot-search/:source` | 热搜列表 | | `GET /hot-search/:source` | 热搜列表 |
| `GET /song-list/:source?page=1` | 歌单列表 | | `GET /song-list/:source[/:page]` | 歌单列表(分页 path 段) |
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) | | `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
| `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/mg/tx) | | `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple |
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/joox/apple) |
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple) |
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple) |
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple;bili 支持合集/多分P) |
| `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5sing/apple/bili/joox/bd/mg(其中 bili、5sing 无 album 端点) |
| `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) |
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
| `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) |
| `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 |
| `POST /login/kg/password` | 酷狗账号密码登录(触发 20028 风控时返回 needVerify 挑战,配合 `/login/kg/verify` 提交图形/短信验证后重试) |
| `POST /login/kg/verify` | 提交酷狗风控验证结果(verifycode 为图形验证凭证或短信码) |
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
| `POST /login/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
| `DELETE /login/{wy,kg,tx,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
| `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) | | `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) |
@@ -46,44 +95,109 @@ pnpm test # 运行冒烟测试(自动启动/关闭本地服务器)
## 歌曲 ID 约定 ## 歌曲 ID 约定
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
- **kg**:`hash`(搜索结果的 `hash` 字段;当前环境 kg 取流接口被服务端限制,URL 获取可能失败) - **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
- **mg**:`songmid`(搜索结果的 `songmid` 字段,内部自动换取 contentId) - **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
- **mg**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/mg` 解密代理
- **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
- **joox**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
- **bili**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
- **5sing**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C`
- **apple**:数字歌曲 id(搜索结果产出);取流固定为 `preview` 试听片段(完整曲为 DRM,不在支持范围)
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
## 调用鉴权与限流
### 调用秘钥(AUTH_KEY)
设置环境变量 `AUTH_KEY` 后,**全部路径**(含文档页、健康检查、不存在的路径)都要求调用秘钥——未授权请求统一返回 401,不暴露 404/401 差异,防止接口被扫描探测。未设置则不鉴权。
```
X-Server-Key: <AUTH_KEY> # 统一验证头(全部路径)
?exp=&sig= # 仅 /stream/* 流代理:短时签名(/url 返回的流地址自动附带)
```
流代理场景(播放器/下载器直接 GET、无法带自定义头)用**短时签名**而非裸秘钥:`/url` 返回的流地址自动附带 `?exp=&sig=`(HMAC 派生,10 分钟有效)——签名泄露仅能在时效窗口内拉当条流,无法反推秘钥或调用其它接口;能带头的客户端仍可对 /stream/* 使用 X-Server-Key。
### 限流(RATE_LIMIT)
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。
## 汽水音乐(已拆分)
汽水音乐(`qs`)已拆分为独立服务端 **SodaServer**(`gitea.4i.hk/hhhge/SodaServer`,本地与 MusicServer 同级):API 路径兼容(`/search/qs`、`/url/qs`、`/stream/qs`、`/login/qs/*`),凭据文件格式同源可互迁;PC 签名继续复用 qs-signer 部署。
## 外部音源
设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。
**简单 API 脚本(`.mjs`)** —— 进程内直接加载、无沙箱的第二套极简契约,只需导出一个 `getMusicUrl`,适合自己写的取流脚本(运行于服务器进程内,请只放自己审计过的代码):
```js
// sources/99-my-api.mjs —— dev 热重载模式下保存即自动重启生效
export const sources = ['kw', 'kg', 'tx', 'wy'] // 可选,默认四平台
export const timeout = 15000 // 可选,单次取流超时 ms(默认 15000)
export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
// source: 平台 id(kw/kg/tx/wy);id: 歌曲 id(songmid/hash)
// quality: 128k/320k/flac/flac24bit
// signal: 取消/超时信号(AbortSignal),fetch(url, { signal }) 透传即可支持取消
const res = await fetch(`https://your-api/get?source=${source}&id=${id}&quality=${quality}`, { signal })
const { data } = await res.json()
return data.url // 播放地址字符串,或 { type, url }
}
```
## 项目结构 ## 项目结构
``` ```
src/ src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码)
server.js HTTP 服务器入口(pnpm start 实际启动文件,Fastify) server.ts HTTP 服务器入口(pnpm start 实际启动文件)
routes.js 业务路由(JSON Schema 校验 + OpenAPI 元数据) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
smoke-test.js 冒烟测试(自动启动本地服务器) routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、
index.js SDK 根模块(工具函数聚合导出) bili 扫码、xm/me cookie 绑定、lr 账密)
http/ HTTP 层 source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
api-error.js 业务错误类型(404/502 等 → 统一 JSON 响应) source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
openapi/ 文档层 source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载)
components.js OpenAPI 共享 schema(与路由校验同源) http/
ui.js ReDoc 页面模板 api-error.ts 业务错误类型(404/502 等 → 统一 JSON 响应)
apis/ 各平台 URL 直连实现(kw/kg/tx/wy/mg/bd) auth-gate.ts 调用秘钥鉴权(AUTH_KEY,全路径防扫描;/stream/* 可改用短时签名)
source.js 外部音源懒加载入口(sources/ 目录脚本,不入库) rate-limit.ts per-IP 滑动窗口限流(RATE_LIMIT,默认 120/分钟)
source-ivm.js isolated-vm 隔离执行器 stream-sign.ts /stream/* 流地址短时签名(HMAC 派生,10 分钟有效)
common/ 公共模块 migu-stream.ts 咪咕加密流票据登记与逐块解密管道
request.js HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) openapi/
utils.js 工具函数(sizeFormate/formatPlayTime/dateFormat 等) components.ts OpenAPI 共享 schema(与路由校验同源)
message.js 请求错误消息 ui.ts ReDoc 页面模板
ipcNames.js IPC 事件名(兼容 SDK) sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;14 个平台)
rendererIpc.js IPC 本地实现(kw/tx 歌词解码) index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
lyricDecode.js 酷我/QQ 歌词解码(替代原 Electron 主进程处理器) apis/ 各平台官方 URL 直连取流实现(14 平台)
qrcDecode.js QQ 歌词 3DES 解密(纯 JS 移植) auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token/续期、tx.ts 扫码/cookie/续期)
lyricUtils/kg.js 酷狗 krc 歌词解码 common/ SDK 基础设施
musicSdk/ 音乐聚合 SDK(搜索/歌词/榜单等核心逻辑,TypeScript 源码) request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
index.js 平台聚合入口 utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
request.js SDK 请求模块(转接 src/common/request.js) message.ts 请求错误消息
message.js SDK 消息模块(转接 src/common/message.js) kg/ kw/ tx/ wy/ migu/ qian/ joox/ bili/ 5sing/ apple/ xm/ me/ qt/ lr/
平台模块(搜索/歌词/榜单/歌单/专辑/链接解析等;migu 含 Z3D 流解密,bili 含扫码登录)
apis/ kw.ts kg.ts tx.ts wy.ts migu.ts qian.ts joox.ts bili.ts 5sing.ts apple.ts …
test/
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
``` ```
> TypeScript 源码(`src/apis/index.ts`、`musicSdk/**/*.ts`)经 `pnpm run build`(tsc)就地发射为 `.js` 产物后运行。 > 分层依赖严格单向:`src` → `sdk`,`sdk` 内部自包含(不反向依赖 src)。源码统一为 TypeScript(import 一律写 `.ts` 后缀),由 Node ≥22.18 的原生类型剥离直接运行——**无编译产物**,改完即跑;tsc 仅用于类型检查(`pnpm typecheck`)。代码只使用可剥离的 TS 语法(无 enum/namespace/参数属性),新增代码也须遵守。
## 已知限制 ## 已知限制
- **kg 取流**:酷狗当前环境下 `wwwapi`/`trackercdn`/`m.kugou.com` 等取流接口均被服务端风控(err_code 30020/20010),播放地址获取可能失败;歌词、搜索、榜单正常。 - **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
- **kw flac**:`antiserver` 接口对 flac 返回 `refuse`,高音质会回退官方接口(需要 csrf token,token 失效时自动重试)。 - **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
- 平台接口随时可能变更,遇失效时可对照 `src/apis/` 内的接口文档调整。 - **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
+97 -44
View File
@@ -1,26 +1,47 @@
# MusicServer 聚合音乐 API # MusicServer 聚合音乐 API
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)/ **mg**(咪咕)。 多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
> 本文档为静态速查版。交互式文档以服务器实际输出为准: 共 14 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `mg`(咪咕)/
`bd`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
> **本文档是人工速查版,可能滞后于代码。**
> 接口与参数以服务器自动生成的规范为准(路由 schema 同源,永远与实现同步):
> >
> - **Swagger UI**:`http://localhost:16688/docs`(可直接调试) > - **Swagger UI**:`/docs`(可直接调试)
> - **ReDoc**:`http://localhost:16688/redoc` > - **ReDoc**:`/redoc`
> - **OpenAPI 3.1**:`http://localhost:16688/openapi.json`(可生成各语言客户端) > - **OpenAPI 3.1**:`/openapi.json`(可生成各语言客户端)
> - **平台能力位**:`GET /sources` —— 各接口具体支持哪些平台,一律以它的能力位为准,本文不再硬列
## 约定 ## 约定
- 默认端口 **16688**(可用 `PORT` 环境变量修改)。 - 默认端口 **16688**(`PORT` 环境变量可改)。
- 仅支持 `GET` 方法;服务器支持 CORS(`Access-Control-Allow-Origin: *`)。 - **全部接口为路径参数风格**,分页走 path 段且第 1 页可省略:`/search/kw/稻香`、`/search/kw/稻香/2`、
`/album/kg/123/2`。查询参数只用于修饰(`?quality=`、`?limit=`、`?singer=` 等)。
- 默认返回 `Access-Control-Allow-Origin: *`,可直接在浏览器/前端调用。
- **成功**:`200` + `{"code": 0, "data": ...}` - **成功**:`200` + `{"code": 0, "data": ...}`
- **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}` - **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}`
- `422` 参数校验失败(缺失、非法音质、页码越界等,FastAPI 风格)
- `404` 接口不存在 / 平台不支持该能力 | 状态码 | 含义 |
- `405` 方法不允许(响应带 `Allow` 头) | --- | --- |
- `502` 上游平台请求失败 | `422` | 参数校验失败(Fastify/Ajv,FastAPI 风格) |
- `500` 服务器内部错误 | `404` | 接口不存在,或平台不支持该能力 |
- 资源类接口统一为**路径参数风格**;`/search`、`/find` 搜索类接口使用 query 参数。 | `405` | 方法不允许(响应带 `Allow` 头) |
- 平台歌曲 id 形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`(mg 内部自动换取 contentId)。 | `502` | 上游平台请求失败 |
| `500` | 服务器内部错误 |
### 鉴权与限流
- 设置 `AUTH_KEY` 后**全部路径**都要求 `X-Server-Key: <AUTH_KEY>`(含文档页与 404,防止扫描探测接口存在性)。
- `/stream/*` 流代理例外:播放器无法携带自定义头,改用 `/url` 返回流地址时自动附带的 `?exp=&sig=` 短时签名(HMAC 派生,10 分钟有效)。
- `RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭),超限返回 `429` + `Retry-After`。
### 歌曲 id 形态
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`xm`/`me` 的 id、`mg` 的三元组、
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
## 接口速查 ## 接口速查
@@ -29,51 +50,86 @@
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/health` | 健康检查(status / uptime / 平台数) | | GET | `/health` | 健康检查(status / uptime / 平台数) |
| GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList/singer/album) | | GET | `/sources` | 平台列表:音质与全部能力位(**判断某接口支持哪些平台的唯一依据**) |
| GET | `/` | 302 重定向到 `/docs` | | GET | `/` | 302 重定向到 `/docs` |
| GET | `/docs` | Swagger UI 文档页 | | GET | `/docs` · `/redoc` · `/openapi.json` | Swagger UI / ReDoc / OpenAPI 3.1 规范 |
| GET | `/redoc` | ReDoc 阅读版文档 |
| GET | `/openapi.json` | OpenAPI 3.1 规范文档 |
### 搜索 ### 搜索
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台,失败的平台跳过 | | GET | `/search/:source/:keyword[/:page]` | 搜索歌曲;`source` 为平台 id 或 `all`(聚合,自带 `noAggregate` 的平台不参与)。`?singer=` 附加歌手、`?limit=` 每页条数 |
| GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 | | GET | `/find/:source/:keyword` | 精确匹配搜索;`?singer=` `?albumName=` `?interval=` 辅助匹配,`source=all` 时 `?excludeSource=` 跳过平台 |
### 歌曲资源 ### 歌曲资源
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/url/:source/:id` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) | | GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
| GET | `/lyric/:source/:id` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) | | GET | `/stream/:source/:id` | 加密流解密代理(`mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
| GET | `/pic/:source/:id` | 封面;可带 `albumId`(kg 需要) | | GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
### 榜单 ### 榜单 / 歌单 / 热搜
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/boards/:source` | 榜单列表 | | GET | `/boards/:source` | 榜单列表 |
| GET | `/boards/:source/:boardId` | 榜单详情;可带 `page`。`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`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 | | GET | `/song-list/:source[/:page]` | 歌单列表;`?sortId=` `?tagId=` 各平台词汇不同,省略时用平台默认 |
| GET | `/playlist/:source/:id[/:page]` | 歌单详情与曲目;`?limit=`。bili 支持合集/多分P |
| GET | `/playlist/:source/search/:keyword` | 歌单搜索(`?page=` `?limit=`) |
| GET | `/playlist/:source/categories` | 歌单分类列表 |
| GET | `/playlist/:source/categories/:categoryId` | 分类下的歌单(`?page=` `?limit=`) |
| GET | `/user-playlist/:source` | 个人歌单(需登录;仅 tx/wy 有完整实现,其余 404/502) |
### 歌手 & 专辑 ### 歌手 & 专辑
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/singer/:source/:id` | 歌手信息(name/desc/avatar 与歌曲/专辑数量)。支持 kg/tx/wy | | GET | `/singer/:source/:id` | 歌手信息(name/desc/avatar 与歌曲/专辑数量) |
| GET | `/singer/:source/:id/songs` | 歌手歌曲列表;可带 `page`/`limit`(默认 20)。支持 kg/tx/wy | | GET | `/singer/:source/:id/songs[/:page]` | 歌手歌曲列表;`?limit=` |
| GET | `/singer/:source/:id/albums` | 歌手专辑列表;可带 `page`/`limit`。支持 kg/tx/wy | | GET | `/singer/:source/:id/albums[/:page]` | 歌手专辑列表;`?limit=` |
| GET | `/album/:source/:id` | 专辑详情与曲目列表;可带 `page`/`limit`(limit 仅 kg 生效,kw 单页固定 1000 条)。支持 kg/kw/mg/tx;tx 的专辑名/作者由曲目数据合成(desc 为空) | | GET | `/album/:source/:id[/:page]` | 专辑详情与曲目;`?limit=` |
| GET | `/album/:source/search/:keyword` | 专辑搜索(`?page=` `?limit=`) |
歌手/专辑 id 来源:搜索结果条目包含 `albumId`;歌手 id 需从平台方获取(不在搜索结果中)。 ### 链接解析
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/parse/song/:source` | 解析歌曲网页链接 → 歌曲信息,body `{"link": "..."}` |
| POST | `/parse/album/:source` | 解析专辑链接 → 专辑信息与曲目 |
| POST | `/parse/playlist/:source` | 解析歌单链接 → 歌单信息与曲目 |
支持的平台见 `/sources` 的 `parse` 能力位;不支持的平台返回 404。
### 登录
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/login/wy/qr` → `/login/wy/qr/check` | 网易云扫码(`check` body 传 `{"key": "..."}`) |
| POST | `/login/wy/cellphone` | 网易云手机号登录(`password` 明文或 `captcha`) |
| DELETE | `/login/wy` | 退出网易云 |
| POST | `/login/kg/qr` → `/login/kg/qr/check` | 酷狗扫码 |
| POST | `/login/kg/captcha` → `/login/kg/cellphone` | 酷狗短信验证码登录 |
| POST | `/login/kg/password` | 酷狗账密登录(命中风控返回挑战,配合 `/login/kg/verify` 重试) |
| POST | `/login/kg` | 绑定酷狗 token(App 抓包) |
| DELETE | `/login/kg` | 退出酷狗 |
| POST | `/login/tx/qr` → `/login/tx/qr/check` | QQ 音乐扫码 |
| POST | `/login/tx` | 绑定 QQ 音乐 cookie(可选 App 抓包字段供自动续期) |
| DELETE | `/login/tx` | 退出 QQ 音乐 |
| POST | `/login/xm` · `/login/me` | 绑定喜马拉雅 cookie / 猫耳FM token |
| DELETE | `/login/xm` · `/login/me` | 退出对应平台 |
| POST | `/login/lr` | 懒人听书账号登录(`account` + `password`) |
| DELETE | `/login/lr` | 退出懒人听书 |
| POST | `/login/bili/qr` → `/login/bili/qr/check` | 哔哩哔哩扫码(解锁大会员音质) |
| DELETE | `/login/bili` | 退出哔哩哔哩 |
| POST | `/login/refresh` | 手动触发登录态续期(kg/tx) |
| GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) |
- 凭据持久化于 `data/auth.json`(不入库,文件权限 0600),登录后取流与平台请求自动携带。
- 自动续期:启动 10s 后对 kg/tx 执行一次,此后每 24h 一次(音源模式下不参与取流,自动续期不启动)。
## 响应示例 ## 响应示例
@@ -84,15 +140,12 @@
// GET /lyric/kw/228908 // GET /lyric/kw/228908
{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "yrc": "" } } { "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "yrc": "" } }
// 参数缺失 // 参数非法 → 422
// 422 { "code": 422, "msg": "params/source must be equal to one of the allowed values", "detail": [...] }
{ "code": 422, "msg": "querystring must have required property 'name'", "detail": [...] }
// 平台不支持该能力 // 平台不支持该能力 → 404
// 404
{ "code": 404, "msg": "不支持的平台: xx" } { "code": 404, "msg": "不支持的平台: xx" }
// 方法不允许 // 方法不允许 → 405 + Allow 头
// 405 + Allow: GET { "code": 405, "msg": "方法 POST 不允许,请使用 GET, HEAD" }
{ "code": 405, "msg": "方法 POST 不允许,请使用 GET" }
``` ```
+2 -2
View File
@@ -3,10 +3,10 @@
## 架构 ## 架构
``` ```
调用方 ── source.kw.getMusicUrl(songInfo, type) ──▶ src/apis/source.js(懒加载单例) 调用方 ── source.kw.getMusicUrl(songInfo, type) ──▶ src/source.ts(懒加载候选链)
│ │
▼ ▼
src/apis/source-ivm.js src/source-ivm.ts
isolated-vm 硬隔离沙箱 isolated-vm 硬隔离沙箱
┌──────────────────────────┐ ┌──────────────────────────┐
│ 音源脚本(升版即换文件) │ │ 音源脚本(升版即换文件) │
+102
View File
@@ -0,0 +1,102 @@
# 外部音源 —— 简单 API 脚本(.mjs)
社区音源脚本之外的第二种脚本接入:脚本就是普通 Node ES 模块,**只需导出一个 `getMusicUrl` 函数**,无需 preload API 契约(`lx.on` / `lx.request` / crypto 桥接…)。适合自己写的取流脚本。
## 两种脚本对比
| | 社区音源脚本(`.js`) | 简单 API 脚本(`.mjs`) |
|---|---|---|
| 加载器 | `source-ivm.ts`,isolated-vm 硬隔离沙箱 | `source-simple.ts`,进程内直接 `import` |
| 脚本契约 | `lx.on('request')` + `lx.send('inited')` 全套 preload API | 一个 `getMusicUrl` 函数 |
| 能力 | 沙箱内桥接的 http/crypto/zlib | Node 全部能力(fetch / import 任意包) |
| 适用 | 来源不明的第三方混淆脚本 | 自己写、自己审计过的脚本 |
两种脚本都放 `sources/` 目录(不入库),按**文件名混排**成候选链(顺序即优先级),失败自动切换下一个。
## 脚本契约
```js
// sources/99-my-api.mjs
export const sources = ['kw', 'kg', 'tx', 'wy'] // 可选,默认 kw/kg/tx/wy 四平台
export const timeout = 15000 // 可选,单次取流超时 ms(默认 15000)
export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
// ...你的取流逻辑
return 'https://…' // 播放地址字符串,或 { type, url }
}
```
`getMusicUrl` 参数:
| 参数 | 说明 |
|---|---|
| `source` | 平台 id:`kw` / `kg` / `tx` / `wy`(或脚本自己声明的) |
| `id` | 歌曲 id(已归一:`songmid` / `hash` / `copyrightId` 取第一个非空值) |
| `songInfo` | 原始歌曲信息对象(含 songmid/hash 等原始字段) |
| `quality` | 音质:`128k` / `320k` / `flac` / `flac24bit` |
| `signal` | 取消/超时信号(`AbortSignal`)——调用方取消或超时都会触发;`fetch(url, { signal })` 透传即可支持取消 |
返回值:
- **字符串**:播放地址(最简单)
- **`{ type, url }`**:对象(type 为实际音质,用于音质回退标注)
抛错即本次取流失败,由候选链切换下一候选,不影响服务器。
## 示例
### 最小示例
```js
// sources/99-demo.mjs
export async function getMusicUrl({ source, id, quality }) {
return `https://your-api.example/get?source=${source}&id=${id}&quality=${quality}`
}
```
### 调用 JSON API 并透传取消信号
```js
// sources/98-my-api.mjs
const API = 'https://your-api.example'
export async function getMusicUrl({ source, id, quality, signal }) {
const res = await fetch(`${API}/url?source=${source}&id=${id}&quality=${quality}`, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { code, msg, data } = await res.json()
if (code !== 0) throw new Error(msg || `code=${code}`)
return data.url
}
```
### 按平台区分逻辑
```js
// sources/97-multi.mjs
export const sources = ['kw', 'kg'] // 只接入 kw/kg,其余平台走候选链中的其它脚本
export async function getMusicUrl({ source, id, quality }) {
if (source === 'kw') return kwLogic(id, quality)
return kgLogic(id, quality)
}
```
## 候选链行为
- 顺序 = 文件名排序(`.js` 与 `.mjs` 混排),如 `01-api.js` 优先于 `99-my-api.mjs`
- 某脚本的平台连续失败 **3 次** → 该脚本在该平台降权冷却 **5 分钟**(沉到候选末尾,恢复后自动探测)
- 单脚本无处可切时不做降权,行为与直连失败一致(路由 502)
- `SOURCE_SCRIPT_PATH` 指定单个社区脚本(`.js`)时跳过目录扫描,但 `.mjs` 仍参与候选链
## 启用方式与调试
- 环境变量 `SOURCE_ENABLED=1`(任意非 `0`/`false` 值)启用外部音源;不设置则走内置直连。音源模式下登录态不参与取流,登录自动续期不会启动
- `pnpm dev` 热重载已监听 `sources/` 目录,改脚本保存即自动重启生效
- 日志前缀 `[source-simple]`:加载(平台列表)、每次取流(▶ 开始 / ✔ 完成 / ✖ 失败,含耗时)
- 离线测试:`node test/source-simple-test.ts`(已并入 `pnpm test`)
## 边界
- **无沙箱**:脚本运行在服务器进程内,可访问 fs/process 等——只放自己审计过的代码;不可信脚本用 `.js` 走 [isolated-vm 加载器](source-ivm.md)
- 仅接入**取流**(getMusicUrl);歌词、封面、榜单等能力始终走内置实现
- 脚本内部自起的长任务(如忽略 signal 的死循环请求)由超时兜底放弃等待,脚本侧仍可能继续在后台运行
-40
View File
@@ -1,40 +0,0 @@
// 接口源声明:id 与 api-source.ts 中 allApi 的 `<id>_<source>` 键对应
// Support qualitys: 128k 320k flac wav
interface ApiSourceInfo {
id: string
name: string
disabled: boolean
supportQualitys: Partial<Record<'kw' | 'kg' | 'tx' | 'wy' | 'mg' | 'bd', string[]>>
}
const sources: ApiSourceInfo[] = [
// {
// id: 'test',
// name: '测试接口',
// disabled: false,
// supportQualitys: {
// kw: ['128k'],
// kg: ['128k'],
// tx: ['128k'],
// wy: ['128k'],
// mg: ['128k'],
// // bd: ['128k'],
// },
// },
{
id: 'source',
name: '外部聚合音源',
disabled: false,
supportQualitys: {
kw: ['128k', '320k', 'flac'],
kg: ['128k', '320k', 'flac'],
tx: ['128k', '320k', 'flac'],
wy: ['128k', '320k', 'flac'],
mg: ['128k', '320k', 'flac'],
// bd: ['128k', '320k', 'flac'],
},
},
]
export default sources
-45
View File
@@ -1,45 +0,0 @@
// 接口选择层:按 apiSource 配置把请求路由到外部聚合音源或服务器内置直连接口
import apiSourceInfo from './api-source-info.ts'
import { apiSource, userApi } from '../src/renderer/store.js'
import internalApi from '../src/apis/index.js'
import { createApi as createSourceApi } from '../src/apis/source.js'
interface SourceApi {
getMusicUrl(songInfo: any, type: string): { promise: Promise<any>; cancelHttp(): void }
[key: string]: any
}
const allApi: Record<string, SourceApi> = {
source_kw: createSourceApi('kw'),
source_kg: createSourceApi('kg'),
source_tx: createSourceApi('tx'),
source_wy: createSourceApi('wy'),
source_mg: createSourceApi('mg'),
}
const apiList: Record<string, SourceApi | undefined> = {}
const supportQuality: Record<string, any> = {}
for (const api of apiSourceInfo) {
supportQuality[api.id] = api.supportQualitys
for (const source of Object.keys(api.supportQualitys)) {
apiList[`${api.id}_api_${source}`] = allApi[`${api.id}_${source}`]
}
}
const getAPI = (source: string): SourceApi | undefined => apiList[`${apiSource.value}_api_${source}`]
export const apis = (source: string): SourceApi => {
if (/^user_api/.test(apiSource.value)) return userApi.apis[source]
// 服务器内置直连接口
if (apiSource.value == 'internal') {
const api = internalApi[source]
if (api) return api
throw new Error('Api is not found')
}
const api = getAPI(source)
if (api) return api
throw new Error('Api is not found')
}
export { supportQuality }
-26
View File
@@ -1,26 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_test = {
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/url/bd/${songInfo.songmid}/${type}`, {
method: 'get',
timeout,
headers,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(requestMsg.fail))
}
})
return requestObj
},
}
export default api_test
-24
View File
@@ -1,24 +0,0 @@
import { httpFetch } from '../request.ts'
import type { HttpRequestObject } from '../types.ts'
export default {
_requestObj: null as HttpRequestObject | null,
async getList(retryNum = 0) {
if (this._requestObj) this._requestObj.cancelHttp()
if (retryNum > 2) return Promise.reject(new Error('try max num'))
const _requestObj = httpFetch('http://musicapi.qianqian.com/v1/restserver/ting?from=android&version=7.0.2.0&channel=ppzs&operator=0&method=baidu.ting.search.hot', {
method: 'get',
headers: {
'User-Agent': 'android_7.0.2.0;baiduyinyue',
},
})
const { body, statusCode } = await _requestObj.promise
if (statusCode != 200 || body.error_code !== 22000) throw new Error('获取热搜词失败')
// console.log(body, statusCode)
return { source: 'bd', list: this.filterList(body.result) }
},
filterList(rawList) {
return rawList.map(item => item.word)
},
}
-40
View File
@@ -1,40 +0,0 @@
import leaderboard from './leaderboard.ts'
import { apis } from '../api-source.ts'
import musicInfo from './musicInfo.ts'
import songList from './songList.ts'
import { httpFetch } from '../request.ts'
import musicSearch from './musicSearch.ts'
import hotSearch from './hotSearch.ts'
const bd = {
leaderboard,
songList,
musicSearch,
hotSearch,
getMusicUrl(songInfo, type) {
return apis('bd').getMusicUrl!(songInfo, type)
},
getPic(songInfo) {
const requestObj = this.getMusicInfo(songInfo)
return requestObj.promise.then(info => info.pic_premium)
},
getLyric(songInfo) {
const requestObj = this.getMusicInfo(songInfo)
requestObj.promise = requestObj.promise.then(info => httpFetch(info.lrclink).promise.then(resp => ({ lyric: resp.body, tlyric: '' })))
return requestObj
},
// getLyric(songInfo) {
// return apis('bd').getLyric(songInfo)
// },
// getPic(songInfo) {
// return apis('bd').getPic(songInfo)
// },
getMusicInfo(songInfo) {
return musicInfo.getMusicInfo(songInfo.songmid)
},
getMusicDetailPageUrl(songInfo) {
return `http://music.taihe.com/song/${songInfo.songmid}`
},
}
export default bd
-153
View File
@@ -1,153 +0,0 @@
import { httpFetch } from '../request.ts'
// import { formatPlayTime } from '../index.js'
const boardList = [
// { id: 'bd__601', name: '歌单榜', bangid: '601' },
{ id: 'bd__2', name: '热歌榜', bangid: '2' },
{ id: 'bd__20', name: '华语金曲榜', bangid: '20' },
{ id: 'bd__25', name: '网络歌曲榜', bangid: '25' },
{ id: 'bd__1', name: '新歌榜', bangid: '1' },
{ id: 'bd__21', name: '欧美金曲榜', bangid: '21' },
{ id: 'bd__200', name: '原创音乐榜', bangid: '200' },
{ id: 'bd__22', name: '经典老歌榜', bangid: '22' },
{ id: 'bd__24', name: '影视金曲榜', bangid: '24' },
{ id: 'bd__23', name: '情歌对唱榜', bangid: '23' },
{ id: 'bd__11', name: '摇滚榜', bangid: '11' },
{ id: 'bd__105', name: '好童星榜', bangid: '105' },
{ id: 'bd__106', name: '雅克•藏羌彝原创音乐榜', bangid: '106' },
]
export default {
limit: 20,
list: [
{
id: 'bdrgb',
name: '热歌榜',
bangid: '2',
},
{
id: 'bdxgb',
name: '新歌榜',
bangid: '1',
},
{
id: 'bdycb',
name: '原创榜',
bangid: '200',
},
{
id: 'bdhyjqb',
name: '华语榜',
bangid: '20',
},
{
id: 'bdomjqb',
name: '欧美榜',
bangid: '21',
},
{
id: 'bdwugqb',
name: '网络榜',
bangid: '25',
},
{
id: 'bdjdlgb',
name: '老歌榜',
bangid: '22',
},
{
id: 'bdysjqb',
name: '影视金曲榜',
bangid: '24',
},
{
id: 'bdqgdcb',
name: '情歌对唱榜',
bangid: '23',
},
{
id: 'bdygb',
name: '摇滚榜',
bangid: '11',
},
],
getUrl(id, p) {
return `http://musicmini.qianqian.com/2018/static/bangdan/bangdanList_${id}_${p}.html`
},
regExps: {
item: /data-song="({.+?})"/g,
info: /{total[\s:]+"(\d+)", size[\s:]+"(\d+)", page[\s:]+"(\d+)"}/,
},
getData(url) {
const requestObj = httpFetch(url)
return requestObj.promise
},
filterData(rawList) {
// console.log(rawList)
return rawList.map(item => {
const types: any[] = []
const _types: Record<string, any> = {}
let size = null
types.push({ type: '128k', size })
_types['128k'] = {
size,
}
if (item.biaoshi) {
types.push({ type: '320k', size })
_types['320k'] = {
size,
}
types.push({ type: 'flac', size })
_types.flac = {
size,
}
}
// types.reverse()
return {
singer: item.song_artist.replace(',', '、'),
name: item.song_title,
albumName: item.album_title,
albumId: item.album_id,
source: 'bd',
interval: '',
songmid: item.song_id,
img: null,
lrc: null,
types,
_types,
typeUrl: {},
}
})
},
parseData(rawData) {
// return rawData.map(item => JSON.parse(item.replace(this.regExps.item, '$1').replace(/&quot;/g, '"').replace(/\\\//g, '/').replace(/(@s_1,w_)\d+(,h_)\d+/, '$1500$2500')))
return rawData.map(item => JSON.parse(item.replace(this.regExps.item, '$1').replace(/&quot;/g, '"').replace(/\\\//g, '/')))
},
async getBoards(retryNum = 0) {
this.list = boardList
return {
list: boardList,
source: 'bd',
}
},
getList(bangid, page, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
return this.getData(this.getUrl(bangid, page)).then(({ body }) => {
let result = body.match(this.regExps.item)
if (!result) return this.getList(bangid, page, retryNum)
let info = body.match(this.regExps.info)
if (!info) return this.getList(bangid, page, retryNum)
const list = this.filterData(this.parseData(result))
this.limit = parseInt(info[2])
return {
total: parseInt(info[1]),
list,
limit: this.limit,
page: parseInt(info[3]),
source: 'bd',
}
})
},
}
-20
View File
@@ -1,20 +0,0 @@
import { httpFetch } from '../request.ts'
export default {
cache: {},
getMusicInfo(songmid) {
if (this.cache[songmid]) {
return { promise: Promise.resolve(this.cache[songmid]) }
}
const requestObj = httpFetch(`https://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getSongLink&format=json&from=bmpc&version=1.0.0&version_d=11.1.6.0&songid=${songmid}&type=1&res=1&s_protocol=1&aac=2&project=tpass`)
requestObj.promise = requestObj.promise.then(({ body }) => {
// console.log(body)
if (body.error_code == 22000) {
this.cache[songmid] = body.result.songinfo
return body.result.songinfo
}
return Promise.reject(new Error('获取音乐信息失败'))
})
return requestObj
},
}
-88
View File
@@ -1,88 +0,0 @@
// import '../../polyfill/array.find.js'
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts'
// import { debug } from '../../utils/env.js'
// import { formatSinger } from './util.js'
export default {
limit: 30,
total: 0,
page: 0,
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = httpFetch(`http://tingapi.ting.baidu.com/v1/restserver/ting?from=android&version=5.6.5.6&method=baidu.ting.search.merge&format=json&query=${encodeURIComponent(str)}&page_no=${page}&page_size=${limit}&type=0&data_source=0&use_cluster=1`)
return searchRequest.promise.then(({ body }) => body)
},
handleResult(rawData) {
let ids = new Set()
const list: any[] = []
if (!rawData) return list
rawData.forEach(item => {
if (ids.has(item.song_id)) return
ids.add(item.song_id)
const types: any[] = []
const _types: Record<string, any> = {}
let size = null
let itemTypes = item.all_rate.split(',')
if (itemTypes.includes('128')) {
types.push({ type: '128k', size })
_types['128k'] = {
size,
}
}
if (itemTypes.includes('320')) {
types.push({ type: '320k', size })
_types['320k'] = {
size,
}
}
if (itemTypes.includes('flac')) {
types.push({ type: 'flac', size })
_types.flac = {
size,
}
}
// types.reverse()
list.push({
singer: item.author.replace(',', '、'),
name: item.title,
albumName: item.album_title,
albumId: item.album_id,
source: 'bd',
interval: formatPlayTime(parseInt(item.file_duration)),
songmid: item.song_id,
img: null,
lrc: null,
types,
_types,
typeUrl: {},
})
})
return list
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
return this.musicSearch(str, page, limit).then(result => {
if (!result || result.error_code !== 22000) return this.search(str, page, limit, retryNum)
let list = this.handleResult(result.result.song_info.song_list)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = result.result.song_info.total
this.page = page
this.allPage = Math.ceil(this.total / limit)
return Promise.resolve({
list,
allPage: this.allPage,
limit,
total: this.total,
source: 'bd',
})
})
},
}
-264
View File
@@ -1,264 +0,0 @@
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts'
import { toMD5 } from '../utils.ts'
import CryptoJS from 'crypto-js'
import type { HttpRequestObject } from '../types.ts'
export default {
_requestObj_tags: null as HttpRequestObject | null,
_requestObj_list: null as HttpRequestObject | null,
_requestObj_listRecommend: null as HttpRequestObject | null,
limit_list: 30,
limit_song: 10000,
successCode: 22000,
sortList: [
{
name: '最热',
id: '1',
},
{
name: '最新',
id: '0',
},
],
regExps: {
// http://music.taihe.com/songlist/566347741
listDetailLink: /^.+\/songlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
aesPassEncod(jsonData) {
let timestamp = Math.floor(Date.now() / 1000)
let privateKey = toMD5('baidu_taihe_music_secret_key' + timestamp).substr(8, 16)
let key = CryptoJS.enc.Utf8.parse(privateKey)
let iv = CryptoJS.enc.Utf8.parse(privateKey)
let arrData: string[] = []
let strData = ''
for (let key in jsonData) arrData.push(key)
arrData.sort()
for (let i = 0; i < arrData.length; i++) {
let key = arrData[i]
strData +=
(i === 0 ? '' : '&') + key + '=' + encodeURIComponent(jsonData[key])
}
let JsonFormatter = {
stringify(cipherParams) {
let jsonObj: Record<string, any> = {
ct: cipherParams.ciphertext.toString(CryptoJS.enc.Base64),
}
if (cipherParams.iv) {
jsonObj.iv = cipherParams.iv.toString()
}
if (cipherParams.salt) {
jsonObj.s = cipherParams.salt.toString()
}
return jsonObj
},
parse(jsonStr) {
let jsonObj = JSON.parse(jsonStr)
let cipherParams = CryptoJS.lib.CipherParams.create({
ciphertext: CryptoJS.enc.Base64.parse(jsonObj.ct),
})
if (jsonObj.iv) {
cipherParams.iv = CryptoJS.enc.Hex.parse(jsonObj.iv)
}
if (jsonObj.s) {
cipherParams.salt = CryptoJS.enc.Hex.parse(jsonObj.s)
}
return cipherParams
},
}
let encrypted = CryptoJS.AES.encrypt(strData, key, {
iv,
blockSize: 16,
mode: CryptoJS.mode.CBC,
format: JsonFormatter,
})
let ciphertext = encrypted.toString().ct
let sign = toMD5('baidu_taihe_music' + ciphertext + timestamp)
let jsonRet = {
timestamp,
param: ciphertext,
sign,
}
return jsonRet
},
createUrl(param, method) {
let data = this.aesPassEncod(param)
return `http://musicmini.qianqian.com/v1/restserver/ting?method=${method}&time=${Date.now()}&timestamp=${data.timestamp}&param=${data.param}&sign=${data.sign}`
},
getTagsUrl() {
return this.createUrl({
from: 'qianqianmini',
type: 'diy',
version: '10.1.8',
}, 'baidu.ting.ugcdiy.getChannels')
},
getListUrl(sortType, tagName, page) {
return this.createUrl({
channelname: tagName || '全部',
from: 'qianqianmini',
offset: (page - 1) * this.limit_list,
order_type: sortType,
size: this.limit_list,
version: '10.1.8',
}, 'baidu.ting.ugcdiy.getChanneldiy')
},
getListDetailUrl(list_id, page) {
return this.createUrl({
list_id,
offset: (page - 1) * this.limit_song,
size: this.limit_song,
withcount: '1',
withsong: '1',
}, 'baidu.ting.ugcdiy.getBaseInfo')
},
// 获取标签
getTags(tryNum = 0) {
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_tags = httpFetch(this.getTagsUrl())
return this._requestObj_tags!.promise.then(({ body }) => {
if (body.error_code !== this.successCode) return this.getTags(++tryNum)
return {
hotTag: this.filterInfoHotTag(body.result.hot),
tags: this.filterTagInfo(body.result.tags),
source: 'bd',
}
})
},
filterInfoHotTag(rawList) {
return rawList.map(item => ({
name: item,
id: item,
source: 'bd',
}))
},
filterTagInfo(rawList) {
return rawList.map(type => ({
name: type.first,
list: type.second.map(item => ({
parent_id: type.first,
parent_name: type.first,
id: item,
name: item,
source: 'bd',
})),
}))
},
// 获取列表数据
getList(sortId, tagId, page, tryNum = 0) {
if (this._requestObj_list) this._requestObj_list.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_list = httpFetch(this.getListUrl(sortId, tagId, page))
return this._requestObj_list!.promise.then(({ body }) => {
if (body.error_code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
return {
list: this.filterList(body.diyInfo),
total: body.nums,
page,
limit: this.limit_list,
source: 'bd',
}
})
},
/**
* 格式化播放数量
* @param {*} num
*/
formatPlayCount(num) {
if (num > 100000000) return parseInt((num / 10000000) as any) / 10 + '亿'
if (num > 10000) return parseInt((num / 1000) as any) / 10 + '万'
return num
},
filterList(rawData) {
return rawData.map(item => ({
play_count: this.formatPlayCount(item.listen_num),
id: String(item.list_id),
author: item.username,
name: item.title,
// time: item.publish_time,
img: item.list_pic_large || item.list_pic,
grade: item.grade,
desc: item.desc || item.tag,
source: 'bd',
}))
},
// 获取歌曲列表内的音乐
getListDetail(id, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1')
const requestObj_listDetail = httpFetch(this.getListDetailUrl(id, page))
return requestObj_listDetail.promise.then(({ body }) => {
if (body.error_code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
let listData = this.filterData(body.result.songlist)
return {
list: listData,
page,
limit: this.limit_song,
total: body.result.song_num,
source: 'bd',
info: {
name: body.result.info.list_title,
img: body.result.info.list_pic,
desc: body.result.info.list_desc,
author: body.result.info.userinfo.username,
play_count: this.formatPlayCount(body.result.listen_num),
},
}
})
},
filterData(rawList) {
// console.log(rawList)
return rawList.map(item => {
const types: any[] = []
const _types: Record<string, any> = {}
let size = null
let itemTypes = item.all_rate.split(',')
if (itemTypes.includes('128')) {
types.push({ type: '128k', size })
_types['128k'] = {
size,
}
}
if (itemTypes.includes('320')) {
types.push({ type: '320k', size })
_types['320k'] = {
size,
}
}
if (itemTypes.includes('flac')) {
types.push({ type: 'flac', size })
_types.flac = {
size,
}
}
// types.reverse()
return {
singer: item.author.replace(',', '、'),
name: item.title,
albumName: item.album_title,
albumId: item.album_id,
source: 'bd',
interval: formatPlayTime(parseInt(item.file_duration)),
songmid: item.song_id,
img: item.pic_s500,
lrc: null,
types,
_types,
typeUrl: {},
}
})
},
}
// getList
// getTags
// getListDetail
-63
View File
@@ -1,63 +0,0 @@
import { getMusicInfosByList } from './musicInfo.ts'
import { createHttpFetch } from './util.ts'
export default {
/**
* 通过AlbumId获取专辑信息
* @param {*} id
*/
async getAlbumInfo(id) {
const albumInfoRequest = await createHttpFetch('http://kmrserviceretry.kugou.com/container/v1/album?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589', {
method: 'POST',
body: {
appid: 1005,
clienttime: 1681833686,
clientver: 11589,
data: [{ album_id: id }],
fields: 'language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,exclusive,is_publish,trans_param,authors,album_tag',
isBuy: 0,
key: 'e6f3306ff7e2afb494e89fbbda0becbf',
mid: '22945702112173152889429073101964063697',
show_album_tag: 0,
},
})
if (!albumInfoRequest) return Promise.reject(new Error('get album info failed.'))
const albumInfo = albumInfoRequest[0]
return {
name: albumInfo.album_name,
image: albumInfo.sizable_cover.replace('{size}', 240),
desc: albumInfo.intro,
authorName: albumInfo.author_name,
// play_count: this.formatPlayCount(info.count),
}
},
/**
* 通过AlbumId获取专辑
* @param {*} id
* @param {*} page
*/
async getAlbumDetail(id, page = 1, limit = 200) {
const albumList = await createHttpFetch(`http://mobiles.kugou.com/api/v3/album/song?version=9108&albumid=${id}&plat=0&pagesize=${limit}&area_code=0&page=${page}&with_res_tag=0`)
if (!albumList.info) return Promise.reject(new Error('Get album list failed.'))
let result = await getMusicInfosByList(albumList.info)
const info = await this.getAlbumInfo(id)
return {
list: result || [],
page,
limit,
total: albumList.total,
source: 'kg',
info: {
name: info.name,
img: info.image,
desc: info.desc,
author: info.authorName,
// play_count: this.formatPlayCount(info.count),
},
}
},
}
-50
View File
@@ -1,50 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_test = {
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/url/kg/${songInfo._types[type].hash}/${type}`, {
method: 'get',
timeout,
headers,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(requestMsg.fail))
}
})
return requestObj
},
getPic(songInfo) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/pic/kg/${songInfo.hash}`, {
method: 'get',
timeout,
headers,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ body }) => {
return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail))
})
return requestObj
},
getLyric(songInfo) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/lrc/kg/${songInfo.hash}`, {
method: 'get',
timeout,
headers,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ body }) => {
return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail))
})
return requestObj
},
}
export default api_test
-25
View File
@@ -1,25 +0,0 @@
import { createHttpFetch } from './util.ts'
export default {
requestObj: null as any,
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
},
tipSearchBySong(str) {
this.cancelTipSearch()
this.requestObj = createHttpFetch(`https://searchtip.kugou.com/getSearchTip?MusicTipCount=10&keyword=${encodeURIComponent(str)}`, {
headers: {
referer: 'https://www.kugou.com/',
},
})
return this.requestObj.then(body => {
return body[0].RecordDatas
})
},
handleResult(rawData) {
return rawData.map(info => info.HintInfo)
},
async search(str) {
return this.tipSearchBySong(str).then(result => this.handleResult(result))
},
}
-2
View File
@@ -1,2 +0,0 @@
!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t=t||self,t.infSign=n())}(this,function(){"use strict";function t(t,n,r){return n in t?Object.defineProperty(t,n,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[n]=r,t}function n(t,n){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var e=Object.getOwnPropertySymbols(t);n&&(e=e.filter(function(n){return Object.getOwnPropertyDescriptor(t,n).enumerable})),r.push.apply(r,e)}return r}function r(r){for(var e=1;e<arguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(o,!0).forEach(function(n){t(r,n,o[n])}):Object.getOwnPropertyDescriptors?Object.defineProperties(r,Object.getOwnPropertyDescriptors(o)):n(o).forEach(function(t){Object.defineProperty(r,t,Object.getOwnPropertyDescriptor(o,t))})}return r}function e(t,n){return n={exports:{}},t(n,n.exports),n.exports}function o(t){return!!t.constructor&&"function"==typeof t.constructor.isBuffer&&t.constructor.isBuffer(t)}function i(t){return"function"==typeof t.readFloatLE&&"function"==typeof t.slice&&o(t.slice(0,0))}function c(){var t,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},i=!1,c=!1,a="json",l=r({},n),u=s.isInClient();"function"==typeof o?t=o:(t=o.callback,i=o.useH5||!1,a=o.postType||"json",c=o.isCDN||!1),e&&("[object Object]"!=Object.prototype.toString.call(e)?u=!1:"urlencoded"==a&&(u=!1));var f=function(){var n=(new Date).getTime(),i=[],s=[],u="NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt",f={srcappid:"2919",clientver:"20000",clienttime:n,mid:n,uuid:n,dfid:"-"};c&&(delete f.clienttime,delete f.mid,delete f.uuid,delete f.dfid),l=r({},f,{},l);for(var g in l)i.push(g);if(i.sort(),i.forEach(function(t){s.push(t+"="+l[t])}),e)if("[object Object]"==Object.prototype.toString.call(e))if("json"==a)s.push(JSON.stringify(e));else{var b=[];for(var g in e)b.push(g+"="+e[g]);s.push(b.join("&"))}else s.push(e);s.unshift(u),s.push(u),l.signature=d(s.join("")),o.log&&(console.log("H5签名前参数",s),console.log("H5签名后返回",l)),e?t&&t(l,"[object Object]"==Object.prototype.toString.call(e)&&"json"==a?JSON.stringify(e):e):t&&t(l)};if(u&&!i){var g=!1;s.mobileCall(764,{get:l,post:e},function(n){return!g&&(g=!0,n&&n.status?(delete n.status,o.log&&(console.log("客户端签名前参数",{get:l,post:e}),console.log("客户端签名后返回",r({},l,{},n))),l=r({},l,{},n),e?t&&t(l,"[object Object]"==Object.prototype.toString.call(e)&&"json"==a?JSON.stringify(e):e):t&&t(l),!1):(u=!1,void f()))})}else u=!1,f()}"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self&&self;var s=e(function(t,n){!function(n,r){t.exports=function(){var t={str2Json:function(t){var n={};if("[object String]"===Object.prototype.toString.call(t))try{n=JSON.parse(t)}catch(t){n={}}return n},json2Str:function(t){var n=t;if("string"!=typeof t)try{n=JSON.stringify(t)}catch(t){n=""}return n},_extend:function(t,n){if(n)for(var r in t)n.hasOwnProperty(r)||(n[r]=t[r]);return n},formatURL:{browser:"",url:""},formatSong:{filename:"",filesize:"",hash:"",bitrate:"",extname:"",duration:"",mvhash:"",m4afilesize:"","320hash":"","320filesize":"",sqhash:"",sqfilesize:0,feetype:0,isfirst:0},formatMV:{filename:"",singername:"",hash:"",imgurl:""},formatShare:{shareName:"",topicName:"",hash:"",listID:"",type:"",suid:"",slid:"",imgUrl:"",filename:"",duration:"",shareData:{linkUrl:"",picUrl:"",content:"",title:""}},cbNum:0,isIOS:!!navigator.userAgent.match(/KGBrowser/gi),isKugouAndroid:!!navigator.userAgent.match(/kugouandroid/gi),isAndroid:"undefined"!=typeof external&&void 0!==external.superCall,loadUrl:function(t){var n=document.createElement("iframe");n.setAttribute("src",t),n.setAttribute("style","display:none;"),n.setAttribute("height","0px"),n.setAttribute("width","0px"),n.setAttribute("frameborder","0"),document.body.appendChild(n),n.parentNode.removeChild(n),n=null},callCmd:function(n){var r=t;if(r.isKugouAndroid){var e={},o="";if(n.cmd&&(e.cmd=n.cmd),n.jsonStr&&(e.jsonStr=n.jsonStr),n.callback&&(o="kgandroidmobilecall"+ ++r.cbNum+Math.random().toString().substr(2,9),e.callback=o,window[o]=function(t,e){void 0!==t&&("[object String]"===Object.prototype.toString.call(t)?(t="#"===e?decodeURIComponent(t):decodeURIComponent(decodeURIComponent(t)),n.callback(r.str2Json(t))):n.callback(t))}),n.AndroidCallback){var i=r.str2Json(n.jsonStr);i.AndroidCallback=o,n.jsonStr=r.json2Str(i),n.jsonStr&&(e.jsonStr=n.jsonStr)}var c=encodeURIComponent(JSON.stringify(e)),s="kugoujsbridge://start.kugou_jsbridge/?".concat(c);r.loadUrl(s)}else if(r.isAndroid){var a="",l="";if(n.jsonStr){if(n.callback&&""!==n.callback&&!0===n.AndroidCallback){l="kgmobilecall"+ ++r.cbNum+Math.random().toString().substr(2,9),window[l]=function(t,e){void 0!==t&&("[object String]"===Object.prototype.toStrLine truncated
-132
View File
@@ -1,132 +0,0 @@
import { httpFetch } from '../request.ts'
import { decodeName } from '../index.ts'
import { formatSinger, objStr2JSON } from './util.ts'
import type { Quality } from '../types.ts'
// let requestObj_list
export default {
limit_list: 36,
limit_song: 1000,
filterListDetail(rawList, albumName, albumId) {
// console.log(rawList)
// console.log(rawList.length, rawList2.length)
return rawList.map((item, inedx) => {
let formats = item.formats.split('|')
let types: Array<{ type: Quality; size: string | null }> = []
let _types: Partial<Record<Quality, { size: string | null }>> = {}
if (formats.includes('MP3128')) {
types.push({ type: '128k', size: null })
_types['128k'] = {
size: null,
}
}
// if (formats.includes('MP3192')) {
// types.push({ type: '192k', size: null })
// _types['192k'] = {
// size: null,
// }
// }
if (formats.includes('MP3H')) {
types.push({ type: '320k', size: null })
_types['320k'] = {
size: null,
}
}
// if (formats.includes('AL')) {
// types.push({ type: 'ape', size: null })
// _types.ape = {
// size: null,
// }
// }
if (formats.includes('ALFLAC')) {
types.push({ type: 'flac', size: null })
_types.flac = {
size: null,
}
}
if (formats.includes('HIRFLAC')) {
types.push({ type: 'flac24bit', size: null })
_types.flac24bit = {
size: null,
}
}
// types.reverse()
return {
singer: formatSinger(decodeName(item.artist)),
name: decodeName(item.name),
albumName,
albumId,
songmid: item.id,
source: 'kw',
interval: null,
img: item.pic,
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {},
}
})
},
/**
* 格式化播放数量
* @param {*} num
*/
formatPlayCount(num: any) {
if (num > 100000000) return parseInt((num / 10000000) as any) / 10 + '亿'
if (num > 10000) return parseInt((num / 1000) as any) / 10 + '万'
return num
},
getAlbumListDetail(id, page, retryNum = 0) {
if (retryNum > 2) return Promise.reject(new Error('try max num'))
const requestObj_listDetail = httpFetch(`http://search.kuwo.cn/r.s?pn=${page - 1}&rn=${this.limit_song}&stype=albuminfo&albumid=${id}&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0`)
return requestObj_listDetail.promise.then(({ statusCode, body }) => {
if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
body = objStr2JSON(body)
// console.log(body)
if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum)
body.name = decodeName(body.name)
return {
list: this.filterListDetail(body.musiclist, body.name, body.albumid),
page,
limit: this.limit_song,
total: parseInt(body.songnum),
source: 'kw',
info: {
name: body.name,
img: body.img || body.hts_img,
desc: decodeName(body.info),
author: decodeName(body.artist),
// play_count: this.formatPlayCount(body.playnum),
},
}
})
},
// getAlbumListDetail(id, page, retryNum = 0) {
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => {
// return resp.promise.then(({ statusCode, body }) => {
// console.log(body)
// return Promise.reject(new Error('failed'))
// // if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
// // const data = body.data
// // console.log(data)
// // if (!data.musicList) return this.getAlbumListDetail(id, page, ++retryNum)
// // return {
// // list: this.filterListDetail(data.musiclist),
// // page,
// // limit: this.limit_song,
// // total: data.total,
// // source: 'kw',
// // info: {
// // name: data.album,
// // img: data.pic,
// // desc: data.albuminfo,
// // author: data.artist,
// // play_count: this.formatPlayCount(data.playCnt),
// // },
// // }
// })
// })
// },
}
-26
View File
@@ -1,26 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_temp = {
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://tm.tempmusics.tk/url/kw/${songInfo.songmid}/${type}`, {
method: 'get',
headers,
timeout,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(body.msg))
}
})
return requestObj
},
}
export default api_temp
-37
View File
@@ -1,37 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_test = {
// getMusicUrl(songInfo, type) {
// const requestObj = httpFetch(`http://45.32.53.128:3002/m/kw/u/${songInfo.songmid}/${type}`, {
// method: 'get',
// headers,
// timeout,
// })
// requestObj.promise = requestObj.promise.then(({ body }) => {
// return body.code === 0 ? Promise.resolve({ type, url: body.data }) : Promise.reject(new Error(body.msg))
// })
// return requestObj
// },
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/url/kw/${songInfo.songmid}/${type}`, {
method: 'get',
timeout,
headers,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(requestMsg.fail))
}
})
return requestObj
},
}
export default api_test
-270
View File
@@ -1,270 +0,0 @@
import { httpFetch } from '../request.ts'
import { decodeLyric, lrcTools } from './util.ts'
import { decodeName } from '../index.ts'
/*
export default {
formatTime(time) {
let m = parseInt(time / 60)
let s = (time % 60).toFixed(2)
return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
},
sortLrcArr(arr) {
const lrcSet = new Set()
let lrc = []
let lrcT = []
for (const item of arr) {
if (lrcSet.has(item.time)) {
const tItem = lrc.pop()
tItem.time = lrc[lrc.length - 1].time
lrcT.push(tItem)
lrc.push(item)
} else {
lrc.push(item)
lrcSet.add(item.time)
}
}
if (lrcT.length && lrc.length > lrcT.length) {
const tItem = lrc.pop()
tItem.time = lrc[lrc.length - 1].time
lrcT.push(tItem)
}
return {
lrc,
lrcT,
}
},
transformLrc(songinfo, lrclist) {
return `[ti:${songinfo.songName}]\n[ar:${songinfo.artist}]\n[al:${songinfo.album}]\n[by:]\n[offset:0]\n${lrclist ? lrclist.map(l => `[${this.formatTime(l.time)}]${l.lineLyric}\n`).join('') : '暂无歌词'}`
},
getLyric(songId) {
const requestObj = httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${songId}`)
requestObj.promise = requestObj.promise.then(({ body }) => {
// console.log(body)
if (!body.data?.lrclist?.length) return Promise.reject(new Error('Get lyric failed'))
let lrcInfo
try {
lrcInfo = this.sortLrcArr(body.data.lrclist)
} catch {
return Promise.reject(new Error('Get lyric failed'))
}
// console.log(body.data.lrclist)
// console.log(lrcInfo.lrc, lrcInfo.lrcT)
// console.log({
// lyric: decodeName(this.transformLrc(body.data.songinfo, lrc)),
// tlyric: decodeName(this.transformLrc(body.data.songinfo, lrcT)),
// })
return {
lyric: decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrc)),
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrcT)) : '',
}
})
return requestObj
},
}
*/
const buf_key = Buffer.from('yeelion')
const buf_key_len = buf_key.length
const buildParams = (id: string, isGetLyricx: boolean): string => {
let params = `user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_${id}`
if (isGetLyricx) params += '&lrcx=1'
const buf_str = Buffer.from(params)
const buf_str_len = buf_str.length
const output = new Uint16Array(buf_str_len)
let i = 0
while (i < buf_str_len) {
let j = 0
while (j < buf_key_len && i < buf_str_len) {
output[i] = buf_key[j] ^ buf_str[i]
i++
j++
}
}
return Buffer.from(output).toString('base64')
}
// console.log(buildParams('207527604', false))
// console.log(buildParams('207527604', true))
const timeExp = /^\[([\d:.]*)\]{1}/g
const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/
const lyricxTag = /^<-?\d+,-?\d+>/
export default {
/* sortLrcArr(arr) {
const lrcSet = new Set()
let lrc = []
let lrcT = []
let markIndex = []
for (const item of arr) {
if (lrcSet.has(item.time)) {
if (lrc.length < 2) continue
const index = lrc.findIndex(l => l.time == item.time)
markIndex.push(index)
if (index == lrc.length - 1) {
lrcT.push({ ...lrc[index], time: item.time })
lrc.push(item)
} else {
lrcT.push({ ...lrc[index], time: lrc[index + 1].time })
if (item.text) {
// const lastIndex = lrc.length - 1
// markIndex.push(lastIndex)
// lrcT.push({ ...lrc[lastIndex], time: lrc[lastIndex - 1].time })
lrc.push(item)
}
}
} else {
lrc.push(item)
lrcSet.add(item.time)
}
}
// console.log(markIndex)
markIndex = Array.from(new Set(markIndex))
for (let index = markIndex.length - 1; index >= 0; index--) {
lrc.splice(markIndex[index], 1)
}
// if (lrcT.length) {
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
// const tItem = lrc.pop()
// tItem.time = lrc[lrc.length - 1].time
// lrcT.push(tItem)
// } else {
// lrc = arr
// lrcT = []
// }
// }
console.log(lrc, lrcT)
return {
lrc,
lrcT,
}
}, */
sortLrcArr(arr) {
const lrcSet = new Set()
let lrc: any[] = []
let lrcT: any[] = []
let isLyricx = false
for (const item of arr) {
if (lrcSet.has(item.time)) {
if (lrc.length < 2) continue
const tItem = lrc.pop()
tItem.time = lrc[lrc.length - 1].time
lrcT.push(tItem)
lrc.push(item)
} else {
lrc.push(item)
lrcSet.add(item.time)
}
if (!isLyricx && lyricxTag.test(item.text)) isLyricx = true
}
if (!isLyricx && lrcT.length > lrc.length * 0.3 && lrc.length - lrcT.length > 6) {
throw new Error('failed')
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
// const tItem = lrc.pop()
// tItem.time = lrc[lrc.length - 1].time
// lrcT.push(tItem)
// } else {
// lrc = arr
// lrcT = []
// }
}
return {
lrc,
lrcT,
}
},
transformLrc(tags, lrclist) {
return `${tags.join('\n')}\n${lrclist ? lrclist.map(l => `[${l.time}]${l.text}\n`).join('') : '暂无歌词'}`
},
parseLrc(lrc: string) {
const lines = lrc.split(/\r\n|\r|\n/)
let tags: string[] = []
let lrcArr: Array<{ time: string; text: string }> = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
let result = timeExp.exec(line)
if (result) {
const text = line.replace(timeExp, '').trim()
let time = RegExp.$1
if (/\.\d\d$/.test(time)) time += '0'
lrcArr.push({
time,
text,
})
} else if (lrcTools.rxps.tagLine.test(line)) {
tags.push(line)
}
}
const lrcInfo = this.sortLrcArr(lrcArr)
return {
lyric: decodeName(this.transformLrc(tags, lrcInfo.lrc)),
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '',
}
},
// getLyric2(musicInfo, isGetLyricx = true) {
// const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
// requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
// if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
// return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => {
// let lrcInfo
// console.log(Buffer.from(base64Data, 'base64').toString())
// try {
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
// } catch {
// return Promise.reject(new Error('Get lyric failed'))
// }
// if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
// lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
// // console.log(lrcInfo.lyric)
// // console.log(lrcInfo.tlyric)
// // console.log(lrcInfo.yrc)
// // console.log(JSON.stringify(lrcInfo))
// })
// })
// return requestObj
// },
getLyric(musicInfo, isGetLyricx = true) {
// this.getLyric2(musicInfo)
const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
return decodeLyric({ lrcBase64: raw!.toString('base64'), isGetLyricx }).then(base64Data => {
// let lrcInfo
// try {
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
// } catch {
// return Promise.reject(new Error('Get lyric failed'))
// }
let lrcInfo
// console.log(Buffer.from(base64Data, 'base64').toString())
try {
lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
} catch (err) {
return Promise.reject(new Error('Get lyric failed'))
}
// console.log(lrcInfo)
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
try {
lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
} catch {
lrcInfo.yrc = ''
}
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
// console.log(lrcInfo)
return lrcInfo
})
})
return requestObj
},
}
-35
View File
@@ -1,35 +0,0 @@
// import { decodeName } from '../index.ts'
// import { tokenRequest } from './util.ts'
import { httpFetch } from '../request.ts'
import type { HttpRequestObject } from '../types.ts'
export default {
regExps: {
relWord: /RELWORD=(.+)/,
},
requestObj: null as HttpRequestObject | null,
async tipSearchBySong(str: string) {
// 报错403,加了referer还是有问题(直接换一个
// this.requestObj = await tokenRequest(`http://www.kuwo.cn/api/www/search/searchKey?key=${encodeURIComponent(str)}`)
this.cancelTipSearch()
this.requestObj = httpFetch(`https://tips.kuwo.cn/t.s?corp=kuwo&newver=3&p2p=1&notrace=0&c=mbox&w=${encodeURIComponent(str)}&encoding=utf8&rformat=json`, {
headers: {
Referer: 'http://www.kuwo.cn/',
},
})
return this.requestObj!.promise.then(({ body, statusCode }) => {
if (statusCode != 200 || !body.WORDITEMS) return Promise.reject(new Error('请求失败'))
return body.WORDITEMS
})
},
handleResult(rawData: any) {
return rawData.map(item => item.RELWORD)
},
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
},
async search(str: string) {
return this.tipSearchBySong(str).then(result => this.handleResult(result))
},
}
-4
View File
@@ -1,4 +0,0 @@
// SDK 根消息模块(转接至 src/common/message.js)
// musicSdk 内 `import { requestMsg } from '../message.js'` 的解析目标
export { requestMsg } from '../src/common/message.js'
-49
View File
@@ -1,49 +0,0 @@
import { createHttpFetch } from './utils/index.js'
import { filterMusicInfoList } from './musicInfo.js'
import { formatPlayCount } from '../index.js'
export default {
/**
* 通过AlbumId获取专辑
* @param {*} id
* @param {*} page
*/
async getAlbumDetail(id, page = 1) {
const list = await createHttpFetch(`http://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong?albumId=${id}&pageNo=${page}`)
if (!list.songList) return Promise.reject(new Error('Get album list error.'))
const songList = filterMusicInfoList(list.songList)
const listInfo = await this.getAlbumInfo(id)
return {
list: songList || [],
page,
limit: listInfo.total,
total: listInfo.total,
source: 'mg',
info: {
name: listInfo.name,
img: listInfo.image,
desc: listInfo.desc,
author: listInfo.author,
play_count: listInfo.play_count,
},
}
}, /**
* 通过AlbumId获取专辑信息
* @param {*} id
*/
async getAlbumInfo(id) {
const info = await createHttpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/resource/album/v2.0?albumId=${id}`)
if (!info) return Promise.reject(new Error('Get album info error.'))
return {
name: info.title ?? null,
image: info.imgItems?.length ? info.imgItems[0].img : null,
desc: info.summary ?? null,
author: info.singer ?? null,
play_count: formatPlayCount(info.opNumItem?.playNum ?? 0),
total: parseInt(info.totalCount) || null,
}
},
}
-26
View File
@@ -1,26 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_test = {
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/url/mg/${songInfo.copyrightId}/${type}`, {
method: 'get',
timeout,
headers,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(requestMsg.fail))
}
})
return requestObj
},
}
export default api_test
-97
View File
@@ -1,97 +0,0 @@
import { httpFetch } from '../request.ts'
import getSongId from './songId.ts'
import { dateFormat2 } from '../index.ts'
import type { HttpRequestObject } from '../types.ts'
export default {
_requestObj: null as HttpRequestObject | null,
_requestObj2: null as HttpRequestObject | null,
_requestObj3: null as HttpRequestObject | null,
lastCommentIds: new Map(),
async getComment(musicInfo, page = 1, limit = 20) {
if (this._requestObj) this._requestObj.cancelHttp()
if (!musicInfo.songId) {
let id = await getSongId(musicInfo)
if (!id) throw new Error('获取评论失败')
musicInfo.songId = id
}
if (page === 1) this.lastCommentIds.clear()
const lastCommentId = this.lastCommentIds.get(String(page)) || ''
if (!lastCommentId && page > 1) throw new Error('获取评论失败')
// const _requestObj = httpFetch(`https://music.migu.cn/v3/api/comment/listComments?targetId=${musicInfo.songId}&pageSize=${limit}&pageNo=${page}`, {
const _requestObj = httpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/user/comment/stack/v1.0?pageSize=${limit}&queryType=1&resourceId=${musicInfo.songId}&resourceType=2&commentId=${lastCommentId}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
// Referer: 'https://music.migu.cn',
},
})
const { body, statusCode } = await _requestObj.promise
// console.log(body)
if (statusCode != 200 || body.code !== '000000') throw new Error('获取评论失败')
const total = parseInt(body.data.commentNums)
const list = this.filterComment(body.data.comments)
this.lastCommentIds.set(String(page + 1), list.length ? list[list.length - 1].id : '')
return { source: 'mg', comments: list, total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
},
async getHotComment(musicInfo, page = 1, limit = 20) {
if (this._requestObj2) this._requestObj2.cancelHttp()
if (!musicInfo.songId) {
let id = await getSongId(musicInfo)
if (!id) throw new Error('获取评论失败')
musicInfo.songId = id
}
// const _requestObj2 = httpFetch(`https://music.migu.cn/v3/api/comment/listTopComments?targetId=${musicInfo.songId}&pageSize=${limit}&pageNo=${page}`, {
const _requestObj2 = httpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/user/comment/stack/v1.0?pageSize=${limit}&queryType=2&resourceId=${musicInfo.songId}&resourceType=2&hotCommentStart=${(page - 1) * limit}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
// Referer: 'https://music.migu.cn',
},
})
const { body, statusCode } = await _requestObj2.promise
// console.log(body)
if (statusCode != 200 || body.code !== '000000') throw new Error('获取热门评论失败')
const total = parseInt(body.data.cfgHotCount)
return { source: 'mg', comments: this.filterComment(body.data.hotComments), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
},
async getReplyComment(musicInfo, replyId, page = 1, limit = 10) {
if (this._requestObj2) this._requestObj2.cancelHttp()
// const _requestObj2 = httpFetch(`https://music.migu.cn/v3/api/comment/listCommentsById?commentId=${replyId}&pageSize=${limit}&pageNo=${page}`, {
const _requestObj2 = httpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/user/comment/stack/${replyId}/v1.0?pageSize=${limit}&queryType=2&resourceId=${musicInfo.songId}&resourceType=2&start=${(page - 1) * limit}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
},
})
const { body, statusCode } = await _requestObj2.promise
// console.log(body)
if (statusCode != 200 || body.code !== '000000') throw new Error('获取回复评论失败')
const total = parseInt(body.data.replyTotalCount)
return { source: 'mg', comments: this.filterComment(body.data.mainCommentItem.replyComments), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
},
filterComment(rawList) {
return rawList.map(item => ({
id: item.commentId,
text: item.commentInfo,
time: item.commentTime,
timeStr: dateFormat2(new Date(item.commentTime).getTime()),
userName: item.user.nickName,
avatar: item.user.middleIcon || item.user.bigIcon || item.user.smallIcon,
userId: item.user.userId,
likedCount: item.opNumItem.thumbNum,
replyNum: item.replyTotalCount,
reply: item.replyComments.map(c => ({
id: c.replyId,
text: c.replyInfo,
time: c.replyTime,
timeStr: dateFormat2(new Date(c.replyTime).getTime()),
userName: c.user.nickName,
avatar: c.user.middleIcon || c.user.bigIcon || c.user.smallIcon,
userId: c.user.userId,
likedCount: null,
replyNum: null,
})),
}))
},
}
-19
View File
@@ -1,19 +0,0 @@
import { httpFetch } from '../request.ts'
import type { HttpRequestObject } from '../types.ts'
export default {
_requestObj: null as HttpRequestObject | null,
async getList(retryNum = 0) {
if (this._requestObj) this._requestObj.cancelHttp()
if (retryNum > 2) return Promise.reject(new Error('try max num'))
const _requestObj = httpFetch('http://jadeite.migu.cn:7090/music_search/v3/search/hotword')
const { body, statusCode } = await _requestObj.promise
if (statusCode != 200 || body.code !== '000000') throw new Error('获取热搜词失败')
// console.log(body, statusCode)
return { source: 'mg', list: this.filterList(body.data.hotwords[0].hotwordList) }
},
filterList(rawList) {
return rawList.filter(item => item.resourceType == 'song').map(item => item.word)
},
}
-34
View File
@@ -1,34 +0,0 @@
import { apis } from '../api-source.ts'
import leaderboard from './leaderboard.ts'
import songList from './songList.ts'
import musicSearch from './musicSearch.ts'
import pic from './pic.ts'
import lyric from './lyric.ts'
import hotSearch from './hotSearch.ts'
import comment from './comment.ts'
import album from './album.js'
// import tipSearch from './tipSearch.ts'
const mg = {
// tipSearch,
songList,
musicSearch,
leaderboard,
hotSearch,
comment,
album,
getMusicUrl(songInfo, type) {
return apis('mg').getMusicUrl!(songInfo, type)
},
getLyric(songInfo) {
return lyric.getLyric(songInfo)
},
getPic(songInfo) {
return pic.getPic(songInfo)
},
getMusicDetailPageUrl(songInfo) {
return `http://music.migu.cn/v3/music/song/${songInfo.copyrightId}`
},
}
export default mg
-252
View File
@@ -1,252 +0,0 @@
import { httpFetch } from '../request.ts'
import { filterMusicInfoList } from './musicInfo.ts'
import type { Source, HttpRequestObject } from '../types.ts'
// const boardList = [{ id: 'mg__27553319', name: '咪咕尖叫新歌榜', bangid: '27553319' }, { id: 'mg__27186466', name: '咪咕尖叫热歌榜', bangid: '27186466' }, { id: 'mg__27553408', name: '咪咕尖叫原创榜', bangid: '27553408' }, { id: 'mg__23189800', name: '咪咕港台榜', bangid: '23189800' }, { id: 'mg__23189399', name: '咪咕内地榜', bangid: '23189399' }, { id: 'mg__19190036', name: '咪咕欧美榜', bangid: '19190036' }, { id: 'mg__23189813', name: '咪咕日韩榜', bangid: '23189813' }, { id: 'mg__23190126', name: '咪咕彩铃榜', bangid: '23190126' }, { id: 'mg__15140045', name: '咪咕KTV榜', bangid: '15140045' }, { id: 'mg__15140034', name: '咪咕网络榜', bangid: '15140034' }, { id: 'mg__23217754', name: 'MV榜', bangid: '23217754' }, { id: 'mg__23218151', name: '新专辑榜', bangid: '23218151' }, { id: 'mg__21958042', name: 'iTunes榜', bangid: '21958042' }, { id: 'mg__21975570', name: 'billboard榜', bangid: '21975570' }, { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815' }, { id: 'mg__22272904', name: '中国TOP排行榜', bangid: '22272904' }, { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943' }, { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437' }]
// const boardList = [
// { id: 'mg__27553319', name: '尖叫新歌榜', bangid: '27553319', webId: 'jianjiao_newsong' },
// { id: 'mg__27186466', name: '尖叫热歌榜', bangid: '27186466', webId: 'jianjiao_hotsong' },
// { id: 'mg__27553408', name: '尖叫原创榜', bangid: '27553408', webId: 'jianjiao_original' },
// { id: 'mg__23189800', name: '港台榜', bangid: '23189800', webId: 'hktw' },
// { id: 'mg__23189399', name: '内地榜', bangid: '23189399', webId: 'mainland' },
// { id: 'mg__19190036', name: '欧美榜', bangid: '19190036', webId: 'eur_usa' },
// { id: 'mg__23189813', name: '日韩榜', bangid: '23189813', webId: 'jpn_kor' },
// { id: 'mg__23190126', name: '彩铃榜', bangid: '23190126', webId: 'coloring' },
// { id: 'mg__15140045', name: 'KTV榜', bangid: '15140045', webId: 'ktv' },
// { id: 'mg__15140034', name: '网络榜', bangid: '15140034', webId: 'network' },
// // { id: 'mg__21958042', name: '美国iTunes榜', bangid: '21958042', webId: 'itunes' },
// // { id: 'mg__21975570', name: '美国billboard榜', bangid: '21975570', webId: 'billboard' },
// // { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815', webId: 'hito' },
// // { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943', webId: 'mnet' },
// // { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437', webId: 'uk' },
// ]
interface BoardItem {
id: string
name: string
bangid: string
source: Source
// 旧数据字段,部分条目缺失(缺失时 getDetailPageUrl 拼出 undefined,与原实现一致)
webId?: string
}
const boardList: BoardItem[] = [
{
id: 'mg__27553319',
name: '新歌榜',
bangid: '27553319',
source: 'mg',
},
{
id: 'mg__27186466',
name: '热歌榜',
bangid: '27186466',
source: 'mg',
},
{
id: 'mg__27553408',
name: '原创榜',
bangid: '27553408',
source: 'mg',
},
{
id: 'mg__75959118',
name: '音乐风向榜',
bangid: '75959118',
source: 'mg',
},
{
id: 'mg__76557036',
name: '彩铃分贝榜',
bangid: '76557036',
source: 'mg',
},
{
id: 'mg__76557745',
name: '会员臻爱榜',
bangid: '76557745',
source: 'mg',
},
{
id: 'mg__23189800',
name: '港台榜',
bangid: '23189800',
source: 'mg',
},
{
id: 'mg__23189399',
name: '内地榜',
bangid: '23189399',
source: 'mg',
},
{
id: 'mg__19190036',
name: '欧美榜',
bangid: '19190036',
source: 'mg',
},
{
id: 'mg__83176390',
name: '国风金曲榜',
bangid: '83176390',
source: 'mg',
},
]
export default {
limit: 200,
list: [
{
id: 'mgyyb',
name: '音乐榜',
bangid: '27553319',
},
{
id: 'mgysb',
name: '影视榜',
bangid: '23603721',
},
{
id: 'mghybnd',
name: '华语内地榜',
bangid: '23603926',
},
{
id: 'mghyjqbgt',
name: '华语港台榜',
bangid: '23603954',
},
{
id: 'mgomb',
name: '欧美榜',
bangid: '23603974',
},
{
id: 'mgrhb',
name: '日韩榜',
bangid: '23603982',
},
{
id: 'mgwlb',
name: '网络榜',
bangid: '23604058',
},
{
id: 'mgclb',
name: '彩铃榜',
bangid: '23604023',
},
{
id: 'mgktvb',
name: 'KTV榜',
bangid: '23604040',
},
{
id: 'mgrcb',
name: '原创榜',
bangid: '23604032',
},
],
getUrl(id, page) {
return `https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/querycontentbyId.do?columnId=${id}&needAll=0`
// return `http://m.music.migu.cn/migu/remoting/cms_list_tag?nid=${id}&pageSize=${this.limit}&pageNo=${page - 1}`
},
successCode: '000000',
requestBoardsObj: null as HttpRequestObject | null,
getBoardsData() {
if (this.requestBoardsObj) (this as any)._requestBoardsObj.cancelHttp()
this.requestBoardsObj = httpFetch('https://app.c.nf.migu.cn/pc/bmw/rank/rank-index/v1.0', {
// this.requestBoardsObj = httpFetch('https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/rank-list/release', {
// this.requestBoardsObj = httpFetch('https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/indexrank.do?templateVersion=8', {
headers: {
Referer: 'https://app.c.nf.migu.cn/',
'User-Agent': 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Mobile Safari/537.36',
channel: '0146921',
},
})
return this.requestBoardsObj!.promise
},
getData(url) {
const requestObj = httpFetch(url)
return requestObj.promise
},
// filterBoardsData(listData, list = [], ids = new Set()) {
// for (const item of listData) {
// if (item.rankId && !ids.has(item.rankId)) {
// ids.add(item.rankId)
// list.push({
// id: 'mg__' + item.rankId,
// name: item.rankName,
// bangid: String(item.rankId),
// source: 'mg',
// })
// } else if (item.contents) this.filterBoardsData(item.contents, list, ids)
// }
// return list
// },
// filterBoardsData(rawList) {
// // console.log(rawList)
// let list = []
// for (const board of rawList) {
// if (board.template != 'group1') continue
// for (const item of board.itemList) {
// if ((item.template != 'row1' && item.template != 'grid1' && !item.actionUrl) || !item.actionUrl.includes('rank-info')) continue
// let data = item.displayLogId.param
// list.push({
// id: 'mg__' + data.rankId,
// name: data.rankName,
// bangid: String(data.rankId),
// })
// }
// }
// return list
// },
async getBoards(retryNum = 0) {
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
// let response
// try {
// response = await this.getBoardsData()
// } catch (error) {
// return this.getBoards(retryNum)
// }
// // console.log(response.body.data.contentItemList)
// if (response.statusCode !== 200 || response.body.code !== this.successCode) return this.getBoards(retryNum)
// const list = this.filterBoardsData(response.body.data.contents)
// console.log(list)
// // console.log(JSON.stringify(list))
// this.list = list
// return {
// list,
// source: 'mg',
// }
this.list = boardList
return {
list: boardList,
source: 'mg',
}
},
getList(bangid, page, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
return this.getData(this.getUrl(bangid, page)).then(({ statusCode, body }) => {
// console.log(body)
if (statusCode !== 200 || body.code !== this.successCode) return this.getList(bangid, page, retryNum)
const list = filterMusicInfoList(body.columnInfo.contents.map(m => m.objectInfo))
return {
total: list.length,
list,
limit: this.limit,
page,
source: 'mg',
}
})
},
getDetailPageUrl(id) {
if (typeof id == 'string') id = id.replace('mg__', '')
for (const item of boardList) {
if (item.bangid == id) {
return `https://music.migu.cn/v3/music/top/${item.webId}`
}
}
return null
},
}
-104
View File
@@ -1,104 +0,0 @@
import { httpFetch } from '../request.ts'
import { getMusicInfo } from './musicInfo.ts'
import { decrypt } from './utils/mrc.ts'
const mrcTools = {
rxps: {
lineTime: /^\s*\[(\d+),\d+\]/,
wordTime: /\(\d+,\d+\)/,
wordTimeAll: /(\(\d+,\d+\))/g,
},
parseLyric(str) {
str = str.replace(/\r/g, '')
const lines = str.split('\n')
const yrcLines: string[] = []
const lrcLines: string[] = []
for (const line of lines) {
if (line.length < 6) continue
let result = this.rxps.lineTime.exec(line)
if (!result) continue
const startTime = parseInt(result[1])
let time: any = startTime
let ms = time % 1000
time /= 1000
let m = parseInt((time / 60) as unknown as string).toString().padStart(2, '0')
time %= 60
let s = parseInt(time as unknown as string).toString().padStart(2, '0')
time = `${m}:${s}.${ms}`
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`[${time}]${words.replace(this.rxps.wordTimeAll, '')}`)
let times = words.match(this.rxps.wordTimeAll)
if (!times) continue
times = times.map(time => {
const result = /\((\d+),(\d+)\)/.exec(time)
return `<${parseInt(result![1]) - startTime},${result![2]}>`
})
const wordArr = words.split(this.rxps.wordTime)
const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('')
yrcLines.push(`[${time}]${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
yrc: yrcLines.join('\n'),
}
},
getText(url, tryNum = 0) {
const requestObj = httpFetch(url, {
headers: {
Referer: 'https://app.c.nf.migu.cn/',
'User-Agent': 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Mobile Safari/537.36',
channel: '0146921',
},
})
return requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 200) return body
if (tryNum > 5 || statusCode == 404) return Promise.reject(new Error('歌词获取失败'))
return this.getText(url, ++tryNum)
})
},
getMrc(url) {
return this.getText(url).then(text => {
return this.parseLyric(decrypt(text))
})
},
getLrc(url) {
return this.getText(url).then(text => ({ yrc: '', lyric: text }))
},
getTrc(url) {
if (!url) return Promise.resolve('')
return this.getText(url)
},
async getMusicInfo(songInfo) {
return songInfo.mrcUrl == null
? getMusicInfo(songInfo.songmid || songInfo.copyrightId)
: songInfo
},
getLyric(songInfo) {
return {
promise: this.getMusicInfo(songInfo).then(info => {
let p
if (info.mrcUrl) p = this.getMrc(info.mrcUrl)
else if (info.lrcUrl) p = this.getLrc(info.lrcUrl)
else if (info.lyricUrl) p = this.getLrc(info.lyricUrl)
if (p == null) return Promise.reject(new Error('获取歌词失败'))
return Promise.all([p, this.getTrc(info.trcUrl)]).then(([lrcInfo, tlyric]) => {
lrcInfo.tlyric = tlyric
return lrcInfo
})
}),
cancelHttp() {},
}
},
}
export default {
getLyric(songInfo) {
let requestObj = mrcTools.getLyric(songInfo)
return requestObj
},
}
-163
View File
@@ -1,163 +0,0 @@
import { sizeFormate, formatPlayTime } from '../index.ts'
import { createHttpFetch } from './utils/index.ts'
import { formatSingerName } from '../utils.ts'
import type { SongInfo, SongType, QualityInfo, Quality } from '../types.ts'
const createGetMusicInfosTask = (ids: Array<string | undefined>) => {
let list = ids
const tasks: Array<Array<string | undefined>> = []
while (list.length) {
tasks.push(list.slice(0, 100))
if (list.length < 100) break
list = list.slice(100)
}
let url = 'https://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?resourceType=2'
return Promise.all(tasks.map(task => createHttpFetch(url, {
method: 'POST',
form: {
resourceId: task.join('|'),
},
}).then(data => data.resource)))
}
export const filterMusicInfoList = (rawList: any[]): SongInfo[] => {
// console.log(rawList)
let ids = new Set()
const list: any[] = []
rawList.forEach(item => {
if (!item.songId || ids.has(item.songId)) return
ids.add(item.songId)
const types: SongType[] = []
const _types: Partial<Record<Quality, QualityInfo>> = {}
item.newRateFormats?.forEach(type => {
let size
switch (type.formatType) {
case 'PQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: '128k', size })
_types['128k'] = {
size,
}
break
case 'HQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: '320k', size })
_types['320k'] = {
size,
}
break
case 'SQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: 'flac', size })
_types.flac = {
size,
}
break
case 'ZQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size,
}
break
}
})
const intervalTest = /(\d\d:\d\d)$/.test(item.length)
list.push({
singer: formatSingerName(item.artists, 'name'),
name: item.songName,
albumName: item.album,
albumId: item.albumId,
songmid: item.songId,
copyrightId: item.copyrightId,
source: 'mg',
interval: intervalTest ? RegExp.$1 : null,
img: item.albumImgs?.length ? item.albumImgs[0].img : null,
lrc: null,
lrcUrl: item.lrcUrl,
mrcUrl: item.mrcUrl,
trcUrl: item.trcUrl,
otherSource: null,
types,
_types,
typeUrl: {},
})
})
return list
}
export const filterMusicInfoListV5 = (rawList) => {
// console.log(rawList)
let ids = new Set()
const list: any[] = []
rawList.forEach(item => {
if (!item.songId || ids.has(item.songId)) return
ids.add(item.songId)
const types: SongType[] = []
const _types: Partial<Record<Quality, QualityInfo>> = {}
item.audioFormats?.forEach(type => {
let size
switch (type.formatType) {
case 'PQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: '128k', size })
_types['128k'] = {
size,
}
break
case 'HQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: '320k', size })
_types['320k'] = {
size,
}
break
case 'SQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: 'flac', size })
_types.flac = {
size,
}
break
case 'ZQ':
size = sizeFormate(type.size ?? type.androidSize)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size,
}
break
}
})
list.push({
singer: formatSingerName(item.singerList, 'name'),
name: item.songName,
albumName: item.album,
albumId: item.albumId,
songmid: item.songId,
copyrightId: item.copyrightId,
source: 'mg',
interval: formatPlayTime(item.duration),
img: item.img3 || item.img2 || item.img1 || null,
lrc: null,
lrcUrl: item.lrcUrl,
mrcUrl: item.mrcUrl,
trcUrl: item.trcUrl,
otherSource: null,
types,
_types,
typeUrl: {},
})
})
return list
}
export const getMusicInfo = async(copyrightId?: string): Promise<SongInfo | undefined> => {
return getMusicInfos([copyrightId]).then(data => data[0])
}
export const getMusicInfos = async(copyrightIds: Array<string | undefined>): Promise<SongInfo[]> => {
return filterMusicInfoList((await createGetMusicInfosTask(copyrightIds)).flat())
}
-224
View File
@@ -1,224 +0,0 @@
import { httpFetch } from '../request.ts'
import { sizeFormate, formatPlayTime } from '../index.ts'
import { toMD5, formatSingerName } from '../utils.ts'
import type { SongType, QualityInfo, Quality } from '../types.ts'
export const createSignature = (time: string, str: string): { sign: string, deviceId: string } => {
const deviceId = '963B7AA0D21511ED807EE5846EC87D20'
const signatureMd5 = '6cdc72a439cef99a3418d2a78aa28c73'
const sign = toMD5(`${str}${signatureMd5}yyapp2d16148780a1dcc7408e06336b98cfd50${deviceId}${time}`)
return { sign, deviceId }
}
export default {
limit: 20,
total: 0,
page: 0,
allPage: 1,
// 旧版API
// musicSearch(str, page, limit) {
// const searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// // searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// headers: {
// // sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// // timestamp: 1578225871982,
// // appId: 'yyapp2',
// // mode: 'android',
// // ua: 'Android_migu',
// // version: '6.9.4',
// osVersion: 'android 7.0',
// 'User-Agent': 'okhttp/3.9.1',
// },
// })
// // searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// return searchRequest.promise.then(({ body }) => body)
// },
// handleResult(rawData) {
// // console.log(rawData)
// let ids = new Set()
// const list = []
// rawData.forEach(item => {
// if (ids.has(item.id)) return
// ids.add(item.id)
// const types = []
// const _types = {}
// item.newRateFormats && item.newRateFormats.forEach(type => {
// let size
// switch (type.formatType) {
// case 'PQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '128k', size })
// _types['128k'] = {
// size,
// }
// break
// case 'HQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '320k', size })
// _types['320k'] = {
// size,
// }
// break
// case 'SQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac', size })
// _types.flac = {
// size,
// }
// break
// case 'ZQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac24bit', size })
// _types.flac24bit = {
// size,
// }
// break
// }
// })
// const albumNInfo = item.albums && item.albums.length
// ? {
// id: item.albums[0].id,
// name: item.albums[0].name,
// }
// : {}
// list.push({
// singer: this.getSinger(item.singers),
// name: item.name,
// albumName: albumNInfo.name,
// albumId: albumNInfo.id,
// songmid: item.songId,
// copyrightId: item.copyrightId,
// source: 'mg',
// interval: null,
// img: item.imgItems && item.imgItems.length ? item.imgItems[0].img : null,
// lrc: null,
// lrcUrl: item.lyricUrl,
// mrcUrl: item.mrcurl,
// trcUrl: item.trcUrl,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// })
// return list
// },
musicSearch(str, page, limit) {
const time = Date.now().toString()
const signData = createSignature(time, str)
const searchRequest = httpFetch(`https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=0&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0&sid=USS`, {
headers: {
uiVersion: 'A_music_3.6.1',
deviceId: signData.deviceId,
timestamp: time,
sign: signData.sign,
channel: '0146921',
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
})
return searchRequest.promise.then(({ body }) => body)
},
filterData(rawData) {
// console.log(rawData)
const list: any[] = []
const ids = new Set()
rawData.forEach(item => {
item.forEach(data => {
if (!data.songId || !data.copyrightId || ids.has(data.copyrightId)) return
ids.add(data.copyrightId)
const types: SongType[] = []
const _types: Partial<Record<Quality, QualityInfo>> = {}
data.audioFormats && data.audioFormats.forEach(type => {
let size
switch (type.formatType) {
case 'PQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '128k', size })
_types['128k'] = {
size,
}
break
case 'HQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '320k', size })
_types['320k'] = {
size,
}
break
case 'SQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac', size })
_types.flac = {
size,
}
break
case 'ZQ24':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size,
}
break
}
})
let img = data.img3 || data.img2 || data.img1 || null
if (img && !/https?:/.test(data.img3)) img = 'http://d.musicapp.migu.cn' + img
list.push({
singer: formatSingerName(data.singerList),
name: data.name,
albumName: data.album,
albumId: data.albumId,
songmid: data.songId,
copyrightId: data.copyrightId,
source: 'mg',
interval: formatPlayTime(data.duration),
img,
lrc: null,
lrcUrl: data.lrcUrl,
mrcUrl: data.mrcurl,
trcUrl: data.trcUrl,
types,
_types,
typeUrl: {},
})
})
})
return list
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then(result => {
// console.log(result)
if (!result || result.code !== '000000') return Promise.reject(new Error(result ? result.info : '搜索失败'))
const songResultData = result.songResultData || { resultList: [], totalCount: 0 }
let list = this.filterData(songResultData.resultList)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = parseInt(songResultData.totalCount)
this.page = page
this.allPage = Math.ceil(this.total / limit)
return {
list,
allPage: this.allPage,
limit,
total: this.total,
source: 'mg',
}
})
},
}
-28
View File
@@ -1,28 +0,0 @@
import { httpFetch } from '../request.ts'
import getSongId from './songId.ts'
export default {
async getPicUrl(songId, tryNum = 0) {
let requestObj = httpFetch(`http://music.migu.cn/v3/api/music/audioPlayer/getSongPic?songId=${songId}`, {
headers: {
Referer: 'http://music.migu.cn/v3/music/player/audio?from=migu',
},
})
requestObj.promise.then(({ body }) => {
if (body.returnCode !== '000000') {
if (tryNum > 5) return Promise.reject(new Error('图片获取失败'))
let tryRequestObj = (this as any).getPic(songId, ++tryNum)
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
return tryRequestObj.promise
}
let url = body.largePic || body.mediumPic || body.smallPic
if (!/https?:/.test(url)) url = 'http:' + url
return url
})
return requestObj
},
async getPic(songInfo) {
const songId = await getSongId(songInfo)
return this.getPicUrl(songId)
},
}
-32
View File
@@ -1,32 +0,0 @@
// import { httpFetch } from '../request.js'
import { getMusicInfo } from './musicInfo.ts'
import type { SongInfo } from '../types.ts'
const getSongId = async(mInfo: SongInfo): Promise<string | undefined> => {
if (mInfo.songmid != mInfo.copyrightId) return mInfo.songmid
const musicInfo = await getMusicInfo(mInfo.copyrightId)
return musicInfo!.songmid
}
// export const getSongId = async(musicInfo, retry = 0) => {
// if (musicInfo.songmid != musicInfo.copyrightId) return musicInfo.songmid
// if (++retry > 2) return Promise.reject(new Error('max retry'))
// const requestObj = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/listen-url?netType=00&resourceType=2&songId=${musicInfo.copyrightId}&toneFlag=PQ`, {
// headers: {
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36',
// channel: '0146921',
// },
// })
// return requestObj.promise.then(({ body }) => {
// console.log(body)
// if (!body || body.code !== '000000') return this.getSongId(musicInfo, retry)
// const id = body.data.songItem.songId
// if (!id) throw new Error('failed')
// return id
// })
// }
export default getSongId
-363
View File
@@ -1,363 +0,0 @@
import { httpFetch } from '../request.ts'
import { formatPlayCount } from '../index.ts'
import { filterMusicInfoListV5 } from './musicInfo.ts'
import { createSignature } from './musicSearch.ts'
import { createHttpFetch } from './utils/index.ts'
import type { HttpRequestObject } from '../types.ts'
// const tagData = { code: '000000', info: 'SUCCESS', columnInfo: { columnTitle: '分类', columnId: '15244430', columnPid: '15031270', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 6, columnStatus: 1, columnCreateTime: '2016-11-10 10:53:05.077', columntype: 2011, contents: [{ contentId: '18464615', relationType: 2011, objectInfo: { columnTitle: '热门', columnId: '18464615', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 8, columnStatus: 1, columnCreateTime: '2017-02-20 16:09:13.400', columntype: 2011, contents: [{ contentId: '1000001672', relationType: 4034, objectInfo: { tagId: '1000001672', tagName: '流行', resourceType: '2034' }, relationSort: 9 }, { contentId: '1003449727', relationType: 4034, objectInfo: { tagId: '1003449727', tagName: '厂牌', resourceType: '2034' }, relationSort: 8 }, { contentId: '1000001795', relationType: 4034, objectInfo: { tagId: '1000001795', tagName: '伤感', resourceType: '2034' }, relationSort: 7 }, { contentId: '1001076080', relationType: 4034, objectInfo: { tagId: '1001076080', tagName: '电影', resourceType: '2034' }, relationSort: 6 }, { contentId: '1000001675', relationType: 4034, objectInfo: { tagId: '1000001675', tagName: '中国风', resourceType: '2034' }, relationSort: 5 }, { contentId: '1000001635', relationType: 4034, objectInfo: { tagId: '1000001635', tagName: '经典老歌', resourceType: '2034' }, relationSort: 4 }, { contentId: '1000001831', relationType: 4034, objectInfo: { tagId: '1000001831', tagName: '翻唱', resourceType: '2034' }, relationSort: 3 }, { contentId: '1000001762', relationType: 4034, objectInfo: { tagId: '1000001762', tagName: '国语', resourceType: '2034' }, relationSort: 1 }], dataVersion: '1620410266029', customizedPicUrls: [] }, relationSort: 6 }, { contentId: '15244503', relationType: 2011, objectInfo: { columnTitle: '主题', columnId: '15244503', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 23, columnStatus: 1, columnCreateTime: '2016-11-10 10:54:10.261', columntype: 2011, contents: [{ contentId: '1003449727', relationType: 4034, objectInfo: { tagId: '1003449727', tagName: '厂牌', resourceType: '2034' }, relationSort: 29 }, { contentId: '1001076080', relationType: 4034, objectInfo: { tagId: '1001076080', tagName: '电影', resourceType: '2034' }, relationSort: 28 }, { contentId: '1001076078', relationType: 4034, objectInfo: { tagId: '1001076078', tagName: '电视剧', resourceType: '2034' }, relationSort: 27 }, { contentId: '1001076083', relationType: 4034, objectInfo: { tagId: '1001076083', tagName: '综艺', resourceType: '2034' }, relationSort: 26 }, { contentId: '1000001827', relationType: 4034, objectInfo: { tagId: '1000001827', tagName: 'KTV', resourceType: '2034' }, relationSort: 23 }, { contentId: '1000001698', relationType: 4034, objectInfo: { tagId: '1000001698', tagName: '爱情', resourceType: '2034' }, relationSort: 22 }, { contentId: '1000001635', relationType: 4034, objectInfo: { tagId: '1000001635', tagName: '经典老歌', resourceType: '2034' }, relationSort: 21 }, { contentId: '1001076096', relationType: 4034, objectInfo: { tagId: '1001076096', tagName: '网络热歌', resourceType: '2034' }, relationSort: 20 }, { contentId: '1000001780', relationType: 4034, objectInfo: { tagId: '1000001780', tagName: '儿童歌曲', resourceType: '2034' }, relationSort: 19 }, { contentId: '1000587702', relationType: 4034, objectInfo: { tagId: '1000587702', tagName: '广场舞', resourceType: '2034' }, relationSort: 18 }, { contentId: '1000587717', relationType: 4034, objectInfo: { tagId: '1000587717', tagName: '70后', resourceType: '2034' }, relationSort: 17 }, { contentId: '1000587718', relationType: 4034, objectInfo: { tagId: '1000587718', t Line truncated
export default {
_requestObj_tags: null as HttpRequestObject | null,
_requestObj_list: null as HttpRequestObject | null,
limit_list: 30,
limit_song: 50,
successCode: '000000',
cachedDetailInfo: {},
cachedUrl: {},
sortList: [
{
name: '推荐',
id: '15127315',
// id: '1',
},
// {
// name: '最新',
// id: '15127272',
// // id: '2',
// },
],
regExps: {
list: /<li><div class="thumb">.+?<\/li>/g,
listInfo: /.+data-original="(.+?)".*data-id="(\d+)".*<div class="song-list-name"><a\s.*?>(.+?)<\/a>.+<i class="iconfont cf-bofangliang"><\/i>(.+?)<\/div>/,
// https://music.migu.cn/v3/music/playlist/161044573?page=1
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl: 'https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-taglist/release',
// tagsUrl: 'https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/indexTagPage.do?needAll=0',
getSongListUrl(sortId, tagId, page) {
// if (tagId == null) {
// return sortId == 'recommend'
// ? `https://music.migu.cn/v3/music/playlist?page=${page}&from=migu`
// : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu`
// }
// return `https://music.migu.cn/v3/music/playlist?tagId=${tagId}&page=${page}&from=migu`
if (!tagId) {
// return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=1`
// return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}`
// https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=50&start=2&templateVersion=5&type=1
// return `https://m.music.migu.cn/migu/remoting/playlist_bycolumnid_tag?playListType=2&type=1&columnId=${sortId}&startIndex=${(page - 1) * 10}`
return `https://app.c.nf.migu.cn/pc/bmw/page-data/playlist-square-recommend/v1.0?templateVersion=2&pageNo=${page}`
}
// return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?area=2&count=${this.limit_list}&start=${page}&tags=${tagId}&templateVersion=5&type=3`
return `https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=${page}&templateVersion=2&tagId=${tagId}`
// return `https://m.music.migu.cn/migu/remoting/playlist_bycolumnid_tag?playListType=2&type=1&tagId=${tagId}&startIndex=${(page - 1) * 10}`
},
getSongListDetailUrl(id, page) {
return `https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0?pageNo=${page}&pageSize=${this.limit_song}&playlistId=${id}`
// return `https://app.c.nf.migu.cn/MIGUM2.0/v1.0/user/queryMusicListSongs.do?musicListId=${id}&pageNo=${page}&pageSize=${this.limit_song}`
},
defaultHeaders: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
Referer: 'https://m.music.migu.cn/',
// language: 'Chinese',
// ua: 'Android_migu',
// mode: 'android',
// version: '6.8.5',
},
getListDetailList(id, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
// https://h5.nf.migu.cn/app/v4/p/share/playlist/index.html?id=184187437&channel=0146921
// if (/playlist\/index\.html\?/.test(id)) {
// id = id.replace(/.*(?:\?|&)id=(\d+)(?:&.*|$)/, '$1')
// } else if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1')
const requestObj_listDetail = httpFetch(this.getSongListDetailUrl(id, page), { headers: this.defaultHeaders })
return requestObj_listDetail.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getListDetailList(id, page, ++tryNum)
// console.log(JSON.stringify(body))
// console.log(body)
return {
list: filterMusicInfoListV5(body.data.songList),
page,
limit: this.limit_song,
total: body.data.totalCount,
source: 'mg',
}
})
},
getListDetailInfo(id, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (this.cachedDetailInfo[id]) return Promise.resolve(this.cachedDetailInfo[id])
const requestObj_listDetailInfo = httpFetch(`https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=${id}`, {
headers: this.defaultHeaders,
})
return requestObj_listDetailInfo.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
// console.log(JSON.stringify(body))
// console.log(body)
const cachedDetailInfo = this.cachedDetailInfo[id] = {
name: body.data.title,
img: body.data.imgItem.img,
desc: body.data.summary,
author: body.data.ownerName,
play_count: formatPlayCount(body.data.opNumItem.playNum),
}
return cachedDetailInfo
})
},
async getDetailUrl(link, page, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('link try max num'))
const requestObj_listDetailLink = httpFetch(link, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link,
},
})
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getDetailUrl(link, page, ++retryNum)
if (location) {
this.cachedUrl[link] = location
return this.getListDetail(location, page)
}
return Promise.reject(new Error('link get failed'))
},
getListDetail(id, page, retryNum = 0) { // 获取歌曲列表内的音乐
// https://h5.nf.migu.cn/app/v4/p/share/playlist/index.html?id=184187437&channel=0146921
// http://c.migu.cn/00bTY6?ifrom=babddaadfde4ebeda289d671ab62f236
// https://music.migu.cn/v5/#/playlist?playlistId=221573417
if (/\/playlist[/?]/.test(id)) {
id = /(?:playlistId|id)=(\d+)/.exec(id)?.[1]
if (!id) throw new Error('list detail id parse failed')
} else if (this.regExps.listDetailLink.test(id)) {
id = id.replace(this.regExps.listDetailLink, '$1')
} else if ((/[?&:/]/.test(id))) {
const url = this.cachedUrl[id]
return url ? this.getListDetail(url, page) : this.getDetailUrl(id, page)
}
return Promise.all([
this.getListDetailList(id, page, retryNum),
this.getListDetailInfo(id, retryNum),
]).then(([listData, info]) => {
listData.info = info
return listData
})
},
// 获取列表数据
getList(sortId, tagId, page, tryNum = 0) {
if (this._requestObj_list) this._requestObj_list.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_list = httpFetch(this.getSongListUrl(sortId, tagId, page), {
headers: this.defaultHeaders,
// headers: {
// sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// timestamp: 1578225871982,
// appId: 'yyapp2',
// mode: 'android',
// ua: 'Android_migu',
// version: '6.9.4',
// osVersion: 'android 7.0',
// 'User-Agent': 'okhttp/3.9.1',
// },
})
// return this._requestObj_list.promise.then(({ statusCode, body }) => {
// if (statusCode !== 200) return this.getList(sortId, tagId, page)
// let list = body.replace(/[\r\n]/g, '').match(this.regExps.list)
// if (!list) return Promise.reject(new Error('获取列表失败'))
// return list.map(item => {
// let info = item.match(this.regExps.listInfo)
// return {
// play_count: info[4],
// id: info[2],
// author: '',
// name: info[3],
// time: '',
// img: info[1],
// grade: 0,
// desc: '',
// source: 'mg',
// }
// })
// })
// return this._requestObj_list.promise.then(({ body }) => {
// console.log(body)
// if (body.retCode !== '100000' || body.retMsg.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
// return {
// list: this.filterList(body.retMsg.playlist),
// total: parseInt(body.retMsg.countSize),
// page,
// limit: this.limit_list,
// source: 'mg',
// }
// })
return this._requestObj_list!.promise.then(({ body }) => {
// console.log(body)
// if (body.retCode !== '000000') return this.getList(sortId, tagId, page, ++tryNum)
if (body.code !== '000000') return this.getList(sortId, tagId, page, ++tryNum)
const list = body.data.contents ? this.filterList2(body.data.contents) : this.filterList(body.data.contentItemList[1].itemList)
return {
list,
total: 99999,
page,
limit: this.limit_list,
source: 'mg',
}
})
},
filterList2(listData, list: any[] = [], ids = new Set()) {
for (const item of listData) {
if (item.contents) this.filterList2(item.contents, list, ids)
else if (item.resType == '2021' && !ids.has(item.resId)) {
ids.add(item.resId)
list.push({
id: String(item.resId),
author: '',
name: item.txt,
// time: dateFormat(item.createTime, 'Y-M-D'),
img: item.img,
// grade: item.grade,
// total: item.contentCount,
desc: item.txt2,
source: 'mg',
})
}
}
return list
},
filterList(rawData) {
// console.log(rawData)
return rawData.map(item => ({
play_count: item.barList[0]?.title,
id: String(item.logEvent.contentId),
author: '',
name: item.title,
// time: dateFormat(item.createTime, 'Y-M-D'),
img: item.imageUrl,
// grade: item.grade,
// total: item.contentCount,
desc: '',
source: 'mg',
}))
},
// 获取标签
getTag(tryNum = 0) {
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_tags = httpFetch(this.tagsUrl, { headers: this.defaultHeaders })
return this._requestObj_tags!.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getTag(++tryNum)
// console.log(body)
return this.filterTagInfo(body.data)
})
// return Promise.resolve(this.filterTagInfo(tagData.columnInfo.contents))
},
filterTagInfo(rawList) {
return {
hotTag: rawList[0].content.map(({ texts: [name, id] }) => ({
id,
name,
source: 'mg',
})),
tags: rawList.slice(1).map(({ header, content }) => ({
name: header.title,
list: content.map(({ texts: [name, id] }) => ({
// parent_id: objectInfo.columnId,
// parent_name: objectInfo.columnTitle,
id,
name,
source: 'mg',
})),
})),
source: 'mg',
}
// return {
// hotTag: rawList[0].objectInfo.contents.map(item => ({
// id: item.objectInfo.tagId,
// name: item.objectInfo.tagName,
// source: 'mg',
// })),
// tags: rawList.slice(1).map(({ objectInfo }) => ({
// name: objectInfo.columnTitle,
// list: objectInfo.contents.map(item => ({
// parent_id: objectInfo.columnId,
// parent_name: objectInfo.columnTitle,
// id: item.objectInfo.tagId,
// name: item.objectInfo.tagName,
// source: 'mg',
// })),
// })),
// source: 'mg',
// }
},
getTags() {
return this.getTag()
},
getDetailPageUrl(id) {
if (/playlist\/index\.html\?/.test(id)) {
id = id.replace(/.*(?:\?|&)id=(\d+)(?:&.*|$)/, '$1')
} else if (this.regExps.listDetailLink.test(id)) {
id = id.replace(this.regExps.listDetailLink, '$1')
}
return `https://music.migu.cn/v3/music/playlist/${id}`
},
filterSongListResult(raw) {
const list: any[] = []
raw.forEach(item => {
if (!item.id) return
const playCount = parseInt(item.playNum)
list.push({
play_count: isNaN(playCount) ? 0 : formatPlayCount(playCount),
id: item.id,
author: item.userName,
name: item.name,
img: item.musicListPicUrl,
total: item.musicNum,
source: 'mg',
})
})
return list
},
search(text, page, limit = 20) {
const timeStr = Date.now().toString()
const signResult = createSignature(timeStr, text)
return createHttpFetch(`https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A0%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22bestShow%22%3A0%2C%22songlist%22%3A1%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(text)}&pageNo=${page}&sort=0&sid=USS`, {
headers: {
uiVersion: 'A_music_3.6.1',
deviceId: signResult.deviceId,
timestamp: timeStr,
sign: signResult.sign,
channel: '0146921',
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}).then(body => {
if (!body.songListResultData) throw new Error('get song list faild.')
const list = this.filterSongListResult(body.songListResultData.result)
return {
list,
limit,
total: parseInt(body.songListResultData.totalCount),
source: 'mg',
}
})
},
}
// getList
// getTags
// getListDetail
-25
View File
@@ -1,25 +0,0 @@
import { createHttpFetch } from './utils/index.ts'
export default {
requestObj: null as any,
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
},
tipSearchBySong(str) {
this.cancelTipSearch()
this.requestObj = createHttpFetch(`https://music.migu.cn/v3/api/search/suggest?keyword=${encodeURIComponent(str)}`, {
headers: {
referer: 'https://music.migu.cn/v3',
},
})
return this.requestObj.then(body => {
return body.songs
})
},
handleResult(rawData) {
return rawData.map(info => `${info.name} - ${info.singerName}`)
},
async search(str) {
return this.tipSearchBySong(str).then(result => this.handleResult(result))
},
}
-30
View File
@@ -1,30 +0,0 @@
import { httpFetch } from '../../request.ts'
import type { HttpOptions } from '../../types.ts'
/**
* 创建一个适用于MG的Http请求
* @param {*} url
* @param {*} options
* @param {*} retryNum
*/
export const createHttpFetch = async(url: string, options?: HttpOptions, retryNum = 0): Promise<any> => {
if (retryNum > 2) throw new Error('try max num')
let result
try {
result = await httpFetch(url, options).promise
} catch (err) {
console.log(err)
return createHttpFetch(url, options, ++retryNum)
}
if (result.statusCode !== 200 ||
(
(result.body.code !== undefined
? result.body.code
: result.body.returnCode !== undefined
? result.body.returnCode
: result.body.code
) !== '000000')
) return createHttpFetch(url, options, ++retryNum)
if (result.body.data) return result.body.data
return result.body
}
-104
View File
@@ -1,104 +0,0 @@
// const key = 'karakal@123Qcomyidongtiantianhaoting'
const DELTA = 2654435769n
const MIN_LENGTH = 32
// const SPECIAL_CHAR = '0'
const keyArr = [
27303562373562475n,
18014862372307051n,
22799692160172081n,
34058940340699235n,
30962724186095721n,
27303523720101991n,
27303523720101998n,
31244139033526382n,
28992395054481524n,
]
const teaDecrypt = (data: bigint[], key: bigint[]): bigint[] => {
const length = data.length
const lengthBitint = BigInt(length)
if (length >= 1) {
// let j = data[data.length - 1];
let j2 = data[0]
let j3 = toLong((6n + (52n / lengthBitint)) * DELTA)
while (true) {
let j4 = j3
if (j4 == 0n) break
let j5 = toLong(3n & toLong(j4 >> 2n))
let j6 = lengthBitint
while (true) {
j6--
if (j6 > 0n) {
let j7 = data[(j6 - 1n) as unknown as number]
let i = j6 as unknown as number
j2 = toLong(data[i] - (toLong(toLong(j2 ^ j4) + toLong(j7 ^ key[toLong(toLong(3n & j6) ^ j5) as unknown as number])) ^ toLong(toLong(toLong(j7 >> 5n) ^ toLong(j2 << 2n)) + toLong(toLong(j2 >> 3n) ^ toLong(j7 << 4n)))))
data[i] = j2
} else break
}
let j8 = data[lengthBitint - 1n as unknown as number]
j2 = toLong(data[0n as unknown as number] - toLong(toLong(toLong(key[toLong(toLong(j6 & 3n) ^ j5) as unknown as number] ^ j8) + toLong(j2 ^ j4)) ^ toLong(toLong(toLong(j8 >> 5n) ^ toLong(j2 << 2n)) + toLong(toLong(j2 >> 3n) ^ toLong(j8 << 4n)))))
data[0] = j2
j3 = toLong(j4 - DELTA)
}
}
return data
}
const longArrToString = (data: bigint[]): string => {
const arrayList: string[] = []
for (const j of data) arrayList.push(longToBytes(j).toString('utf16le'))
return arrayList.join('')
}
// https://stackoverflow.com/a/29132118
const longToBytes = (l: bigint): Buffer => {
const result = Buffer.alloc(8)
for (let i = 0; i < 8; i++) {
result[i] = parseInt((l & 0xFFn) as unknown as string)
l >>= 8n
}
return result
}
const toBigintArray = (data: string): bigint[] => {
const length = Math.floor(data.length / 16)
const jArr: bigint[] = Array(length)
for (let i = 0; i < length; i++) {
jArr[i] = toLong(data.substring(i * 16, (i * 16) + 16))
}
return jArr
}
// 基于社区通用的 mrc 逐字歌词解析思路
const MAX = 9223372036854775807n
const MIN = -9223372036854775808n
const toLong = (str: string | bigint): bigint => {
const num = typeof str == 'string' ? BigInt('0x' + str) : str
if (num > MAX) return toLong(num - (1n << 64n))
else if (num < MIN) return toLong(num + (1n << 64n))
return num
}
export const decrypt = (data: string | null): string | null => {
// console.log(data.length)
// -3551594764563790630
// console.log(toLongArrayFromArr(Buffer.from(key)))
// console.log(teaDecrypt(toBigintArray(data), keyArr))
// console.log(longArrToString(teaDecrypt(toBigintArray(data), keyArr)))
// console.log(toByteArray(teaDecrypt(toBigintArray(data), keyArr)))
return (data == null || data.length < MIN_LENGTH)
? data
: longArrToString(teaDecrypt(toBigintArray(data), keyArr))
}
// console.log(14895149309145760986n - )
// console.log(toLong('14895149309145760986'))
// console.log(decrypt(str))
// console.log(decrypt(str))
// console.log(toByteArray([6048138644744000495n]))
// console.log(toByteArray([16325999628386395n]))
// console.log(toLong(90994076459972177136n))
-9
View File
@@ -1,9 +0,0 @@
// SDK 公共请求常量
export const bHh = '624868746c'
export const headers: Record<string, string | string[]> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
[bHh]: [bHh],
}
export const timeout = 15000
-8
View File
@@ -1,8 +0,0 @@
// SDK 根请求模块(转接至 src/common/request.js)
// musicSdk 内 `import { ... } from '../request.js'` 的解析目标
import { httpFetch as rawHttpFetch } from '../src/common/request.js'
import type { HttpRequestObject, HttpOptions } from './types.ts'
export const httpFetch: (url: string, options?: HttpOptions) => HttpRequestObject =
rawHttpFetch as (url: string, options?: HttpOptions) => HttpRequestObject
-45
View File
@@ -1,45 +0,0 @@
import { filterMusicInfoItem } from './singer.ts'
import { createMusicuFetch } from './utils/index.ts'
export default {
/**
* 通过专辑 mid 获取专辑详情与曲目列表
* @param {*} id 专辑 mid(搜索结果中的 albumId)
* @param {*} page
* @param {*} limit
*/
async getAlbumDetail(id, page = 1, limit = 100) {
const body = await createMusicuFetch({
req: {
module: 'music.musichallAlbum.AlbumSongList',
method: 'GetAlbumSongList',
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
},
})
if (body.req.code != 0) throw new Error('get album song list faild.')
const data = body.req.data
const songList = data.songList ?? []
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则
const first = songList[0]?.songInfo
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))]
return {
source: 'tx',
info: {
name: first?.album?.name ?? null,
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`,
desc: null,
author: authors.join('、') || null,
},
list: this.filterSongList(songList),
page,
limit,
total: data.totalNum,
}
},
filterSongList(raw) {
return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
},
}
-29
View File
@@ -1,29 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_messoer = {
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/url/tx/${songInfo.songmid}/${type}`, {
method: 'get',
timeout,
headers,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(requestMsg.fail))
}
})
return requestObj
},
getPic(songInfo) {
return Promise.resolve(`https://y.gtimg.cn/music/photo_new/T002R500x500M000${songInfo.albumId}.jpg`)
},
}
export default api_messoer
-30
View File
@@ -1,30 +0,0 @@
import type { HttpRequestObject } from '../types.ts'
import { httpFetch } from '../request.ts'
export default {
// regExps: {
// relWord: /RELWORD=(.+)/,
// },
requestObj: null as HttpRequestObject | null,
tipSearch(str) {
this.cancelTipSearch()
this.requestObj = httpFetch(`https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&notice=0&platform=yqq&needNewCode=0`, {
headers: {
Referer: 'https://y.qq.com/portal/player.html',
},
})
return this.requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('请求失败'))
return body.data
})
},
handleResult(rawData) {
return rawData.map(info => `${info.name} - ${info.singer}`)
},
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
},
async search(str) {
return this.tipSearch(str).then(result => this.handleResult(result.song.itemlist))
},
}
-50
View File
@@ -1,50 +0,0 @@
import { httpFetch } from '../request.js'
import { requestMsg } from '../message.js'
import { headers, timeout } from '../options.js'
import { dnsLookup } from '../utils.js'
const api_test = {
getMusicUrl(songInfo, type) {
const requestObj = httpFetch(`http://ts.tempmusics.tk/url/wy/${songInfo.songmid}/${type}`, {
method: 'get',
timeout,
headers,
lookup: dnsLookup,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests))
switch (body.code) {
case 0: return Promise.resolve({ type, url: body.data })
default: return Promise.reject(new Error(requestMsg.fail))
}
})
return requestObj
},
/* getPic(songInfo) {
const requestObj = httpFetch(`http://localhost:3100/pic/wy/${songInfo.songmid}`, {
method: 'get',
timeout,
headers,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ body }) => {
return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail))
})
return requestObj
},
getLyric(songInfo) {
const requestObj = httpFetch(`http://localhost:3100/lrc/wy/${songInfo.songmid}`, {
method: 'get',
timeout,
headers,
family: 4,
})
requestObj.promise = requestObj.promise.then(({ body }) => {
return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail))
})
return requestObj
}, */
}
export default api_test
-34
View File
@@ -1,34 +0,0 @@
import { httpFetch } from '../request.ts'
import { weapi } from './utils/crypto.ts'
import { formatSingerName } from '../utils.ts'
import type { HttpRequestObject } from '../types.ts'
export default {
requestObj: null as HttpRequestObject | null,
cancelTipSearch() {
if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
},
tipSearchBySong(str) {
this.cancelTipSearch()
this.requestObj = httpFetch('https://music.163.com/weapi/search/suggest/web', {
method: 'POST',
headers: {
referer: 'https://music.163.com/',
origin: 'https://music.163.com/',
},
form: weapi({
s: str,
}),
})
return this.requestObj!.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 200) return Promise.reject(new Error('请求失败'))
return body.result.songs
})
},
handleResult(rawData) {
return rawData.map(info => `${info.name} - ${formatSingerName(info.artists, 'name')}`)
},
async search(str) {
return this.tipSearchBySong(str).then(result => this.handleResult(result))
},
}
-23
View File
@@ -1,23 +0,0 @@
import { httpFetch } from '../../request.ts'
import { eapi } from './crypto.ts'
import type { HttpRequestObject } from '../../types.ts'
export const eapiRequest = (url: string, data: object): HttpRequestObject => {
return httpFetch('http://interface.music.163.com/eapi/batch', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
// cookie: 'os=pc; deviceId=A9C064BB4584D038B1565B58CB05F95290998EE8B025AA2D07AE; osver=Microsoft-Windows-10-Home-China-build-19043-64bit; appver=2.5.2.197409; channel=netease; MUSIC_A=37a11f2eb9de9930cad479b2ad495b0e4c982367fb6f909d9a3f18f876c6b49faddb3081250c4980dd7e19d4bd9bf384e004602712cf2b2b8efaafaab164268a00b47359f85f22705cc95cb6180f3aee40f5be1ebf3148d888aa2d90636647d0c3061cd18d77b7a0; __csrf=05b50d54082694f945d7de75c210ef94; mode=Z7M-KP5(7)GZ; NMTID=00OZLp2VVgq9QdwokUgq3XNfOddQyIAAAF_6i8eJg; ntes_kaola_ad=1',
},
form: eapi(url, data),
})
// requestObj.promise = requestObj.promise.then(({ body }) => {
// // console.log(raw)
// console.log(body)
// // console.log(eapiDecrypt(raw))
// // return eapiDecrypt(raw)
// return body
// })
// return requestObj
}
+10 -5
View File
@@ -3,12 +3,13 @@
"version": "1.0.0", "version": "1.0.0",
"description": "多平台音乐聚合服务器", "description": "多平台音乐聚合服务器",
"type": "module", "type": "module",
"main": "src/index.js", "main": "sdk/index.ts",
"packageManager": "pnpm@12.4.1", "packageManager": "pnpm@12.4.1",
"scripts": { "scripts": {
"build": "tsc", "typecheck": "tsc",
"start": "pnpm run build && node src/server.js", "start": "node src/server.ts",
"test": "pnpm run build && node src/smoke-test.js" "dev": "node scripts/dev.mjs",
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/bili-cdn-test.ts && node test/smoke-test.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
@@ -18,11 +19,15 @@
"fastify": "^5.12.1", "fastify": "^5.12.1",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"isolated-vm": "^7.0.1", "isolated-vm": "^7.0.1",
"pako": "^2.1.0" "pako": "^2.1.0",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.4.0", "@types/node": "^26.4.0",
"playwright": "^1.62.1", "playwright": "^1.62.1",
"typescript": "^7.0.2" "typescript": "^7.0.2"
},
"engines": {
"node": ">=22.18"
} }
} }
+216
View File
@@ -190,6 +190,9 @@ importers:
pako: pako:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.2.0 version: 2.2.0
qrcode:
specifier: ^1.5.4
version: 1.5.4
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^26.4.0 specifier: ^26.4.0
@@ -383,6 +386,14 @@ packages:
ajv@8.20.0: ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
atomic-sleep@1.0.0: atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -398,6 +409,20 @@ packages:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
content-disposition@2.0.1: content-disposition@2.0.1:
resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -419,6 +444,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -427,6 +456,12 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'} engines: {node: '>=6'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escape-html@1.0.3: escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@@ -461,6 +496,14 @@ packages:
resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
engines: {node: '>=20'} engines: {node: '>=20'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
glob@13.0.6: glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -480,6 +523,10 @@ packages:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
isolated-vm@7.0.1: isolated-vm@7.0.1:
resolution: {integrity: sha512-nBCQ1hXEaYKYJ79wwfhaF44ITOJC30Hw0wPEsFeNsdD3hdeKK7FW1SGMQYBUA+qasvkqdOazI+iBuq0lcbN/aw==} resolution: {integrity: sha512-nBCQ1hXEaYKYJ79wwfhaF44ITOJC30Hw0wPEsFeNsdD3hdeKK7FW1SGMQYBUA+qasvkqdOazI+iBuq0lcbN/aw==}
engines: {node: '>=24.0.0'} engines: {node: '>=24.0.0'}
@@ -497,6 +544,10 @@ packages:
light-my-request@6.6.0: light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lru-cache@11.5.2: lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -528,9 +579,25 @@ packages:
openapi-types@12.1.3: openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pako@2.2.0: pako@2.2.0:
resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-scurry@2.0.2: path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -555,12 +622,21 @@ packages:
engines: {node: '>=20'} engines: {node: '>=20'}
hasBin: true hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
process-warning@4.0.1: process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
process-warning@5.1.0: process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
quick-format-unescaped@4.0.4: quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -571,10 +647,17 @@ packages:
real-require@1.0.0: real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
ret@0.5.0: ret@0.5.0:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -605,6 +688,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@@ -622,6 +708,14 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
thread-stream@4.2.0: thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -642,11 +736,29 @@ packages:
undici-types@8.9.0: undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yaml@2.9.1: yaml@2.9.1:
resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
snapshots: snapshots:
'@fastify/accept-negotiator@2.1.0': {} '@fastify/accept-negotiator@2.1.0': {}
@@ -796,6 +908,12 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
avvio@9.3.0: avvio@9.3.0:
@@ -809,6 +927,20 @@ snapshots:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
camelcase@5.3.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
content-disposition@2.0.1: {} content-disposition@2.0.1: {}
cookie@1.1.1: {} cookie@1.1.1: {}
@@ -819,10 +951,16 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
depd@2.0.0: {} depd@2.0.0: {}
dequal@2.0.3: {} dequal@2.0.3: {}
dijkstrajs@1.0.3: {}
emoji-regex@8.0.0: {}
escape-html@1.0.3: {} escape-html@1.0.3: {}
fast-decode-uri-component@1.0.1: {} fast-decode-uri-component@1.0.1: {}
@@ -876,6 +1014,13 @@ snapshots:
fast-querystring: 1.1.2 fast-querystring: 1.1.2
safe-regex2: 5.1.1 safe-regex2: 5.1.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
get-caller-file@2.0.5: {}
glob@13.0.6: glob@13.0.6:
dependencies: dependencies:
minimatch: 10.2.6 minimatch: 10.2.6
@@ -898,6 +1043,8 @@ snapshots:
ipaddr.js@2.5.0: {} ipaddr.js@2.5.0: {}
is-fullwidth-code-point@3.0.0: {}
isolated-vm@7.0.1: isolated-vm@7.0.1:
dependencies: dependencies:
node-gyp-build: 4.8.4 node-gyp-build: 4.8.4
@@ -922,6 +1069,10 @@ snapshots:
process-warning: 4.0.1 process-warning: 4.0.1
set-cookie-parser: 2.7.2 set-cookie-parser: 2.7.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lru-cache@11.5.2: {} lru-cache@11.5.2: {}
mime@3.0.0: {} mime@3.0.0: {}
@@ -940,8 +1091,20 @@ snapshots:
openapi-types@12.1.3: {} openapi-types@12.1.3: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
pako@2.2.0: {} pako@2.2.0: {}
path-exists@4.0.0: {}
path-scurry@2.0.2: path-scurry@2.0.2:
dependencies: dependencies:
lru-cache: 11.5.2 lru-cache: 11.5.2
@@ -973,18 +1136,30 @@ snapshots:
dependencies: dependencies:
playwright-core: 1.63.0 playwright-core: 1.63.0
pngjs@5.0.0: {}
process-warning@4.0.1: {} process-warning@4.0.1: {}
process-warning@5.1.0: {} process-warning@5.1.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
quick-format-unescaped@4.0.4: {} quick-format-unescaped@4.0.4: {}
real-require@0.2.0: {} real-require@0.2.0: {}
real-require@1.0.0: {} real-require@1.0.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
ret@0.5.0: {} ret@0.5.0: {}
reusify@1.1.0: {} reusify@1.1.0: {}
@@ -1003,6 +1178,8 @@ snapshots:
semver@7.8.5: {} semver@7.8.5: {}
set-blocking@2.0.0: {}
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
@@ -1015,6 +1192,16 @@ snapshots:
statuses@2.0.2: {} statuses@2.0.2: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
thread-stream@4.2.0: thread-stream@4.2.0:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
@@ -1048,4 +1235,33 @@ snapshots:
undici-types@8.9.0: {} undici-types@8.9.0: {}
which-module@2.0.1: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@4.0.3: {}
yaml@2.9.1: {} yaml@2.9.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
+2
View File
@@ -1,2 +1,4 @@
# isolated-vm 的原生构建脚本需显式放行(pnpm 12 要求),
# 否则加载失败会降级为「未安装 isolated-vm」,社区 .js 音源脚本被静默跳过。
allowBuilds: allowBuilds:
isolated-vm: true isolated-vm: true
+29
View File
@@ -0,0 +1,29 @@
// pnpm dev 的包装:node --watch-path 指向不存在的目录会直接 ENOENT 崩溃。
// sources/ 不入库(第三方音源脚本,gitignore 整目录),新 clone 的仓库里没有它,
// 所以这里只为实际存在的目录注册 watch。
import { existsSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const watchTargets = ['src', 'sdk', 'sources']
.map((dir) => join(root, dir))
.filter((dir) => existsSync(dir))
const child = spawn(
process.execPath,
[
...watchTargets.flatMap((dir) => ['--watch-path', dir]),
'--watch',
'--watch-preserve-output',
join(root, 'src/server.ts'),
],
{ stdio: 'inherit', cwd: root },
)
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal)
else process.exit(code ?? 1)
})
+50
View File
@@ -0,0 +1,50 @@
// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
// 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
// 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
// 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
// 榜单/热搜/歌手/评论(上游均无)。
// 取流入口在 sdk/apis/5sing.ts;songmid 约定 "<songId>|<songType>"(如 "932557|yc",与上游 ID 一致)。
// 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益);
// 本移植从 sdk/auth/store.ts 的 getCredentials('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import fivesingApi from '../apis/5sing.ts'
import { buildSongLink, extractSongId, getStatus } from './util.ts'
const fivesing = {
musicSearch,
getMusicUrl(songInfo, type) {
return fivesingApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
playlist,
parse,
getMusicDetailPageUrl(songInfo) {
if (songInfo?.link) return String(songInfo.link)
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id, songInfo?.songType)
if (!songID || !songType) return 'http://5sing.kugou.com/'
return buildSongLink(songType, songID)
},
// 上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义
userPlaylist: {
async getList(_page?: number, _limit?: number): Promise<any> {
throw new Error('user playlists not supported')
},
},
}
/** 登录状态(凭据存在性判断;5sing 上游无扫码/账密登录,凭据只能手工写入 data/auth.json) */
export const status = getStatus
export default fivesing
+38
View File
@@ -0,0 +1,38 @@
// 5sing 歌词(上游 fivesing/lyric.go 的 GetLyrics 移植)
// 接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
// → data.dynamicWords 即 LRC 文本(实测为 "[00:31.76]..." 格式,逐字/滚动歌词同源)。
// 上游 ID 约定:model.Song.ID = "<songid>|<songtype>",Extra.songid/songtype 优先;
// 本移植从 songmid 的 "|" 拆分(路由层只透传 songmid),也接受 songInfo.songType 字段。
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric: '' }(5sing 无翻译歌词)。
import { isForeignSource, extractSongId } from './util.ts'
import { fetchDynamicLyric } from './musicInfo.ts'
/** 非取消句柄版(测试/内部用):直接返回歌词文本 */
export const getLyricText = async(songID: string, songType: string): Promise<string> => {
const lyric = await fetchDynamicLyric(songID, songType)
if (!lyric) throw new Error('lyrics not found')
return lyric
}
export const getLyric = (songInfo: any, ..._rest: any[]) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
let current: any = null
requestObj.cancelHttp = () => {
current?.cancelHttp?.()
current = null
}
requestObj.promise = (async() => {
if (isForeignSource(songInfo)) throw new Error('source mismatch')
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType)
if (!songID) throw new Error('invalid id')
const lyric = await fetchDynamicLyric(songID, songType, (request) => { current = request })
if (!lyric) throw new Error('lyrics not found')
return { lyric, tlyric: '' }
})()
return requestObj
}
export default { getLyric, getLyricText }
+114
View File
@@ -0,0 +1,114 @@
// 5sing 歌曲信息 / 取流底层(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink 移植)
//
// 链路(实测 2026-10-02 全部可用,无需签名/base64 参数——上游 fivesing 包内没有 _sign/base64 逻辑,
// 仅 playlist 搜索的封面等是 CDN 地址;取流为明文 URL 直出):
// 取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
// 详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
// 档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址(上游 getFirstValid 取首个非空)。
// 上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
import { MOBILE_API, fetch5singJson, getFirstValid, kindName, estimateDuration, buildSongLink } from './util.ts'
export type AudioHit = { type: string, url: string }
const QUALITY_TIERS: Array<'sq' | 'hq' | 'lq'> = ['sq', 'hq', 'lq']
/** data 里某档位的可用地址(主地址 → backup,上游 getFirstValid 语义) */
const tierUrl = (data: any, tier: string): string =>
getFirstValid(data?.[`${tier}url`], data?.[`${tier}url_backup`])
/**
* 上游 fetchAudioLink:仅获取音频链接。
* register 用于把当前 httpFetch 对象交给调用方(cancelHttp)。
* preferLow:请求 64k/lq 时从低档位开始找(上游无该意识,移植补的最小档位选择,默认仍 sq 最高)。
*/
export const fetchAudioLink = async(
songID: string,
songType: string,
register?: (request: any) => void,
preferLow = false,
): Promise<AudioHit> => {
const url = `${MOBILE_API}/song/getSongUrl?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}`
const { statusCode, body } = await fetch5singJson(url, { register })
if (statusCode != 200 || !body || typeof body !== 'object') {
throw new Error(`5sing 取流失败(HTTP ${statusCode})`)
}
if (Number(body.code) !== 1000) {
// 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
throw new Error(body.message ? `5sing 取流失败(${body.message})` : 'api returned error code')
}
const data = body.data ?? {}
const order = preferLow ? [...QUALITY_TIERS].reverse() : QUALITY_TIERS
for (const tier of order) {
const hit = tierUrl(data, tier)
if (hit && /^https?:\/\//i.test(hit)) return { type: tier, url: hit }
}
throw new Error('no valid download url found')
}
/** 上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象) */
export const fetchNewGet = async(
songID: string,
songType: string,
register?: (request: any) => void,
): Promise<any> => {
const url = `${MOBILE_API}/song/newget?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}`
const { statusCode, body } = await fetch5singJson(url, { register })
if (statusCode != 200 || !body || typeof body !== 'object') {
throw new Error(`5sing 歌曲信息获取失败(HTTP ${statusCode})`)
}
return body.data ?? {}
}
/** 上游 newget 歌词:data.dynamicWords(LRC 文本) */
export const fetchDynamicLyric = async(
songID: string,
songType: string,
register?: (request: any) => void,
): Promise<string> => {
const data = await fetchNewGet(songID, songType, register)
return String(data?.dynamicWords ?? '')
}
/**
* 上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。
* 取流失败即抛错(与上游 Parse 链路一致);newget 失败时保留兜底名 5sing_<type>_<id>。
*/
export const fetchSongInfo = async(songID: string, songType: string, register?: (request: any) => void) => {
const hit = await fetchAudioLink(songID, songType, register)
let name = ''
let artist = ''
let cover = ''
try {
const data = await fetchNewGet(songID, songType, register)
name = String(data?.SN ?? '')
artist = String(data?.user?.NN ?? '')
cover = String(data?.user?.I ?? '')
} catch {
// 上游 newget 失败时继续(metaBody 为 nil 不阻断)
}
if (!name) name = `5sing_${songType}_${songID}`
return {
songmid: `${songID}|${songType}`,
name,
singer: artist,
albumId: '',
albumName: '',
interval: 0,
img: cover || null,
lrc: null,
types: [],
_types: {},
url: hit.url,
type: hit.type,
link: buildSongLink(songType, songID),
songType,
typeName: kindName(songType),
source: '5sing' as const,
}
}
/** 时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式) */
export const songInterval = (songSize: number): number => estimateDuration(songSize)
+56
View File
@@ -0,0 +1,56 @@
// 5sing 歌曲搜索(上游 fivesing/song.go 的 Search 移植)
// 接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
// 返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
// 内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;上游以 typeEname 入 ID/Extra,
// 本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
// 上游 Search() 无 page 参数(硬编码 page=1);本移植按契约透传 page(实测 page 参数有效,pageInfo 可用于翻页)。
// 时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
import { formatPlayTime } from '../common/utils.ts'
import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts'
/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing') */
export const mapSearchSong = (item: any) => {
const songType = String(item?.typeEname ?? '')
const duration = estimateDuration(Number(item?.songSize) || 0)
return {
songmid: `${item?.songId ?? ''}|${songType}`,
name: removeEmTags(unescapeHtml(item?.songName)),
singer: removeEmTags(unescapeHtml(item?.singer ?? item?.nickName)),
albumId: '',
albumName: '',
interval: duration > 0 ? formatPlayTime(duration) : 0,
img: null,
lrc: null,
types: [],
_types: {},
// 统一种类字段(上游 typeEname / typeName)
songType,
typeName: String(item?.typeName ?? kindName(songType)),
size: Number(item?.songSize) || 0,
link: buildSongLink(songType, item?.songId),
source: '5sing' as const,
}
}
export const search = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5sing' as const }
if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0`
const { statusCode, body } = await fetch5singJson(url)
if (statusCode != 200) throw new Error(`5sing 搜索失败(HTTP ${statusCode})`)
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
const list = rawList.slice(0, pageSize).map(mapSearchSong)
const pageInfo = body?.pageInfo ?? {}
const total = Number(pageInfo.totalCount) || list.length
// 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo)
return { list, allPage, total, limit: pageSize, source: '5sing' as const }
}
export default { search }
+18
View File
@@ -0,0 +1,18 @@
// 5sing 链接解析(上游 fivesing/song.go 的 Parse + playlist.go 的 ParsePlaylist 移植)
// 歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetchSongInfo(直链 + newget 元数据,与上游一致:无直链即报错)
// 歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId,见 util.extractPlaylistLink)
// 专辑: 上游无专辑概念(不实现)
import { extractSongLink } from './util.ts'
import { fetchSongInfo } from './musicInfo.ts'
import { parsePlaylistLink } from './playlist.ts'
export const parseSong = async(link: string) => {
const parsed = extractSongLink(link)
if (!parsed) throw new Error('invalid 5sing link')
return fetchSongInfo(parsed.songID, parsed.songType)
}
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parsePlaylist }
+251
View File
@@ -0,0 +1,251 @@
// 5sing 歌单(上游 fivesing/fivesing.go + playlist.go 移植)
// 搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/pictureUrl/playCount/userName/songCnt/content/userId
// 上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植用同样回退、并发 5。
// 详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
// ② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块(上游 parseSongsFromHTML)
// ③ 数字 userId 页会 302 到个性域名页(如 /yeluoluo/dj/...),httpFetch 不跟跳 → util.fetch5sing 手动跟跳。
// 分类/个人歌单:上游显式返回不支持(model.ErrPlaylistCategoriesUnsupported / ErrUserPlaylistsUnsupported),本移植保留同样语义(抛错)。
// 链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名(/name/dj/xxx.html)与省略 user 段,ID 以 API 反查为准。
import { MOBILE_API, SEARCH_API, fetch5sing, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, buildPlaylistLink, extractPlaylistLink, kindName } from './util.ts'
/** 上游 GetPlaylistCategories / GetCategoryPlaylists:显式不支持 */
export const getCategories = async() => {
throw new Error('playlist categories not supported')
}
export const getCategoryPlaylists = async(_categoryId?: any, _page = 1, _limit = 30) => {
throw new Error('playlist categories not supported')
}
/** 歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状 */
const mapPlaylistSong = (songID: string, kind: string, name: string, artist: string) => ({
songmid: `${songID}|${kind}`,
name,
singer: artist,
albumId: '',
albumName: '',
interval: 0,
img: null,
lrc: null,
types: [],
_types: {},
songType: kind,
typeName: kindName(kind),
link: buildSongLink(kind, songID),
source: '5sing' as const,
})
/**
* 上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
* 块正则 `<li class="p_rel">([\s\S]*?)</li>`;块内 href 提取 yc/fc/bz 与 id,s_soner 提取歌手(上游的拼写错误 soner 保留)。
*/
export const parseSongsFromHTML = (htmlContent: string) => {
const blocks = [...String(htmlContent ?? '').matchAll(/<li class="p_rel">([\s\S]*?)<\/li>/g)]
if (!blocks.length) throw new Error('no songs found in playlist html (structure mismatch)')
// 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
const songRe = /href="(?:(?:https?:)?\/\/)?5sing\.kugou\.com\/(yc|fc|bz)\/(\d+)\.html"[^>]*>([^<]+)<\/a>/
const artistRe = /class="s_soner[^"]*".*?>([^<]+)<\/a>/
const songs: any[] = []
const seen = new Set<string>()
for (const match of blocks) {
const blockHTML = match[1]
const songMatch = songRe.exec(blockHTML)
if (!songMatch) continue
const kind = songMatch[1]
const songID = songMatch[2]
const rawName = songMatch[3]
let artist = 'Unknown'
const artistMatch = artistRe.exec(blockHTML)
if (artistMatch) artist = artistMatch[1]
const uniqueKey = `${kind}|${songID}`
if (seen.has(uniqueKey)) continue
seen.add(uniqueKey)
const name = removeEmTags(unescapeHtml(rawName)).trim()
artist = removeEmTags(unescapeHtml(artist)).trim()
songs.push(mapPlaylistSong(songID, kind, name, artist))
}
return songs
}
/** 上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名(data 必须是对象) */
export const fetchCreatorName = async(playlistId: string): Promise<string> => {
if (!playlistId) return ''
try {
const { statusCode, body } = await fetch5singJson(`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=user`)
if (statusCode != 200 || !body || typeof body !== 'object') return ''
const data = body.data
if (!data || typeof data !== 'object' || Array.isArray(data)) return ''
return String(data?.user?.NN ?? '')
} catch {
return ''
}
}
/** 歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐) */
const mapPlaylistSummary = (item: any) => {
const id = String(item?.songListId ?? '')
const userId = String(item?.userId ?? '')
const desc = removeEmTags(unescapeHtml(item?.content))
const creator = String(item?.userName ?? '') || (userId ? `ID: ${userId}` : '')
return {
id,
name: removeEmTags(unescapeHtml(item?.title)),
cover: String(item?.pictureUrl ?? ''),
trackCount: Number(item?.songCnt) || 0,
playCount: Number(item?.playCount) || 0,
creator,
creatorId: userId,
// 上游怪癖:content 为字符串 "0" 时视为空简介
description: desc === '0' ? '' : desc,
link: buildPlaylistLink(userId, id),
source: '5sing' as const,
}
}
/** 上游 SearchPlaylist(type=1),补创建者名(并发 5) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const }
if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1`
const { statusCode, body } = await fetch5singJson(url)
if (statusCode != 200) throw new Error(`5sing 歌单搜索失败(HTTP ${statusCode})`)
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
const list = rawList.slice(0, pageSize).map(mapPlaylistSummary)
// 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
const targets = list.filter((pl) => pl.creatorId && pl.creator === `ID: ${pl.creatorId}`)
for (let i = 0; i < targets.length; i += 5) {
await Promise.all(targets.slice(i, i + 5).map(async(pl) => {
const name = await fetchCreatorName(pl.id)
if (name) pl.creator = name
}))
}
const total = Number(body?.pageInfo?.totalCount) || list.length
return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const }
}
/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */
export const fetchPlaylistAll = async(playlistIdRaw: string) => {
const playlistId = String(playlistIdRaw ?? '').trim()
if (!playlistId) throw new Error('invalid 5sing playlist id')
// 1) API 元数据(标题/封面/统计/UserID)
const { statusCode, body } = await fetch5singJson(
`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=ID,user`,
)
if (statusCode != 200) throw new Error(`fetch info failed: HTTP ${statusCode}`)
// 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
const data = body?.data
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('playlist info not found or invalid (api returned empty list)')
}
const userId = String(data?.user?.ID ?? '')
if (!userId) throw new Error('playlist user not found or invalid id')
const playlist = {
id: playlistId,
name: String(data?.T ?? ''),
cover: String(data?.P ?? ''),
trackCount: Number(data?.E) || 0,
playCount: Number(data?.H) || 0,
creator: String(data?.user?.NN ?? ''),
description: String(data?.C ?? ''),
link: buildPlaylistLink(userId, playlistId),
userId,
source: '5sing' as const,
}
// 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳)
const page = await fetch5sing(playlist.link)
if (page.statusCode != 200) throw new Error(`fetch html failed: HTTP ${page.statusCode}`)
// 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
let songs: any[] = []
try {
songs = parseSongsFromHTML(String(page.body ?? ''))
} catch {
songs = []
}
return { playlist, songs }
}
/** 歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const { playlist, songs } = await fetchPlaylistAll(id)
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const start = (pageNo - 1) * pageSize
const list = songs.slice(start, start + pageSize)
const total = songs.length
return {
source: '5sing' as const,
info: {
id: playlist.id,
name: playlist.name,
img: playlist.cover,
desc: playlist.description,
author: playlist.creator,
link: playlist.link,
trackCount: playlist.trackCount,
playCount: playlist.playCount,
},
list,
page: pageNo,
limit: pageSize,
total,
allPage: Math.ceil(total / pageSize) || 1,
}
}
/** 上游 GetPlaylistSongs:仅返回曲目列表(全量) */
export const getPlaylistSongs = async(id: string) => {
const { songs } = await fetchPlaylistAll(id)
return songs
}
/** 上游 ParsePlaylist:歌单链接 → 详情(全量曲目,匹配上游返回全部歌曲的行为) */
export const parsePlaylistLink = async(link: string) => {
const parsed = extractPlaylistLink(link)
if (!parsed) throw new Error('invalid 5sing playlist link')
const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId)
return {
source: '5sing' as const,
info: {
id: playlist.id,
name: playlist.name,
img: playlist.cover,
desc: playlist.description,
author: playlist.creator,
link: playlist.link,
trackCount: playlist.trackCount,
playCount: playlist.playCount,
},
list: songs,
page: 1,
limit: songs.length || 1,
total: songs.length,
allPage: 1,
}
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
parsePlaylistLink,
}
+188
View File
@@ -0,0 +1,188 @@
// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植)
// 平台 id 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
// 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
import { httpFetch } from '../common/request.ts'
import { getCredentials } from '../auth/store.ts'
export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
/** 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302) */
export const MOBILE_API = 'http://mobileapi.5sing.kugou.com'
/** 搜索接口(type=0 单曲 / type=1 歌单) */
export const SEARCH_API = 'http://search.5sing.kugou.com/home/json'
/** 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏) */
export const SONG_KINDS: Record<string, string> = {
yc: '原创',
fc: '翻唱',
bz: '伴奏',
}
/** 上游 getFirstValid:返回第一个非空字符串(空串视为无效) */
export const getFirstValid = (...urls: Array<string | null | undefined>): string => {
for (const u of urls) {
if (u) return String(u)
}
return ''
}
/** 上游 removeEmTags:去掉搜索结果里 <em class="keyword"> 高亮标签 */
export const removeEmTags = (s: any): string =>
String(s ?? '')
.replaceAll('<em class="keyword">', '')
.replaceAll('</em>', '')
.trim()
const HTML_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
copy: '©',
reg: '®',
hellip: '…',
mdash: '—',
ndash: '–',
}
/** Go html.UnescapeString 的等价实现(常用实体 + 数字实体) */
export const unescapeHtml = (s: any): string =>
String(s ?? '').replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => {
if (entity.startsWith('#x') || entity.startsWith('#X')) {
const code = Number.parseInt(entity.slice(2), 16)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
if (entity.startsWith('#')) {
const code = Number.parseInt(entity.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
return HTML_ENTITIES[entity.toLowerCase()] ?? match
})
/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */
export const get5singCookie = (): string => String((getCredentials('5sing') as { cookie?: string } | null)?.cookie ?? '')
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
export const getStatus = () => {
const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
}
/** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */
export const isForeignSource = (songInfo: any): boolean =>
Boolean(songInfo?.source) && songInfo.source !== '5sing'
/**
* 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现)
* httpFetch 不跟随 302:这里手动跟跳最多 3 次(5sing 老链接 http→https / 数字 userId→个性域名)。
*/
export const fetch5sing = async(
url: string,
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
): Promise<any> => {
const cookie = get5singCookie()
const headers = {
'User-Agent': UserAgent,
...(cookie ? { Cookie: cookie } : {}),
...(options.headers || {}),
}
let current = String(url)
let response: any = null
for (let hop = 0; hop <= 3; hop++) {
const request = httpFetch(current, { headers, timeout: options.timeout ?? 15000 })
options.register?.(request)
response = await request.promise
const status = Number(response?.statusCode) || 0
const location = response?.headers?.location
if (status >= 300 && status < 400 && location) {
try {
current = new URL(String(location), current).href
} catch {
return response
}
continue
}
return response
}
return response
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/**
* 5sing 手机端 API 有频率控制(实测连续请求会返回 { code: 13, message: "操作太频繁", data: [] }),
* 上游 Go 无重试;本移植对 JSON 接口做最多 2 次退避重试,保证搜索/取流/歌词/歌单在突发请求下稳定。
*/
export const fetch5singJson = async(
url: string,
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
): Promise<any> => {
let response: any = null
for (let attempt = 0; attempt < 3; attempt++) {
response = await fetch5sing(url, options)
const code = Number(response?.body?.code)
const message = String(response?.body?.message ?? '')
if ((code === 13 || message.includes('太频繁')) && attempt < 2) {
await sleep(800 * (attempt + 1))
continue
}
return response
}
return response
}
/** 歌曲页链接(上游 Link 拼法:http://5sing.kugou.com/{typeEname}/{songId}.html) */
export const buildSongLink = (songType: string, songId: string | number): string =>
songType && songId ? `http://5sing.kugou.com/${songType}/${songId}.html` : ''
/** 歌单页链接(上游 Link 拼法:http://5sing.kugou.com/{userId}/dj/{playlistId}.html) */
export const buildPlaylistLink = (userId: string | number, playlistId: string): string =>
userId && playlistId ? `http://5sing.kugou.com/${userId}/dj/${playlistId}.html` : ''
/**
* 解析歌曲的 <songid>|<songtype> 约定(上游 model.Song.ID: "123|yc" + Extra{songid,songtype})
* 兼容只给了 songType 字段(路由层只透传 songmid 时用 `|` 分隔)。
*/
export const extractSongId = (
songmid: any,
songTypeFallback?: any,
): { songID: string, songType: string } => {
const raw = String(songmid ?? '')
const [idPart = '', typePart = ''] = raw.split('|')
return {
songID: idPart || '',
songType: String(typePart || songTypeFallback || ''),
}
}
/** 上游 Parse 的正则:5sing.kugou.com/(\w+)/(\d+).html(专辑/歌手页不匹配) */
export const extractSongLink = (link: any): { songType: string, songID: string } | null => {
const match = /5sing\.kugou\.com\/(\w+)\/(\d+)\.html/.exec(String(link ?? ''))
if (!match) return null
return { songType: match[1], songID: match[2] }
}
/**
* 上游 ParsePlaylist 的正则增强:
* 原正则 `5sing\.kugou\.com/(?:(\d+)/)?dj/([a-zA-Z0-9]+)\.html` 只认数字 userId,
* 个性域名(如 /yeluoluo/dj/xxx.html)与省略 user 段的链接会漏;userId 本身不参与请求(API 反查更准),故放宽为任意单段。
*/
export const extractPlaylistLink = (link: any): { userId: string, playlistId: string } | null => {
const match = /5sing\.kugou\.com\/(?:([^/?#]+)\/)?dj\/([a-zA-Z0-9]+)\.html/.exec(String(link ?? ''))
if (!match) return null
return { userId: match[1] || '', playlistId: match[2] }
}
/** 上游 Search 的时长估算:songSize*8/320000(320kbps 假设;songSize 为 0 时返回 0) */
export const estimateDuration = (songSize: number): number => {
const size = Number(songSize) || 0
return size > 0 ? Math.trunc((size * 8) / 320000) : 0
}
/** 作品类型中文名(typeName 缺失时兜底) */
export const kindName = (songType: string): string => SONG_KINDS[songType] ?? ''
+53
View File
@@ -0,0 +1,53 @@
// 5sing 取流(上游 fivesing/download.go 的 GetDownloadURL + fivesing.go 的 fetchAudioLink 移植)
//
// 链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=<id>&songtype=<yc|fc|bz>
// → { code: 1000, data: { squrl/squrl_backup, hqurl/hqurl_backup, lqurl/lqurl_backup, sqsize/hqsize/lqsize } }
// 直链为 CDN 明文地址(wsaudiobssdlbig.kugou.com,https,无需 Referer/防盗链签名;实测 Range 206 可播)。
// 说明:上游 fivesing 包内没有 base64/加密参数逻辑(不存在 _sign/song_id 签名),取流就是上述明文 GET;
// 数字 userId 的网页链接 302 的问题只影响歌单页,与取流无关。
// 档位:上游固定 sq(最高,常为无损/高码)→ hq → lq,返回首个非空(含 _backup);
// 本移植默认沿用该顺序,只有显式请求 64k(或 type=lq)时才倒序优先 lq,返回实际命中档位。
// songmid 约定 "<songId>|<songType>";Parse 出来的歌曲带 url 时直接复用(上游 GetDownloadURL: if s.URL != "" return)。
import { fetchAudioLink } from '../5sing/musicInfo.ts'
import { extractSongId, isForeignSource } from '../5sing/util.ts'
/** 上游只有 sq/hq/lq 三档,实际命中档位以返回 type 为准(sq 常缺,多命中 hq) */
export const supportQualitys = ['sq', 'hq', 'lq']
/** 上游 GetDownloadURL 的等价实现(非取消句柄版):register 用于取消当前请求 */
export const getDownloadUrl = async(songInfo: any, type?: string, register?: (request: any) => void) => {
if (isForeignSource(songInfo)) throw new Error('source mismatch')
// 上游逻辑:Song.URL 已带直链(Parse 场景)直接复用
if (songInfo?.url) {
return { type: String(songInfo?.type || type || 'sq'), url: String(songInfo.url) }
}
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType)
if (!songID || !songType) {
throw new Error('invalid id structure: 5sing songmid 需为 "<songId>|<songType>"(songType 为 yc/fc/bz)')
}
const preferLow = type === '64k' || type === 'lq'
return fetchAudioLink(songID, songType, register, preferLow)
}
/**
* 统一取流接口:getMusicUrl(songInfo, type) → { promise, cancelHttp }
* promise resolve → { type: 'sq'|'hq'|'lq', url }(type 为实际命中档位,可能低于请求档位)
*/
export const getMusicUrl = (songInfo: any, type?: string) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
let current: any = null
requestObj.cancelHttp = () => {
current?.cancelHttp?.()
current = null
}
requestObj.promise = (async() => {
const hit = await getDownloadUrl(songInfo, type, (request) => { current = request })
return { type: hit.type, url: hit.url }
})()
return requestObj
}
export default { getMusicUrl, getDownloadUrl, supportQualitys }
+72
View File
@@ -0,0 +1,72 @@
// Apple Music 取流(上游 apple.go GetDownloadURL)
// ⚠️ Apple 只提供 **preview 试听片段**(audio-ssl.itunes.apple.com,AAC/m4a,约 30-90 秒,实测单文件 ~3MB),
// 完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密,
// 本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,并在注释/响应里明确标注,不假装是无损完整曲。
// 歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用,同上游 s.URL 逻辑)。
// 统一接口约定:apis('apple').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type:'preview', url, ext:'m4a', preview:true, songDuration, isrc }
// (songDuration 是歌曲完整时长,试听片段本身约 30-90 秒)
// type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐
// /sources 与 /url?quality 枚举,返回的实际档位以 type='preview' 为准。
import { appleAmpGetTracked, extractAppleID, getAppleConfig } from '../apple/util.ts'
export const supportQualitys = ['128k']
const isAudioURL = (value: any): boolean => /^https?:/i.test(String(value ?? ''))
/** 详情 → preview 结果(内部/测试用) */
export const previewFromAttributes = (attr: any) => {
const url = String(attr?.previews?.[0]?.url ?? '')
if (!url) {
throw new Error('apple music: no preview URL available (完整曲目受 DRM 保护,SDK 仅提供 30-90 秒试听片段)')
}
return {
type: 'preview',
url,
ext: 'm4a',
preview: true,
// 注意:这是歌曲完整时长(catalog durationInMillis),不是试听片段长度——试听片段约 30-90 秒
songDuration: Math.round(Number(attr?.durationInMillis ?? 0) / 1000),
isrc: attr?.isrc || '',
}
}
/** 按 song id 拉取 preview(上游 GetDownloadURL → Parse) */
export const getPreviewUrl = async(songId: string, tracker?: { current: any }) => {
const cfg = getAppleConfig()
const body: any = await appleAmpGetTracked(
`/v1/catalog/${cfg.storefront}/songs/${songId}`,
{ extend: 'extendedAssetUrls' },
tracker,
)
const data = body?.data ?? []
if (!data.length) throw new Error(`apple song not found: ${songId}`)
return previewFromAttributes(data[0]?.attributes)
}
export const getMusicUrl = (songInfo: any, _type?: string) => {
const requestObj: any = { promise: null as any, cancelHttp() {} }
const tracker: { current: any } = { current: null }
requestObj.cancelHttp = () => {
try {
tracker.current?.cancelHttp?.()
} catch {
// ignore
}
tracker.current = null
}
requestObj.promise = (async() => {
// 上游 GetDownloadURL:Song.URL 已有直链时直接复用(搜索结果里的 previewUrl / 链接解析结果)
if (isAudioURL(songInfo?.previewUrl)) return { type: 'preview', url: String(songInfo.previewUrl), ext: 'm4a', preview: true }
if (isAudioURL(songInfo?.url)) return { type: 'preview', url: String(songInfo.url), ext: 'm4a', preview: true }
const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
if (!songId) throw new Error('apple 歌曲 id 无效(需要数字 song id 或 Apple Music 歌曲链接)')
return getPreviewUrl(songId, tracker)
})()
return requestObj
}
export default { getMusicUrl, getPreviewUrl, previewFromAttributes, supportQualitys }
+73
View File
@@ -0,0 +1,73 @@
// 千千音乐 URL 获取(/v1/song/tracklink,签名通道)
// 档位:3000=flac / 320 / 128 / 64(aac)。实测(2026-10-02):
// - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL)
// - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链
// 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位)
import { apiOk, firstNonEmpty, trackLink } from '../bd/util.ts'
// 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
const QUALITY_RATES: Record<string, string[]> = {
'64k': ['64'],
'128k': ['128', '64'],
'320k': ['320', '128', '64'],
'flac': ['3000', '320', '128', '64'],
'flac24bit': ['3000', '320', '128', '64'],
}
const DEFAULT_RATES = ['3000', '320', '128', '64']
export const supportQualitys = ['64k', '128k', '320k', 'flac']
/** 命中档位判定:以响应 format/rate/bits 为准(可能低于请求档位) */
const typeOfHit = (requestRate: string, data: any): string => {
const format = String(data?.format ?? '').toLowerCase()
const rate = Number(data?.rate ?? 0)
const bits = Number(data?.bits ?? 0)
if (format === 'flac' || rate >= 1000) return bits === 24 ? 'flac24bit' : 'flac'
if (rate >= 320 || requestRate === '320') return '320k'
if (rate >= 128 || requestRate === '128') return '128k'
return '64k'
}
/** 逐档请求 tracklink,取首个带 path 的档位;register 用于取流对象取消当前请求 */
export const getDownloadUrl = async(tsid: string, type?: string, register?: (req: any) => void) => {
const id = String(tsid ?? '').trim()
if (!id) throw new Error('千千歌曲 id(TSID)为空')
const rates = QUALITY_RATES[String(type ?? '')] ?? DEFAULT_RATES
let lastErr: any = null
for (const rate of rates) {
try {
const body = await trackLink(id, rate, register)
if (!apiOk(body)) {
lastErr = new Error(body?.errmsg || `千千取流失败(errno ${body?.errno})`)
continue
}
const data = body?.data ?? {}
const url = firstNonEmpty(data?.path, data?.trail_audio_info?.path)
if (url) return { type: typeOfHit(rate, data), url }
lastErr = new Error('千千该歌曲无可用直链(VIP/版权曲目需登录态,免费曲目可直接取流)')
} catch (err) {
lastErr = err
}
}
throw lastErr ?? new Error('千千未找到可用音频直链')
}
export const getMusicUrl = (songInfo: any, type?: string) => {
const requestObj: any = { promise: null, cancelHttp() {} }
let current: any = null
requestObj.cancelHttp = () => {
current?.cancelHttp?.()
current = null
}
requestObj.promise = (async() => {
// 上游 GetDownloadURL:Song.URL 已有直链时直接复用(Parse 场景)
if (songInfo?.url) return { type: type || '128k', url: String(songInfo.url) }
const tsid = firstNonEmpty(songInfo?.songmid, songInfo?.TSID)
if (!tsid) throw new Error('千千歌曲 id(TSID)为空')
return getDownloadUrl(tsid, type, (req) => { current = req })
})()
return requestObj
}
export default { getMusicUrl, getDownloadUrl, supportQualitys }
+235
View File
@@ -0,0 +1,235 @@
// Bilibili 取流(上游 bilibili/bilibili.go 的 fetchAudioURL + account.go 的 IsVipAccount)
// 接口: https://api.bilibili.com/x/player/playurl?fnval=&qn=127&bvid=&cid=
// fnval=80 标准 DASH(匿名可用)
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
//
// CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
// 迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
// 正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
// cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
// 因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
// 逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
// 参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
import { httpFetch } from '../common/request.ts'
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
// 与 routes.ts 的 QUALITIES 枚举对齐(132k/192k 为 B 站实际档位,命中时按实际返回)
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
const QUALITY_LABEL: Record<number, string> = {
30216: '64k',
30232: '132k',
30280: '192k',
30250: 'flac',
30251: 'flac24bit',
}
// CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
const PROBE_HOP_TIMEOUT = 8000
const PROBE_TOTAL_BUDGET = 20000
const PROBE_MAX_CANDIDATES = 3
let vipCache: { value: boolean, at: number } | null = null
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
export const resetVipCache = () => {
vipCache = null
}
/** 上游 IsVipAccount:x/web-interface/nav 的 isLogin + vipStatus + vipType(进程内缓存 5 分钟) */
export const isVipAccount = async(cookie: string): Promise<boolean> => {
if (!cookie) return false
if (vipCache && Date.now() - vipCache.at < 5 * 60 * 1000) return vipCache.value
try {
const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', {
headers: biliHeaders(cookie),
timeout: 15000,
}).promise as any
const data = body?.data ?? {}
const vip = body?.code === 0 && !!data.isLogin && Number(data.vipStatus) === 1 && Number(data.vipType) > 0
vipCache = { value: vip, at: Date.now() }
return vip
} catch {
return false
}
}
/** CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / upos-*.akamaized.net,多地节点)
* 1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方) */
const hostRank = (url: string): number => {
let host = ''
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
if (host.endsWith('.akamaized.net')) return 0
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
return 2
}
/** 节点地址收集:baseUrl + backupUrl(去重) */
const nodeUrls = (node: any): string[] => {
const backups = [
...(Array.isArray(node?.backupUrl) ? node.backupUrl : []),
...(Array.isArray(node?.backup_url) ? node.backup_url : []),
]
return [node?.baseUrl, ...backups].filter(Boolean).map(String)
}
/** 候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先) */
const rankCandidates = (urls: string[]): string[] => {
const seen = new Set<string>()
const uniq: string[] = []
for (const url of urls) {
const u = String(url || '')
if (!u || seen.has(u)) continue
seen.add(u)
uniq.push(u)
}
return uniq
.map((u, i) => ({ u, i, rank: hostRank(u) }))
.sort((a, b) => a.rank - b.rank || a.i - b.i)
.map(x => x.u)
}
const pickAudio = (data: any, type: string): { id: number, label: string, urls: string[] } | null => {
const dash = data?.dash ?? {}
const flac = dash?.flac?.audio
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? flac : null
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
const standards: any[] = (dash?.audio ?? [])
.filter((a: any) => a?.baseUrl)
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
let node: any = null
let label = ''
if (type === '64k') {
const low = standards.find(a => Number(a.id) === 30216)
if (low) { node = low; label = QUALITY_LABEL[30216] }
}
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] }
if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] }
if (!node && standards.length) {
node = standards[0]
label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}`
}
if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) }
const durlNode = data?.durl?.[0]
if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) }
return null
}
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: string[] }> => {
const cookie = await getCookie()
const needFlac = type === 'flac' || type === 'flac24bit'
const vip = await isVipAccount(cookie)
const fnval = needFlac || vip ? 4048 : 80
const url = `https://api.bilibili.com/x/player/playurl?fnval=${fnval}&qn=127&bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(String(cid))}`
const { statusCode, body } = await httpFetch(url, { headers: biliHeaders(cookie), timeout: 15000 }).promise as any
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`)
if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
const picked = pickAudio(body.data, type)
if (!picked || !picked.urls.length) throw new Error('B站未返回可用音频流')
return { type: picked.label, urls: picked.urls }
}
/**
* 探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开 body。
* HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
* 会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。
* 命中 2xx/206 返回该地址;4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
*/
const resolveUrl = async(url: string, tracker: { current: any, cancelled?: boolean }, deadline: number): Promise<string> => {
let current = String(url)
for (let hop = 0; hop < 3; hop++) {
const remaining = deadline - Date.now()
if (remaining <= 0) throw new Error('CDN 探测超时(总预算耗尽)')
const controller = new AbortController()
tracker.current = controller
const timer = setTimeout(() => controller.abort(new Error('CDN 探测超时')), Math.min(PROBE_HOP_TIMEOUT, remaining))
timer.unref?.()
let resp: Response
try {
resp = await fetch(current, {
method: 'get',
headers: { ...biliHeaders(), Range: 'bytes=0-0' },
redirect: 'manual',
signal: controller.signal,
})
} catch (err: any) {
if (tracker.cancelled) { err.canceled = true; throw err }
throw err
} finally {
clearTimeout(timer)
}
// 拿到响应头即断开,不消费 body
await resp.body?.cancel().catch(() => {})
const status = resp.status
const location = resp.headers.get('location')
if (status >= 300 && status < 400 && location) {
try {
current = new URL(location, current).href
continue
} catch {
return current
}
}
if (status >= 200 && status < 300) return current
if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`)
return current
}
return current
}
/**
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
* CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
*/
export const getMusicUrl = (songInfo: any, type: string) => {
const tracker: { current: any, cancelled?: boolean } = { current: null }
const requestObj: any = {
cancelHttp() {
tracker.cancelled = true
try {
tracker.current?.abort?.(new Error('Canceled'))
} catch {
// ignore
}
},
promise: null as any,
}
requestObj.promise = (async() => {
const { bvid, cid } = parseSongId(songInfo?.songmid ?? songInfo?.id)
if (!bvid) throw new Error('bili songmid 需为 <bvid>|<cid> 格式')
let realCid = cid
if (!realCid) {
const view = await fetchView(bvid)
realCid = String(view?.pages?.[0]?.cid ?? '')
}
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
const info = await getAudioUrl(bvid, realCid, type)
const deadline = Date.now() + PROBE_TOTAL_BUDGET
for (const candidate of info.urls.slice(0, PROBE_MAX_CANDIDATES)) {
try {
const finalUrl = await resolveUrl(candidate, tracker, deadline)
return { type: info.type, url: finalUrl }
} catch (err: any) {
if (err?.canceled || tracker.cancelled) throw new Error('canceled')
if (Date.now() >= deadline) break
}
}
// 探测全败(或预算耗尽):回退分级排序后的最优候选
return { type: info.type, url: info.urls[0] }
})()
return requestObj
}
export default { getMusicUrl }
+49
View File
@@ -0,0 +1,49 @@
// JOOX URL 获取(新版 web 播放器通道 openjoox2,含 md5(secretAPIKey) 签名)
// 链路:GET cache.api.joox.com/openjoox2/v1/track/<songid>?...&secret=<md5>
// → play_url_list[0](无则 refrain_url)→ 带 vkey/hdnts 的 CDN 直链(ak-hk.stream.music.joox.com 等)
// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10-02 实测),
// 故取流改用官网当前使用的 openjoox2 通道(签名细节见 sdk/joox/musicInfo.ts)。
// 地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0,登录 cookie 的
// wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
// 实测:游客态返回 128k MP3(免费完整曲目)或 VIP 曲目的 preview/refrain 片段;
// 未登录请求 VIP 曲目时 play_url_list 为试听片段——与上游行为一致(上游同样只能拿游客流)。
// 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { httpFetch } from '../common/request.ts'
import { jooxHeaders, normalizeJooxID } from '../joox/util.ts'
import { buildTrackUrl } from '../joox/musicInfo.ts'
/** 游客通道实测仅 128k MP3;VIP 账号理论上更高,但需登录后验证,故不虚报 */
export const supportQualitys = ['128k']
export const getMusicUrl = (songInfo: any, _type?: string) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
let current: any = null
requestObj.cancelHttp = () => {
if (current?.cancelHttp) current.cancelHttp()
}
requestObj.promise = (async() => {
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '')
if (!songId) throw new Error('joox 歌曲 id 无效')
current = httpFetch(buildTrackUrl(songId, { fullSong: true }), { headers: jooxHeaders(), timeout: 20000 })
const { statusCode, body } = await current.promise as any
if (statusCode != 200 || !body || typeof body !== 'object' || body.code) {
throw new Error(`joox 取流失败:${body?.msg || body?.message || `HTTP ${statusCode}`}`)
}
const urls: any[] = Array.isArray(body.play_url_list) ? body.play_url_list : []
const clean = (value: any) => String(value ?? '').replace(/&amp;/g, '&')
let url = urls.map(clean).find(item => item.startsWith('http')) || ''
if (!url && clean(body.refrain_url).startsWith('http')) url = clean(body.refrain_url)
if (!url) throw new Error('无可用播放地址(VIP/版权限制或地区不可用)')
return { type: '128k', url }
})()
return requestObj
}
export default { getMusicUrl }
+99
View File
@@ -0,0 +1,99 @@
// 酷狗音乐 URL 获取(手机客户端通道)
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
// 成功响应(status:1)播放地址在顶层 url: string[] 与 backupUrl: string[](2026-10 实测;
// 旧 data:[{url}] 结构仅作兼容兜底)。album_audio_id 传 NaN 会被网关拒答,必须归零。
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts'
import { getKgAuth } from '../auth/store.ts'
import { androidSign, getDevice } from '../auth/kg.ts'
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
const APPID = 1005
const CLIENTVER = 11430
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = Object.keys(qualityMap)
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const hash = (songInfo._types?.[type]?.hash || songInfo.hash || '').toLowerCase()
if (!hash) {
requestObj.promise = Promise.reject(new Error('缺少 hash'))
return requestObj
}
requestObj.cancelHttp = () => {} // 单次 GET,无需取消
requestObj.promise = (async() => {
const dev = getDevice()
const auth = getKgAuth()
const userid = Number(auth?.userid || 0)
const params: Record<string, any> = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
album_id: num(songInfo.albumId),
area_code: 1,
hash,
ssa_flag: 'is_fromtrack',
version: CLIENTVER,
page_id: 151369488,
quality: qualityMap[type] ?? '128',
album_audio_id: num(songInfo.songmid),
behavior: 'play',
pid: 2,
cmd: 26,
pidversion: 3001,
IsFreePart: 0,
ppage_id: '463467626,350369493,788954147',
cdnBackup: 1,
module: '',
}
if (auth) {
params.token = auth.token
params.userid = userid
}
params.key = md5(`${hash}${SALT_KEY}${APPID}${dev.mid}${userid}`)
params.signature = androidSign(params)
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://gateway.kugou.com/v5/url?${qs}`, {
method: 'get',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'x-router': 'trackercdn.kugou.com',
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
timeout: 15000,
}).promise
const body = r.body && typeof r.body === 'object' ? r.body : {}
if (body.status !== 1) {
throw new Error(body.error || (auth ? `获取失败(status ${body.status ?? '未知'})` : '获取失败(该资源需要酷狗登录)'))
}
let url = ''
if (Array.isArray(body.url)) url = body.url[0] || ''
else if (Array.isArray(body.backupUrl)) url = body.backupUrl[0] || ''
else if (Array.isArray(body.data)) {
// 旧版 data:[{url, fallback}] 结构兜底
const item = body.data.find(d => d.url) ?? body.data[0]
url = item?.url || item?.fallback || ''
}
if (!url) throw new Error('获取失败(网关未返回播放地址)')
return { type, url }
})()
return requestObj
}
export default { getMusicUrl }
+182
View File
@@ -0,0 +1,182 @@
// 酷我音乐 URL 获取(直连)
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际拿到的音质,请求档位缺失时沿降级链回退)
//
// 取流通道(按优先级):
// 1) http://mobi.kuwo.cn/mobi.s convert_url_with_sign(主通道)
// source=kwplayer_ar_8.5.5.0_apk_keluze.apk 指纹 + user=0 匿名即可。
// 2026-10-01 实测(含整段 MPEG 帧逐帧校验,非仅看开头):
// - 付费/VIP 歌匿名放行真音频:晴天(rid 228908) 拿到 2000k flac 52.83MB(269.7s,fLaC
// STREAMINFO 校验)与 320k mp3 10.29MB(10329 帧全程 320kbps/44100Hz 对齐),
// 均与搜索接口 N_MINFO 元数据的官方大小逐字节吻合;
// - hi-res 也可用:起风了(rid 82239887) br=4000kflac → 88.2kHz flac 109.12MB;
// - 请求档位不存在时会静默降级(2000kflac→48k aac、4000kflac→128k mp3)甚至升级到
// mgg/mflac 加密格式,因此必须严格校验「返回 bitrate == 请求档位 且 格式为明文音频」,
// 不匹配视为未命中、沿降级链换档重试;
// - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合,
// 本组合已验证为真音频。
// - 返回 url 的 query 用 `$` 顶替 `=`,统一还原后对外返回(见 normalizeUrl)。
// 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
// 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发),
// 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法,
// 参考 qyhqiu/kuwoMusicApi 的 secret.js)。token 会话级缓存,失效自动刷新重试一次。
// 限制:服务器无视 br 参数,匿名恒返回 128k MP3;VIP 歌明确报「该歌曲为付费内容」,
// 不吐假音频(作为 mobi 通道网络故障时的兜底)。
// - 写死 token(含旧 kw_token cookie)必报 "The request is illegal!",token 必须动态获取。
import { randomUUID } from 'crypto'
import { httpFetch } from '../common/request.ts'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const MOBI_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36'
// 音质 → mobi.s 请求档位(bitrate 为响应必须精确命中的数值)
const qualityTiers: Record<string, { br: string, bitrate: number }> = {
'128k': { br: '128kmp3', bitrate: 128 },
'320k': { br: '320kmp3', bitrate: 320 },
'flac': { br: '2000kflac', bitrate: 2000 },
'flac24bit': { br: '4000kflac', bitrate: 4000 },
}
// 档位缺失时的降级链(请求档位 → 逐级尝试)
const fallbackChain: Record<string, string[]> = {
'flac24bit': ['flac', '320k', '128k'],
'flac': ['320k', '128k'],
'320k': ['128k'],
'128k': [],
}
// 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播
const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a'])
// 酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`,实为酷我客户端
// 自有写法,签名 token 在 path 里、query 无 sign 参数),`$` 非标准分隔符,播放器与 URL
// 解析器普遍不认,统一还原成 `=` 再对外返回
const normalizeUrl = (url: string) => url.replace(/\$/g, '=')
// 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit']
/**
* mobi.s convert_url_with_sign 取流
* 未命中(无 url / bitrate 不匹配 / 加密格式)resolve null,由调用方沿降级链换档
*/
const getUrlByMobi = (songmid: string, tier: { br: string, bitrate: number }): Promise<{ url: string } | null> => {
const requestObj = httpFetch(`http://mobi.kuwo.cn/mobi.s?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk&type=convert_url_with_sign&br=${tier.br}&rid=${songmid}`, {
method: 'get',
headers: {
'User-Agent': MOBI_UA,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
timeout: 15000,
})
return requestObj.promise.then(({ statusCode, body }) => {
const data = body?.data
if (statusCode != 200 || !data?.url) return null
if (Number(data.bitrate) != tier.bitrate || !plainFormats.has(String(data.format))) return null
return { url: normalizeUrl(data.url) }
}).catch(() => null)
}
interface KwToken { name: string, value: string, secret: string }
let token: KwToken | null = null
let tokenPromise: Promise<KwToken> | null = null
/**
* 酷我 web 端 Secret 头哈希:t 为消息(cookie 值),e 为密钥(cookie 名)
*/
const kwSecret = (t: string, e: string): string => {
let digits = ''
for (let i = 0; i < e.length; i++) digits += e.charCodeAt(i).toString()
const r = Math.floor(digits.length / 5)
const o = parseInt(digits.charAt(r) + digits.charAt(2 * r) + digits.charAt(3 * r) + digits.charAt(4 * r) + digits.charAt(5 * r))
const l = Math.ceil(e.length / 2)
const c = 2 ** 31 - 1
if (o < 2) throw new Error('酷我 Secret 哈希失败')
let d = Math.round(1e9 * Math.random()) % 1e8
digits += d
while (digits.length > 10) digits = (parseInt(digits.substring(0, 10)) + parseInt(digits.substring(10, digits.length))).toString()
let n = (o * parseInt(digits) + l) % c
let out = ''
for (let i = 0; i < t.length; i++) {
const x = t.charCodeAt(i) ^ Math.floor(n / c * 255)
out += x < 16 ? `0${x.toString(16)}` : x.toString(16)
n = (o * n + l) % c
}
let hex = d.toString(16)
while (hex.length < 8) hex = `0${hex}`
return out + hex
}
/**
* 获取会话 token(访问首页,解析动态下发的 Hm_Iuvt_* cookie 名与值)
*/
const getToken = (retryNum = 0): Promise<KwToken> => {
if (token) return Promise.resolve(token)
if (tokenPromise) return tokenPromise
tokenPromise = httpFetch('https://www.kuwo.cn/', {
method: 'get',
headers: { 'User-Agent': UA },
timeout: 15000,
}).promise.then(({ headers }) => {
tokenPromise = null
const cookies = ((headers['set-cookie'] as string[]) || []).map(c => {
const i = c.indexOf('=')
return { name: c.slice(0, i).trim(), value: i >= 0 ? c.slice(i + 1).split(';')[0] : '' }
}).filter(c => /^Hm_Iuvt_\w+$/.test(c.name) && c.value)
if (!cookies.length) throw new Error('获取酷我token失败')
token = { name: cookies[0].name, value: cookies[0].value, secret: kwSecret(cookies[0].value, cookies[0].name) }
return token
}).catch(err => {
tokenPromise = null
if (retryNum > 2) throw err
return getToken(++retryNum)
})
return tokenPromise
}
/**
* 通过官方 playUrl 接口获取(需 Secret 签名;匿名恒 128k)
* @param t 本次请求使用的 token(显式传入,避免与并发刷新竞态)
*/
const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => {
const requestObj = { cancelHttp() {}, promise: null as any }
requestObj.promise = httpFetch(`https://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId=${randomUUID()}`, {
method: 'get',
headers: {
Cookie: `${t.name}=${t.value}`,
Secret: t.secret,
Referer: 'https://www.kuwo.cn/',
'User-Agent': UA,
},
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
const msg: string = body?.msg || body?.message || ''
if (statusCode != 200 || !body?.success || !body.data?.url) {
// 签名/token 失效:刷新后重试一次(VIP 拒绝等其他错误不重试)
if (retryNum == 0 && /illegal/i.test(msg)) {
token = null
return getToken().then(fresh => getUrlByPlayUrl(songmid, fresh, ++retryNum).promise)
}
return Promise.reject(new Error(msg || '获取失败'))
}
// 服务器无视 br,匿名实际只回 128k,如实上报
return { type: '128k', url: normalizeUrl(body.data.url) }
})
return requestObj
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const start = qualityTiers[type] ? type : '128k'
const chain = [start, ...fallbackChain[start]]
// 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
requestObj.promise = chain.reduce(
(p, tierType) => p.then(res => res ?? getUrlByMobi(songInfo.songmid, qualityTiers[tierType]).then(hit => hit && { type: tierType, url: hit.url })),
Promise.resolve(null as any),
).then(res => res ?? getToken().then(t => getUrlByPlayUrl(songInfo.songmid, t).promise))
return requestObj
}
export default { getMusicUrl }
+41
View File
@@ -0,0 +1,41 @@
// 懒人听书 URL 获取(web 通道 getPlayPath,需登录)
// 接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1&sections=[<section>]&type=0
// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
// 成功响应 list[0].path 为音频直链。
// songmid 为复合 id "<bookId>:<section>"(/album/lr 章节列表产出);纯 bookId 视为第一章
import { httpFetch } from '../common/request.ts'
import { getLrCookie } from '../lr/auth.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const PLAY_URL = 'https://m.lrts.me/ajax/getPlayPath'
export const supportQualitys = ['64k', '128k']
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
const [bookId, section = '1'] = songmid.split(':')
requestObj.promise = (async() => {
if (!bookId || !/^\d+$/.test(bookId)) throw new Error('懒人 id 需为 <bookId>[:<section>](见 /album/lr 章节列表)')
const cookie = getLrCookie()
if (!cookie) throw new Error('懒人听书取流需要登录(POST /login/lr 绑定账号密码)')
const { statusCode, body } = await httpFetch(
`${PLAY_URL}?entityId=${bookId}&entityType=3&opType=1&sections=[${section}]&type=0`,
{ headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/', Cookie: cookie }, timeout: 15000 },
).promise as any
const b = body ?? {}
if (statusCode != 200) throw new Error(`取流失败(HTTP ${statusCode})`)
const path = b?.list?.[0]?.path
if (!path) {
if (b.status === 2) throw new Error(b.msg || '收费章节未购买(需账号已购/会员)')
if (String(b.msg || '').includes('登录')) throw new Error('登录态已失效,请重新 POST /login/lr')
throw new Error(b.msg || '取流失败')
}
return { type: type === '128k' ? '128k' : '64k', url: String(path) }
})()
return requestObj
}
export default { getMusicUrl }
+68
View File
@@ -0,0 +1,68 @@
// 猫耳FM URL 获取(App API 通道)
// 链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
// soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
// soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
// 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
// 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带
// songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出);
// "d:<drama_id>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)
// 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { getSoundInfo } from '../me/musicInfo.ts'
// 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
const QUALITY_FIELDS: Record<string, string[]> = {
'128k': ['soundurl_128', 'soundurl', 'soundurl_64', 'soundurl_32'],
'64k': ['soundurl_64', 'soundurl', 'soundurl_128', 'soundurl_32'],
}
export const supportQualitys = ['64k', '128k']
const pickUrl = (info: any, type: string) => {
const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k']
let url = ''
for (const field of fields) {
const v = info?.[field]
if (v && String(v).startsWith('http')) { url = String(v); break }
}
if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)')
const hit128 = url.includes('-128k') || type === '128k'
return { type: hit128 ? '128k' : '64k', url }
}
/** 剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[]) */
const resolveDramaFirstFree = async(dramaId: string): Promise<string> => {
const { httpFetch } = await import('../common/request.ts')
const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/getdrama?drama_id=${dramaId}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
Accept: 'application/json',
Referer: `https://www.missevan.com/mdrama/${dramaId}`,
},
timeout: 15000,
}).promise as any
const episodes = (statusCode == 200 ? body?.info?.episodes?.episode : null) ?? []
if (!episodes.length) throw new Error('剧集列表为空或已下线')
const first = episodes.find((e: any) => Number(e.pay_type) === 0) ?? episodes[0]
return String(first.sound_id)
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
requestObj.promise = (async() => {
let soundId = songmid
if (songmid.startsWith('d:')) {
if (!/^\d+$/.test(songmid.slice(2))) throw new Error('剧 id 无效')
soundId = await resolveDramaFirstFree(songmid.slice(2))
} else if (!/^\d+$/.test(soundId)) {
throw new Error('me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)')
}
const info = await getSoundInfo(soundId)
return pickUrl(info, type)
})()
return requestObj
}
export default { getMusicUrl }
+357
View File
@@ -0,0 +1,357 @@
// 咪咕取流 —— listen-url 多档位候选链 + 逐档探测(Range 0-63 验音频魔数/解密验证),
// 命中可访问的最高档位;匿名仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie。
// 上游对应:music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
// selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)
import { httpFetch } from '../common/request.ts'
import {
AndroidChannel, AndroidUA, AndroidVersion, decryptZ3D, firstNonEmpty,
getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem,
} from '../mg/util.ts'
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
/** 平台档位 → 咪咕 toneFlag(取流请求用) */
const QUALITY_TONE: Record<string, string> = {
'64k': 'LQ',
'128k': 'PQ',
'320k': 'HQ',
flac: 'SQ',
flac24bit: 'ZQ24',
wav: 'ZQ32',
}
const TONE_EXT: Record<string, string> = {
LQ: 'mp3', PQ: 'mp3', HQ: 'mp3', SQ: 'flac', ZQ: 'flac', ZQ24: 'flac',
ZQ32: 'wav', Z3D: 'wav', I3D: 'm4a', '3D60': 'wav',
}
/** 可重写的 tone 路径(上游 miguTonePaths) */
const TONE_PATHS: Record<string, string> = {
LQ: '全曲试听/Mp3_64_22_16',
PQ: '标清高清/MP3_128_16_Stero',
HQ: '标清高清/MP3_320_16_Stero',
SQ: '歌曲下载/flac',
ZQ: '歌曲下载/flac_24bit',
ZQ24: '歌曲下载/flac_24bit',
ZQ32: '歌曲下载/wav_32bit',
}
const TONE_PREFERENCE = ['ZQ32', 'ZQ24', 'ZQ', 'SQ', 'HQ', 'Z3D', 'I3D', '3D60', 'PQ', 'LQ']
const normalizeTone = (value?: string): string => {
const v = String(value ?? '').trim().replace(/^\./, '').toUpperCase()
return TONE_EXT[v] ? v : ''
}
/** songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c` */
export const miguSongParts = (songInfo: any): { contentId: string, resourceType: string, formatType: string } => {
const extra = songInfo?.extra ?? {}
let contentId = String(extra.content_id ?? '').trim()
let resourceType = String(extra.resource_type ?? '').trim()
let formatType = String(extra.format_type ?? '').trim()
if (contentId && resourceType && formatType) return { contentId, resourceType, formatType }
const parts = String(songInfo?.songmid ?? songInfo?.id ?? '').split('|')
if (parts.length === 3) {
return { contentId: parts[0]!.trim(), resourceType: parts[1]!.trim(), formatType: parts[2]!.trim() }
}
if (!contentId) contentId = String(songInfo?.songmid ?? songInfo?.id ?? '').trim()
return { contentId, resourceType, formatType }
}
interface ListenResponse {
code?: string
info?: string
data?: {
url?: string
formatType?: string
dialogInfo?: { text?: string }
songItem?: MiguSongItem
}
}
/** listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误 */
const fetchListenInfo = async(
contentId: string, copyrightId: string, songId: string, albumId: string, resourceType: string, toneFlag: string,
): Promise<ListenResponse> => {
const params: Record<string, string> = {
netType: '01',
resourceType: firstNonEmpty(resourceType, '2'),
contentId: contentId.trim(),
toneFlag: firstNonEmpty(toneFlag, 'PQ'),
}
if (copyrightId.trim()) params.copyrightId = copyrightId.trim()
if (songId.trim()) params.songId = songId.trim()
if (albumId.trim()) params.albumId = albumId.trim()
const cookie = getMiguCookie()
const headers: Record<string, string> = {
'User-Agent': AndroidUA,
channel: AndroidChannel,
version: AndroidVersion,
Referer: 'https://music.migu.cn/',
}
if (cookie) headers.Cookie = cookie
const { statusCode, body } = await httpFetch(`https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url?${new URLSearchParams(params)}`, {
headers,
timeout: 30000,
}).promise
if (statusCode < 200 || statusCode >= 300) throw new Error(`migu listen endpoint returned status ${statusCode}`)
const result = body as ListenResponse
if (result?.code && result.code !== '000000') throw new Error(`migu api error: ${result.info} (code ${result.code})`)
return result
}
const normalizeDownloadURL = (rawURL?: string | null): string => {
let url = String(rawURL ?? '').trim()
if (!url) return ''
if (url.startsWith('//')) url = `https:${url}`
if (url.startsWith('ftp://218.200.160.122:21/')) {
url = `https://freetyst.nf.migu.cn/${url.slice('ftp://218.200.160.122:21/'.length)}`
}
return url
}
interface DownloadInfo {
url: string
format: string
ext: string
encrypted: boolean
fileKey: string
size: number
}
const directDownloadInfo = (rawURL: string, format: string, expectedSize: number): DownloadInfo | null => {
const url = normalizeDownloadURL(rawURL)
if (!url) return null
let ext = ''
try {
const path = new URL(url).pathname
ext = (path.split('.').pop() ?? '').toLowerCase()
if (!ext || ext.includes('/')) ext = ''
} catch { ext = '' }
let tone = normalizeTone(format)
if (!ext) ext = TONE_EXT[tone] ?? ''
if (!ext) ext = 'mp3'
if (!tone) {
// 按扩展名回填档位(上游 formatFromMiguExt)
switch (ext) {
case 'flac': tone = 'SQ'; break
case 'wav': tone = 'ZQ32'; break
case 'm4a': tone = 'I3D'; break
default: tone = 'PQ'
}
}
return { url, format: tone, ext, encrypted: false, fileKey: '', size: expectedSize }
}
/** z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse */
const embeddedDownloadInfo = (resp: ListenResponse, targetTone: string, expectedSize: number): DownloadInfo | null => {
const code = resp?.data?.songItem?.z3dCode
if (!code) return null
const parseSize = (v?: string) => {
const n = parseInt(String(v ?? '').trim(), 10)
return Number.isFinite(n) && n > 0 ? n : 0
}
switch (targetTone) {
case 'Z3D': {
const url = normalizeDownloadURL(firstNonEmpty(code.androidUrl, code.url))
const fileKey = String(code.androidFileKey ?? '').trim()
if (!url || !fileKey) return null
return {
url, format: 'Z3D',
ext: firstNonEmpty(String(code.androidFileType ?? '').replace(/^\./, ''), 'wav'),
encrypted: true, fileKey,
size: parseSize(code.androidSize) || expectedSize,
}
}
case 'I3D': {
const url = normalizeDownloadURL(firstNonEmpty(code.iosUrl, code.url))
const fileKey = String(code.iosFileKey ?? '').trim()
if (!url || !fileKey) return null
return {
url, format: 'I3D',
ext: firstNonEmpty(String(code.iosFileType ?? '').replace(/^\./, ''), 'm4a'),
encrypted: true, fileKey,
size: parseSize(code.iosSize) || expectedSize,
}
}
case '3D60': {
const url = normalizeDownloadURL(firstNonEmpty(code.url, code.h5Url))
if (!url) return null
return {
url, format: '3D60',
ext: firstNonEmpty(String(code.fileType ?? '').replace(/^\./, ''), 'wav'),
encrypted: false, fileKey: '',
size: parseSize(code.size) || expectedSize,
}
}
default:
return null
}
}
const detectTonePath = (decodedPath: string): string => {
for (const tone of ['ZQ24', 'ZQ', 'ZQ32', 'SQ', 'HQ', 'LQ', 'PQ']) {
const tonePath = TONE_PATHS[tone]!
if (tonePath && decodedPath.includes(tonePath)) return tonePath
}
return ''
}
/** 把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL) */
const rewriteToneURL = (rawURL: string, targetTone: string): string | null => {
const tone = normalizeTone(targetTone)
const targetPath = TONE_PATHS[tone]
const targetExt = TONE_EXT[tone]
if (!targetPath || !targetExt || tone === 'Z3D' || tone === 'I3D' || tone === '3D60') return null
let parsed: URL
try { parsed = new URL(normalizeDownloadURL(rawURL)) } catch { return null }
const decodedPath = decodeURIComponent(parsed.pathname)
const sourcePath = detectTonePath(decodedPath)
if (!sourcePath) return null
let newPath = decodedPath.replace(sourcePath, targetPath)
if (newPath === decodedPath) return null
const dotIdx = newPath.lastIndexOf('.')
if (dotIdx > newPath.lastIndexOf('/')) newPath = newPath.slice(0, dotIdx)
newPath += `.${targetExt}`
parsed.pathname = newPath
return parsed.toString()
}
/** 候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选(上游 buildMiguDownloadCandidates) */
const buildCandidates = (responses: ListenResponse[], expectedSize: number): DownloadInfo[] => {
const sources: Array<{ url: string, format: string }> = []
const seenSources = new Set<string>()
for (const resp of responses) {
const candidateURL = normalizeDownloadURL(resp?.data?.url)
if (!candidateURL) continue
const actualFormat = normalizeTone(resp?.data?.formatType) || 'PQ'
const key = `${actualFormat}\u0000${candidateURL}`
if (seenSources.has(key)) continue
seenSources.add(key)
sources.push({ url: candidateURL, format: actualFormat })
}
const candidates: DownloadInfo[] = []
const seen = new Set<string>()
const add = (info: DownloadInfo | null) => {
if (!info || !info.url) return
const key = `${info.format}\u0000${info.url}`
if (seen.has(key)) return
seen.add(key)
candidates.push(info)
}
for (const tone of TONE_PREFERENCE) {
for (const resp of responses) add(embeddedDownloadInfo(resp, tone, expectedSize))
for (const source of sources) {
if (tone === source.format) {
add(directDownloadInfo(source.url, source.format, expectedSize))
continue
}
const rewritten = rewriteToneURL(source.url, tone)
if (rewritten) add(directDownloadInfo(rewritten, tone, 0))
}
}
return candidates
}
/** 探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积 */
const downloadInfoValid = async(info: DownloadInfo): Promise<boolean> => {
try {
const cookie = getMiguCookie()
const headers: Record<string, string> = { 'User-Agent': AndroidUA, Referer: 'https://music.migu.cn/', Range: 'bytes=0-63' }
if (cookie) headers.Cookie = cookie
const { statusCode, headers: resHeaders, raw } = await httpFetch(info.url, { headers, timeout: 10000 }).promise
if (statusCode < 200 || statusCode >= 300) return false
const prefix = Buffer.isBuffer(raw) ? raw.subarray(0, 64) : Buffer.alloc(0)
if (!prefix.length) return false
if (info.encrypted) {
try { decryptZ3D(prefix, info.fileKey) } catch { return false }
} else if (!isMiguAudioMagic(prefix)) {
return false
}
const contentRange = String((resHeaders as any)['content-range'] ?? '')
const slash = contentRange.lastIndexOf('/')
if (slash >= 0) {
const size = parseInt(contentRange.slice(slash + 1).trim(), 10)
if (Number.isFinite(size) && size > 0) info.size = size
}
return true
} catch {
return false
}
}
const MAX_PROBES = 10
export interface MiguUrlResult {
type: string
url: string
/** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/mg 代理) */
encrypted?: boolean
fileKey?: string
ext?: string
size?: number
}
/** 取流入口:候选链 + 逐档探测;返回实际命中的最高档位 */
export const getMusicUrl = (songInfo: any, type: string) => {
const requestObj = { cancelHttp() {}, promise: null as any }
requestObj.promise = (async(): Promise<MiguUrlResult> => {
const { contentId, resourceType, formatType } = miguSongParts(songInfo)
if (!contentId) throw new Error('migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)')
const rt = resourceType || '2'
const targetTone = normalizeTone(QUALITY_TONE[type] ?? '')
|| normalizeTone(formatType)
|| normalizeTone(songInfo?.ext)
|| 'PQ'
const copyrightId = String(songInfo?.extra?.copyright_id ?? '')
const songId = String(songInfo?.extra?.song_id ?? '')
const albumId = String(songInfo?.extra?.album_id ?? songInfo?.albumId ?? '')
const requestTones: string[] = []
for (const tone of ['ZQ32', targetTone, 'PQ']) {
if (!requestTones.includes(tone)) requestTones.push(tone)
}
const responses: ListenResponse[] = []
let fetchErr: Error | null = null
for (const tone of requestTones) {
try {
responses.push(await fetchListenInfo(contentId, copyrightId, songId, albumId, rt, tone))
} catch (err: any) {
if (!fetchErr) fetchErr = err
}
}
if (!responses.length) throw fetchErr ?? new Error('migu listen-url 全部失败')
const expectedSize = Number(songInfo?.size ?? 0) || 0
const candidates = buildCandidates(responses, expectedSize)
let probed = 0
for (const candidate of candidates) {
if (probed++ >= MAX_PROBES) break
if (await downloadInfoValid(candidate)) {
const quality = TONE_QUALITY[candidate.format] ?? type
return {
type: quality,
url: candidate.url,
...(candidate.encrypted ? { encrypted: true, fileKey: candidate.fileKey } : {}),
ext: candidate.ext || miguFormatExt(candidate.format),
size: candidate.size,
}
}
}
// 全部候选不可用:优先回传接口文案(如「登录后免费试听」)
const dialog = responses.map(r => String(r?.data?.dialogInfo?.text ?? '').trim()).find(Boolean)
if (dialog) throw new Error(`migu 无可用音频:${dialog}(登录后可解锁更高档位)`)
if (fetchErr) throw fetchErr
throw new Error('migu 未返回可访问的音频档位')
})()
return requestObj
}
export default { getMusicUrl, supportQualitys }
+34
View File
@@ -0,0 +1,34 @@
// 蜻蜓FM URL 获取(点播 audiostream 签名直链)
// 链路(官网 main.js 逆向,2026-10-02 实测):
// GET https://audio.qtfm.cn/audiostream/redirect/<channel>/<program>?access_token=&device_id=MOBILESITE&qingting_id=&t=<ms>&sign=<hex>
// sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外)
// 匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3)
// 直播电台另有 /live/<cid>/64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn)
// songmid 为复合 id "<channelId>:<programId>"(/album/qt 列表产出)
import crypto from 'node:crypto'
const SIGN_KEY = '7l8CZ)SgZgM_bkrw'
const AUDIO_HOST = 'https://audio.qtfm.cn'
export const supportQualitys = ['64k', '128k']
export const buildProgramUrl = (channelId: string, programId: string) => {
const s = `/audiostream/redirect/${channelId}/${programId}?access_token=&device_id=MOBILESITE&qingting_id=&t=${Date.now()}`
const sign = crypto.createHmac('md5', SIGN_KEY).update(s).digest('hex')
return `${AUDIO_HOST}${s}&sign=${sign}`
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
const [channelId, programId] = songmid.split(':')
requestObj.promise = (async() => {
if (!channelId || !programId) {
throw new Error('蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)')
}
return { type: type === '128k' ? '128k' : '64k', url: buildProgramUrl(channelId, programId) }
})()
return requestObj
}
export default { getMusicUrl, buildProgramUrl }
+90
View File
@@ -0,0 +1,90 @@
// QQ音乐 URL 获取(直连)
// 接口: https://u.y.qq.com/cgi-bin/musicu.fcg
// 方法: vkey.GetVkeyServer.CgiGetVkey
// 取流方案:
// 按请求音质组 filename 候选链一次请求(命中第一个有 purl 的档位即最高可用音质);
// 始终附带 C400{songmid}.m4a 保底(匿名可取的 128k)。
// 档位前缀(社区资料 2026-10 核对):RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC >
// F000 无损 FLAC > M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
// VIP 档 filename 用 media_mid(非 songmid)。
// 登录 cookie(uin + qm_keyst,见 /login/tx)可解锁 320k/无损/Hi-Res。
// 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中的音质,可能低于请求值)
import { httpFetch } from '../common/request.ts'
import { getTxAuth } from '../auth/store.ts'
// 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
const candidateMap: Record<string, Array<[string, string, string]>> = {
'128k': [['M500', 'mp3', '128k']],
'320k': [['M800', 'mp3', '320k'], ['M500', 'mp3', '128k']],
flac: [['F000', 'flac', 'flac'], ['M800', 'mp3', '320k'], ['M500', 'mp3', '128k']],
flac24bit: [
['RS01', 'flac', 'flac24bit'],
['Q000', 'flac', 'flac24bit'],
['AI00', 'flac', 'flac24bit'],
['F000', 'flac', 'flac'],
['M800', 'mp3', '320k'],
['M500', 'mp3', '128k'],
],
}
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = Object.keys(candidateMap)
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const mediaMid = songInfo.strMediaMid || songInfo.songmid
const entries: any[] = []
for (const [prefix, ext, hitType] of candidateMap[type] || candidateMap['128k']) {
entries.push({ filename: `${prefix}${mediaMid}.${ext}`, type: hitType })
}
entries.push({ filename: `C400${songInfo.songmid}.m4a`, type: '128k' })
const auth = getTxAuth()
const uin = auth?.uin || '0'
const data = {
comm: { ct: '19', cv: '1859', uin },
req: {
module: 'vkey.GetVkeyServer',
method: 'CgiGetVkey',
param: {
guid: '7342827269',
songmid: [songInfo.songmid],
filename: entries.map(e => e.filename),
songtype: [0],
uin,
loginflag: 1,
platform: '20',
needNewCode: 1,
},
},
}
requestObj.promise = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
Referer: 'https://y.qq.com/',
...(auth ? { cookie: auth.cookie } : {}),
},
body: data,
timeout: 15000,
}).promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 0 || body.req?.code != 0) {
return Promise.reject(new Error('获取失败'))
}
const data = body.req.data
const sip = data.sip?.[0] || 'http://aqqmusic.tc.qq.com/'
const infos = data.midurlinfo || []
const idx = infos.findIndex(info => info.purl)
if (idx >= 0) {
return { type: entries[idx]?.type || '128k', url: `${sip}${infos[idx].purl}` }
}
// 全部受限(VIP 歌匿名)时回退试听地址(服务器返回的 128k m4a 试听文件)
if (data.testfile2g) return { type: '128k', url: `${sip}${data.testfile2g}` }
return Promise.reject(new Error('获取失败'))
})
return requestObj
}
export default { getMusicUrl }
+10 -3
View File
@@ -4,8 +4,10 @@
// 统一接口约定:apis('wy').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('wy').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url } // promise resolve → { type, url }
import { httpFetch } from '../common/request.js' import { httpFetch } from '../common/request.ts'
import { eapi } from '../../musicSdk/wy/utils/crypto.ts' // crypto.js 为 tsc 就地发射产物(源 sdk/wy/utils/crypto.ts);统一走 .js,避免同进程出现 .ts/.js 双实例
import { eapi } from '../wy/utils/crypto.ts'
import { getWyCookie } from '../auth/store.ts'
const levelMap = { const levelMap = {
'128k': 'standard', '128k': 'standard',
@@ -14,18 +16,23 @@ const levelMap = {
flac24bit: 'hires', flac24bit: 'hires',
} }
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = Object.keys(levelMap)
export const getMusicUrl = (songInfo, type) => { export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null } const requestObj = { cancelHttp() {}, promise: null as any }
const data = { const data = {
ids: `[${songInfo.songmid}]`, ids: `[${songInfo.songmid}]`,
level: levelMap[type] || 'standard', level: levelMap[type] || 'standard',
encodeType: 'flac', encodeType: 'flac',
} }
const cookie = getWyCookie()
const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', { const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', {
method: 'post', method: 'post',
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
origin: 'https://music.163.com', origin: 'https://music.163.com',
...(cookie ? { cookie } : {}),
}, },
form: eapi('/api/song/enhance/player/url/v1', data), form: eapi('/api/song/enhance/player/url/v1', data),
timeout: 15000, timeout: 15000,
+52
View File
@@ -0,0 +1,52 @@
// 喜马拉雅 URL 获取(PC 桌面端 baseInfo 通道)
// 链路:makeXmSign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList
// → decodeWinUrl(AES-ECB, 客户端 4.0.14 asar 密钥) → a.xmcdn.com 带签名直链
// 实测(2026-10-02)匿名免费曲目 HTTP 200 audio/mp4 可播;付费/VIP 曲 ret=1001(需登录,暂未接)
// 档位命名如 M4A_64/MP3_64/M4A_24/MP3_32/AAC_24(音乐内容另有 MP3_128/M4A_128);
// 喜马拉雅以有声内容为主,码率低于音乐平台,音质映射到 '64k'/'128k' 桶
// 统一接口约定:apis('xm').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { getTrackInfo } from '../xm/musicInfo.ts'
import { decodeWinUrl } from '../xm/decoder.ts'
// 优先 M4A(同码率体积更小),128 档优先 MP3_128
const QUALITY_PREFERENCE: Record<string, string[]> = {
'128k': ['MP3_128', 'M4A_128', 'M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'],
'64k': ['M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'],
}
export const supportQualitys = ['64k', '128k']
const bitrateOf = (type: string): number => Number(/(\d+)/.exec(type)?.[1] ?? 0) || 0
const bucketOf = (bitrate: number): string => (bitrate >= 128 ? '128k' : '64k')
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const trackId = String(songInfo.songmid ?? '')
requestObj.promise = (async() => {
const trackInfo = await getTrackInfo(trackId)
const playList: any[] = trackInfo.playUrlList ?? []
if (!playList.length) throw new Error('无可用播放资源(付费/版权内容需登录)')
const preference = QUALITY_PREFERENCE[type] ?? QUALITY_PREFERENCE['128k']
let picked: { type: string, url: string } | null = null
for (const want of preference) {
const hit = playList.find(item => item?.type === want && item.url)
if (hit) {
picked = { type: hit.type, url: hit.url.startsWith('http') ? hit.url : decodeWinUrl(hit.url) }
break
}
}
if (!picked) {
// 兜底:未知档位命名时取码率最高的一条
const best = [...playList].filter(item => item?.url).sort((a, b) => bitrateOf(b.type) - bitrateOf(a.type))[0]
if (!best) throw new Error('无可用播放资源')
picked = { type: best.type, url: best.url.startsWith('http') ? best.url : decodeWinUrl(best.url) }
}
return { type: bucketOf(bitrateOf(picked.type)), url: picked.url }
})()
return requestObj
}
export default { getMusicUrl }
+77
View File
@@ -0,0 +1,77 @@
// Apple Music 专辑(上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)
// 详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
// → data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
// 链接:extractAppleID(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
import {
appleAmpGet,
appleAlbumToPlaylist,
appleSongFromCatalog,
extractAppleID,
fetchAllTrackResources,
getAppleConfig,
} from './util.ts'
export { searchAlbum } from './musicSearch.ts'
import { searchAlbum } from './musicSearch.ts'
/** 专辑详情(page/limit 在完整曲目上切片,与其它平台 getAlbumDetail 契约一致) */
export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
const albumId = extractAppleID(String(rawId ?? ''), 'album')
if (!albumId) throw new Error(`invalid apple music album id/link: ${rawId}`)
const cfg = getAppleConfig()
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/albums/${albumId}`, { extend: 'extendedAssetUrls' })
const album = (body?.data ?? [])[0]
if (!album) throw new Error(`apple album not found: ${albumId}`)
const tracks = await fetchAllTrackResources(album?.relationships?.tracks)
const songs = tracks.map((track: any) => appleSongFromCatalog(track, albumId))
const p = Math.max(1, Number(page) || 1)
const l = Math.max(1, Number(limit) || 50)
const summary = appleAlbumToPlaylist(album)
// 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
return {
source: 'apple' as const,
info: {
id: albumId,
name: summary.name,
img: summary.cover,
desc: summary.description,
author: summary.creator,
link: summary.link,
trackCount: summary.trackCount,
releaseDate: summary.extra?.release_date || '',
recordLabel: summary.extra?.record_label || '',
},
list: songs.slice((p - 1) * l, (p - 1) * l + l),
page: p,
limit: l,
total: songs.length,
}
}
/** 上游 GetAlbumSongs:只返回曲目数组 */
export const getAlbumSongs = async(id: string): Promise<any[]> => {
const { list } = await getAlbumDetail(id, 1, 100000)
return list
}
/** 上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页) */
export const parseAlbumLink = async(link: string) => {
const detail = await getAlbumDetail(link, 1, 100000)
return {
source: 'apple' as const,
id: detail.info.id,
info: detail.info,
list: detail.list,
page: 1,
limit: Math.max(detail.total, 1),
total: detail.total,
}
}
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink }
+106
View File
@@ -0,0 +1,106 @@
// Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: apple)
// 能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到,见 lyric.ts)/ 封面 /
// 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
// 链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 sdk/apis/apple.ts)。
// 凭据:上游 New(cookie) → getCredentials('apple'),cookie 形如
// "media-user-token=xxx;storefront=cn;token=xxx",也支持 storefront/mediaUserToken/token 直填字段。
// 未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
import appleApi from '../apis/apple.ts'
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import {
appleArtworkURL,
appleSongFromCatalog,
appleWebURL,
extractAppleID,
fetchAppleSongResource,
getAppleConfig,
getAppleToken,
} from './util.ts'
import { getCredentials } from '../auth/store.ts'
const songIdOf = (songInfo: any): string => (
extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
)
const apple = {
/** 预热 developer token(失败不阻塞启动,路由调用时会重试) */
init() {
return getAppleToken().then(() => true).catch(() => false)
},
musicSearch,
getMusicUrl(songInfo, type) {
return appleApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
/** 封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort) */
async getPic(songInfo) {
if (songInfo?.img) return String(songInfo.img)
const id = songIdOf(songInfo)
if (!id) return null
try {
const res = await fetchAppleSongResource(id)
return res ? (appleArtworkURL(res.attributes?.artwork, 600) || null) : null
} catch {
return null
}
},
/** 歌曲详情(best-effort;等价上游 Parse(id)) */
async getMusicInfo(songInfo) {
const id = songIdOf(songInfo)
if (!id) return null
const res = await fetchAppleSongResource(id, 'lyrics,albums')
return res ? appleSongFromCatalog(res) : null
},
album: {
getAlbumDetail: album.getAlbumDetail,
getAlbumSongs: album.getAlbumSongs,
searchAlbum: album.searchAlbum,
parseAlbumLink: album.parseAlbumLink,
},
playlist,
// 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/apple 复用
songList: {
sortList: [{ name: '热门', id: '1526756058' }],
getTags() {
return playlist.getCategories()
},
getList(sortId: string, tagId: string, page = 1, limit = 20) {
const cate = String(tagId || sortId || '').trim()
return playlist.getCategoryPlaylists(cate, page, limit)
},
},
parse,
getMusicDetailPageUrl(songInfo) {
if (songInfo?.link) return String(songInfo.link)
const id = songIdOf(songInfo)
if (!id) return 'https://music.apple.com/'
return appleWebURL('song', id, getAppleConfig().storefront)
},
}
/** 登录状态(media-user-token 存在性判断;无在线校验接口) */
export const status = () => {
const cred = getCredentials('apple') as any
const cfg = getAppleConfig()
return (cfg.mediaUserToken || cred?.cookie)
? { logged: true, updatedAt: cred?.updatedAt }
: { logged: false }
}
export default apple
+50
View File
@@ -0,0 +1,50 @@
// Apple Music 歌词(上游 apple.go GetLyrics)
// 通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
// → data[0].relationships.lyrics.data[*].attributes.ttml
// 上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
// 注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
// (即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词接口需要 media-user-token(订阅态)才能拿到;
// 本实现保持上游行为:拿不到就返回 { lyric: '', tlyric: '' },不抛错。
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
import { appleAmpGetTracked, extractAppleID, getAppleConfig } from './util.ts'
export const getLyric = (songInfo: any, ..._rest: any[]) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
const tracker = { current: null as any }
requestObj.cancelHttp = () => {
try {
tracker.current?.cancelHttp?.()
} catch {
// ignore
}
tracker.current = null
}
const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
requestObj.promise = (async() => {
if (!songId) throw new Error('apple 歌曲 id 无效')
const cfg = getAppleConfig()
const body: any = await appleAmpGetTracked(
`/v1/catalog/${cfg.storefront}/songs/${songId}`,
{ include: 'lyrics', extend: 'extendedAssetUrls' },
tracker,
)
const lyrics = body?.data?.[0]?.relationships?.lyrics?.data ?? []
for (const rel of lyrics) {
const text = String(rel?.attributes?.ttml ?? '')
if (text) return { lyric: text, tlyric: '' }
}
return { lyric: '', tlyric: '' }
})()
return requestObj
}
/** 非取消句柄版:直接返回歌词文本(内部/测试用) */
export const getLyricsText = async(songId: string): Promise<string> => {
const result = await getLyric({ songmid: songId }).promise
return result?.lyric || ''
}
export default { getLyric }
+74
View File
@@ -0,0 +1,74 @@
// Apple Music 搜索(上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)
// 通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.ts)
// 单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
// 因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
import {
appleAmpGet,
appleSongFromCatalog,
appleAlbumToPlaylist,
applePlaylistToPlaylist,
getAppleConfig,
} from './util.ts'
/** amp-api search 单页硬上限 */
export const SEARCH_PAGE_MAX = 25
const catalogSearch = async(keyword: string, types: string, page: number, limit: number) => {
const word = String(keyword ?? '').trim()
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 100)
if (!word) return { body: null, page: p, limit: l }
const cfg = getAppleConfig()
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/search`, {
term: word,
types,
limit: Math.min(l, SEARCH_PAGE_MAX),
offset: (p - 1) * l,
})
return { body, page: p, limit: l }
}
/** 上游 Search:搜索歌曲 */
export const search = async(keyword: string, page = 1, limit = 20) => {
const { body, page: p, limit: l } = await catalogSearch(keyword, 'songs', page, limit)
const bucket = body?.results?.songs ?? {}
const list = (bucket.data ?? []).map((item: any) => appleSongFromCatalog(item))
const hasNext = Boolean(bucket.next)
return {
list,
allPage: hasNext ? p + 1 : p,
total: (p - 1) * l + list.length,
limit: l,
source: 'apple' as const,
}
}
/** 上游 SearchAlbum:搜索专辑(返回歌单摘要形状) */
export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
const { body, page: p, limit: l } = await catalogSearch(keyword, 'albums', page, limit)
const items = body?.results?.albums?.data ?? []
const list = items.map((item: any) => appleAlbumToPlaylist(item))
return {
list,
total: (p - 1) * l + list.length,
page: p,
limit: l,
source: 'apple' as const,
}
}
/** 上游 SearchPlaylist:搜索歌单 */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const { body, page: p, limit: l } = await catalogSearch(keyword, 'playlists', page, limit)
const items = body?.results?.playlists?.data ?? []
const list = items.map((item: any) => applePlaylistToPlaylist(item))
return {
list,
total: (p - 1) * l + list.length,
page: p,
limit: l,
source: 'apple' as const,
}
}
export default { search, searchAlbum, searchPlaylist }
+22
View File
@@ -0,0 +1,22 @@
// Apple Music 链接解析(上游 apple.go Parse / ParseAlbum / ParsePlaylist)
// song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
// album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
// playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
// 上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
import { appleSongFromCatalog, extractAppleID, fetchAppleSongResource } from './util.ts'
import { parseAlbumLink } from './album.ts'
import { parsePlaylistLink } from './playlist.ts'
/** 上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链) */
export const parseSong = async(link: string) => {
const songId = extractAppleID(String(link ?? ''), 'song')
if (!songId) throw new Error(`invalid apple music song link: ${link}`)
const res = await fetchAppleSongResource(songId, 'lyrics,albums')
if (!res) throw new Error(`apple song not found: ${songId}`)
return appleSongFromCatalog(res)
}
export const parseAlbum = async(link: string) => parseAlbumLink(link)
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+165
View File
@@ -0,0 +1,165 @@
// Apple Music 歌单(上游 apple.go 的 SearchPlaylist / ParsePlaylist / GetPlaylistSongs /
// GetPlaylistCategories / GetCategoryPlaylists 与 applePlaylistToPlaylist 映射)
// 详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
// 曲目超过一页时沿 relationships.tracks.next 全量拉取(上游 fetchAllTrackResources,带 20 页上限防死循环)。
// 分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
// GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
// 链接:extractAppleID(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
import {
appleAmpGet,
applePlaylistToPlaylist,
appleSongFromCatalog,
extractAppleID,
fetchAllTrackResources,
getAppleConfig,
} from './util.ts'
export { searchPlaylist } from './musicSearch.ts'
import { searchPlaylist } from './musicSearch.ts'
/** 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators) */
export const appleCuratorCategories: Array<{ id: string, name: string }> = [
{ id: '1526756058', name: '热门' },
{ id: '1479949880', name: 'C-Pop' },
{ id: '1019400042', name: '国语流行' },
{ id: '1019398918', name: '粤语流行' },
{ id: '1019399540', name: '国际流行' },
{ id: '1019399551', name: 'K-Pop' },
{ id: '1019399547', name: 'J-Pop' },
{ id: '989061415', name: '嘻哈/说唱' },
{ id: '1019400044', name: 'R&B' },
{ id: '1019400046', name: '摇滚' },
{ id: '1019397973', name: '另类音乐' },
{ id: '976439535', name: '舞曲' },
{ id: '976439536', name: '电子' },
{ id: '976439541', name: '独立音乐' },
{ id: '1019399549', name: '爵士乐' },
{ id: '1019398924', name: '古典音乐' },
{ id: '976439528', name: '蓝调' },
{ id: '976439534', name: '乡村音乐' },
{ id: '1531542847', name: '拉丁音乐' },
{ id: '988656348', name: '非洲音乐' },
{ id: '976439543', name: '金属乐' },
{ id: '976439550', name: '朋克乐' },
{ id: '1019400049', name: '不插电' },
{ id: '1231181168', name: '影视原声' },
{ id: '1441811365', name: 'DJ 混音精选' },
{ id: '1532467784', name: '瞩目之星' },
{ id: '1554938339', name: '年代之声' },
{ id: '1564180390', name: '空间音频' },
{ id: '989010186', name: '亲子' },
]
/** 上游 GetPlaylistCategories:静态返回 Apple Music genre curators */
export const getCategories = async() => ({
source: 'apple' as const,
list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'apple' as const })),
})
/** 上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐) */
export const getCategoryPlaylists = async(categoryID: string, page = 1, limit = 20) => {
const categoryId = String(categoryID ?? '').trim()
if (!categoryId) throw new Error('apple 分类 id 不能为空')
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 300)
const apiMax = 25
let offset = (p - 1) * l
const all: any[] = []
while (all.length < l) {
const batchSize = Math.min(apiMax, l - all.length)
let body: any
try {
body = await appleAmpGet(`/v1/catalog/cn/apple-curators/${categoryId}/playlists`, {
limit: batchSize,
offset,
l: 'zh-Hans-CN',
})
} catch (err) {
if (all.length > 0) break // 上游:已有数据时返回已取部分
throw err
}
const data = body?.data ?? []
all.push(...data.map((item: any) => applePlaylistToPlaylist(item)))
// 没有更多数据或没有下一页
if (data.length < batchSize || !body?.next) break
offset += data.length
}
return {
source: 'apple' as const,
list: all.slice(0, l),
page: p,
limit: l,
total: all.length,
}
}
/** 歌单详情(全量曲目后按 page/limit 切片) */
export const getPlaylistDetail = async(rawId: string, page = 1, limit = 100) => {
const playlistId = extractAppleID(String(rawId ?? ''), 'playlist')
if (!playlistId) throw new Error(`invalid apple music playlist id/link: ${rawId}`)
const cfg = getAppleConfig()
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/playlists/${playlistId}`, {
'limit[tracks]': 300,
extend: 'extendedAssetUrls',
})
const item = (body?.data ?? [])[0]
if (!item) throw new Error(`apple playlist not found: ${playlistId}`)
const tracks = await fetchAllTrackResources(item?.relationships?.tracks)
const songs = tracks.map((track: any) => appleSongFromCatalog(track))
const p = Math.max(1, Number(page) || 1)
const l = Math.max(1, Number(limit) || 100)
const summary = applePlaylistToPlaylist(item)
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
return {
source: 'apple' as const,
info: {
id: playlistId,
name: summary.name,
img: summary.cover,
desc: summary.description,
author: summary.creator,
link: summary.link,
trackCount: summary.trackCount,
},
list: songs.slice((p - 1) * l, (p - 1) * l + l),
page: p,
limit: l,
total: songs.length,
}
}
/** 上游 GetPlaylistSongs:只返回曲目数组 */
export const getPlaylistSongs = async(id: string): Promise<any[]> => {
const { list } = await getPlaylistDetail(id, 1, 100000)
return list
}
/** 上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页) */
export const parsePlaylistLink = async(link: string) => {
const detail = await getPlaylistDetail(link, 1, 100000)
return {
source: 'apple' as const,
id: detail.info.id,
info: detail.info,
list: detail.list,
page: 1,
limit: Math.max(detail.total, 1),
total: detail.total,
}
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
parsePlaylistLink,
}
+452
View File
@@ -0,0 +1,452 @@
// Apple Music(music.apple.com / amp-api.music.apple.com)公共工具
// 上游 music-lib/apple/apple.go 的移植基础层:
// - New(cookie) → getCredentials('apple'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx"
// - fetchAppleToken():从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)
// 当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi({"typ":...})开头而非上游正则写的 eyJh,
// 故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取见 getAppleToken/invalidateAppleToken。
// - ampGet/ampHeaders:amp-api 请求(httpFetch 不自动跟 302,首页跳转用 fetchFollow 手动跟)
// - 模型转换:appleSongFromCatalog / appleAlbumToPlaylist / applePlaylistToPlaylist(字段映射同上游)
// - ID 提取:extractAppleID / extractAppleIDFromPath / isAppleRawID(apple_test.go 的断言全部逐条保留)
import { httpFetch } from '../common/request.ts'
import { formatPlayTime } from '../common/utils.ts'
import { getCredentials } from '../auth/store.ts'
export const APPLE_HOMEPAGE_URL = 'https://music.apple.com'
export const APPLE_AMP_API_URL = 'https://amp-api.music.apple.com'
export const DEFAULT_STOREFRONT = 'us'
export const APPLE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** 上游 New(cookie) 的等价配置(cookie 字符串解析 + 凭据直填字段) */
export interface AppleRuntimeConfig {
storefront: string
/** media-user-token(订阅用户 token;歌词/完整曲目等受限资源需要) */
mediaUserToken: string
/** 显式提供的 developer token(上游 cookie 里的 token=),优先于网页抓取 */
token: string
}
/** 上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白 */
export const parseCookieParts = (cookie: string): Record<string, string> => {
const result: Record<string, string> = {}
for (const part of String(cookie ?? '').split(';')) {
const trimmed = part.trim()
const idx = trimmed.indexOf('=')
if (idx > 0) {
const key = trimmed.slice(0, idx).trim()
const val = trimmed.slice(idx + 1).trim()
result[key] = val
}
}
return result
}
/** 读取 apple 平台运行时配置(对应上游 New(cookie)) */
export const getAppleConfig = (): AppleRuntimeConfig => {
const cred = (getCredentials('apple') as any) || {}
const parts = parseCookieParts(String(cred.cookie ?? ''))
const storefront = String(cred.storefront || parts.storefront || DEFAULT_STOREFRONT).trim()
return {
storefront: storefront || DEFAULT_STOREFRONT,
mediaUserToken: String(cred.mediaUserToken || parts['media-user-token'] || '').trim(),
token: String(cred.token || parts.token || '').trim(),
}
}
// ---------------- developer token ----------------
let cachedToken = ''
let cachedAt = 0
let pendingToken: Promise<string> | null = null
// 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
let bypassExplicitToken = false
// JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
const TOKEN_CACHE_TTL = 12 * 60 * 60 * 1000
/** 解码 JWT payload(Apple token 为 ES256 JWT) */
export const decodeAppleTokenPayload = (token: string): any => {
try {
const part = String(token ?? '').split('.')[1] || ''
const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
return JSON.parse(json)
} catch {
return {}
}
}
export const isAppleTokenExpired = (token: string): boolean => {
const exp = Number(decodeAppleTokenPayload(token)?.exp || 0)
return exp > 0 && exp * 1000 < Date.now() + 60 * 1000
}
/** 从 index JS 中提取所有 JWT 形态的候选 token(含引号/等号前缀的主匹配 + 无前缀回退) */
export const extractAppleTokens = (js: string): string[] => {
const pattern = '(eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})'
const found: string[] = []
let m: RegExpExecArray | null
const primary = new RegExp(`(?:=["'])${pattern}`, 'g')
while ((m = primary.exec(String(js ?? '')))) found.push(m[1])
if (!found.length) {
const loose = new RegExp(pattern, 'g')
while ((m = loose.exec(String(js ?? '')))) found.push(m[1])
}
return [...new Set(found)]
}
/** 选择可用 token:优先 issuer=AMPWebPlay(amp-api 使用的那个),其次第一个未过期;全部过期视为抓取失败 */
export const pickAppleToken = (js: string): string => {
const tokens = extractAppleTokens(js)
if (!tokens.length) return ''
const alive = tokens.filter(t => !isAppleTokenExpired(t))
if (!alive.length) return ''
const preferred = alive.find(t => String(decodeAppleTokenPayload(t)?.iss || '') === 'AMPWebPlay')
return preferred || alive[0] || ''
}
/** 手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳) */
export const fetchFollow = async(url: string, headers: Record<string, string> = {}, maxHops = 5): Promise<any> => {
let current = String(url)
for (let hop = 0; hop < maxHops; hop++) {
const res: any = await httpFetch(current, { headers, timeout: 20000 }).promise
const code = Number(res?.statusCode) || 0
const location = res?.headers?.location
if (code >= 300 && code < 400 && location) {
current = new URL(String(location), current).href
continue
}
return res
}
throw new Error('apple: too many redirects')
}
const responseText = (res: any): string => {
if (typeof res?.body === 'string') return res.body
return Buffer.isBuffer(res?.raw) ? res.raw.toString('utf8') : String(res?.body ?? '')
}
/** 上游 fetchAppleToken:首页 → index-legacy JS → bearer token */
export const fetchAppleToken = async(): Promise<string> => {
const home = await fetchFollow(APPLE_HOMEPAGE_URL, { 'User-Agent': APPLE_UA })
if (Number(home?.statusCode) !== 200) throw new Error(`apple: fetch homepage error: HTTP ${home?.statusCode}`)
const html = responseText(home)
// 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
let match = /\/(assets\/index-legacy[~-][^/"']+\.js)/.exec(html)
if (!match) match = /\/(assets\/index[~-][^/"']+\.js)/.exec(html)
if (!match) throw new Error('apple: index.js URI not found')
const jsRes = await fetchFollow(`${APPLE_HOMEPAGE_URL}/${match[1]}`, { 'User-Agent': APPLE_UA })
if (Number(jsRes?.statusCode) !== 200) throw new Error(`apple: fetch index.js error: HTTP ${jsRes?.statusCode}`)
const token = pickAppleToken(responseText(jsRes))
if (!token) throw new Error('apple: token not found in index.js')
return token
}
/**
* 获取 developer token(缓存 + JWT exp 校验 + 并发去重)
* force=true 或显式 token 失效(401/403 后)时回源重新抓取
*/
export const getAppleToken = async(force = false): Promise<string> => {
const cfg = getAppleConfig()
if (!force && !bypassExplicitToken && cfg.token) return cfg.token
if (!force && cachedToken && Date.now() - cachedAt < TOKEN_CACHE_TTL && !isAppleTokenExpired(cachedToken)) {
return cachedToken
}
if (!pendingToken) {
pendingToken = (async() => {
try {
const token = await fetchAppleToken()
cachedToken = token
cachedAt = Date.now()
return token
} finally {
pendingToken = null
}
})()
}
return pendingToken
}
/** 清除缓存 token(401/403 时调用,下次请求会回源重取) */
export const invalidateAppleToken = (): void => {
cachedToken = ''
cachedAt = 0
bypassExplicitToken = true
}
/** 仅测试/调试用:重置内存态 */
export const resetAppleTokenState = (): void => {
cachedToken = ''
cachedAt = 0
bypassExplicitToken = false
pendingToken = null
}
// ---------------- amp-api 请求 ----------------
export const appleAmpHeaders = (token: string): Record<string, string> => {
const cfg = getAppleConfig()
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Origin: APPLE_HOMEPAGE_URL,
Referer: `${APPLE_HOMEPAGE_URL}/`,
'User-Agent': APPLE_UA,
Accept: 'application/json',
}
// 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
if (cfg.mediaUserToken) headers.Cookie = `media-user-token=${cfg.mediaUserToken}`
return headers
}
/** 拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL */
export const buildAppleAmpUrl = (uri: string, params?: Record<string, any> | URLSearchParams | null): string => {
const raw = String(uri ?? '')
const base = /^https?:\/\//i.test(raw)
? raw
: raw.startsWith('/')
? APPLE_AMP_API_URL + raw
: `${APPLE_AMP_API_URL}/${raw}`
let query = ''
if (params instanceof URLSearchParams) {
query = params.toString()
} else if (params && typeof params === 'object') {
const search = new URLSearchParams()
for (const key of Object.keys(params)) {
const value = params[key]
if (value == null || value === '') continue
search.append(key, String(value))
}
query = search.toString()
}
if (!query) return base
return base + (base.includes('?') ? '&' : '?') + query
}
const ampErrorDetail = (res: any): string => {
const first = res?.body?.errors?.[0]
return String(first?.detail || first?.title || '')
}
/**
* amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)
* tracker 用于把内层 httpFetch 请求对象暴露给调用方做 cancelHttp
*/
export const appleAmpGetTracked = async(
uri: string,
params: Record<string, any> | URLSearchParams | null | undefined,
tracker?: { current: any },
): Promise<any> => {
const url = buildAppleAmpUrl(uri, params)
const attempt = async(): Promise<any> => {
const token = await getAppleToken()
const request = httpFetch(url, { headers: appleAmpHeaders(token), timeout: 20000 })
if (tracker) tracker.current = request
return request.promise
}
let res: any = await attempt()
if (res?.statusCode === 401 || res?.statusCode === 403) {
invalidateAppleToken()
res = await attempt()
}
if (!(res?.statusCode >= 200 && res?.statusCode < 300)) {
const detail = ampErrorDetail(res)
throw new Error(`apple amp-api HTTP ${res?.statusCode}${detail ? `: ${detail}` : ''}`)
}
return res.body
}
export const appleAmpGet = async(uri: string, params?: Record<string, any> | null): Promise<any> => (
appleAmpGetTracked(uri, params, { current: null })
)
/** 拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources;带页数上限防死循环) */
const MAX_TRACK_PAGES = 20
export const fetchAllTrackResources = async(tracks: any): Promise<any[]> => {
const all: any[] = [...(tracks?.data ?? [])]
let nextUri = String(tracks?.next ?? '').trim()
for (let page = 0; nextUri && page < MAX_TRACK_PAGES; page++) {
const body: any = await appleAmpGetTracked(nextUri, null, undefined)
all.push(...(body?.data ?? []))
nextUri = String(body?.next ?? '').trim()
}
return all
}
// ---------------- 模型转换(字段映射同上游) ----------------
/** 上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位 */
export const appleArtworkURL = (artwork: any, size = 600): string => {
const url = String(artwork?.url ?? '')
if (!url) return ''
return url.replace(/\{w\}/g, String(size)).replace(/\{h\}/g, String(size))
}
/** 上游 appleSongFromCatalog → 契约 Song 形状 */
export const appleSongFromCatalog = (res: any, defaultAlbumId = ''): any => {
const attr = res?.attributes ?? {}
const previewURL = Array.isArray(attr.previews) && attr.previews.length ? String(attr.previews[0]?.url ?? '') : ''
const durationSec = Math.round(Number(attr.durationInMillis ?? 0) / 1000)
return {
songmid: String(res?.id ?? ''),
name: attr.name || '',
singer: attr.artistName || '',
albumId: String(attr.albumId || defaultAlbumId || ''),
albumName: attr.albumName || '',
interval: formatPlayTime(durationSec),
img: appleArtworkURL(attr.artwork, 600) || null,
lrc: null,
types: [],
_types: {},
source: 'apple' as const,
link: attr.url || '', // music.apple.com 网页地址
previewUrl: previewURL, // 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
duration: durationSec,
isrc: attr.isrc || '',
}
}
/** 上游 appleAlbumToPlaylist → 契约歌单/专辑摘要 */
export const appleAlbumToPlaylist = (res: any): any => {
const attr = res?.attributes ?? {}
return {
id: String(res?.id ?? ''),
name: attr.name || '',
cover: appleArtworkURL(attr.artwork, 300),
trackCount: Number(attr.trackCount ?? 0) || 0,
creator: attr.artistName || '',
description: attr.editorialNotes?.short || '',
link: attr.url || '',
source: 'apple' as const,
extra: {
release_date: attr.releaseDate || '',
record_label: attr.recordLabel || '',
},
}
}
/** 上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游) */
export const applePlaylistToPlaylist = (res: any): any => {
const attr = res?.attributes ?? {}
const description = attr.description?.short
|| attr.description?.standard
|| attr.editorialNotes?.short
|| attr.editorialNotes?.standard
|| ''
return {
id: String(res?.id ?? ''),
name: attr.name || '',
cover: appleArtworkURL(attr.artwork, 300),
trackCount: Number(attr.trackCount ?? 0) || 0,
creator: attr.curatorName || '',
description,
link: attr.url || '',
source: 'apple' as const,
}
}
/** 网页地址(搜索结果 link 缺失时的兜底) */
export const appleWebURL = (kind: 'song' | 'album' | 'playlist', id: string, storefront = DEFAULT_STOREFRONT): string => {
const sf = String(storefront || DEFAULT_STOREFRONT)
return `${APPLE_HOMEPAGE_URL}/${sf}/${kind}/${id}`
}
// ---------------- ID 提取(上游 extractAppleID/isAppleRawID/extractAppleIDFromPath) ----------------
/** 上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字) */
export const isAppleRawID = (value: string, mediaType: string): boolean => {
if (/[/?#]/.test(value) || value.includes('://')) return false
if (mediaType === 'playlist') {
if (value.startsWith('pl.')) return /^[a-zA-Z0-9._-]+$/.test(value)
return /^[a-zA-Z0-9_-]+$/.test(value)
}
return /^\d+$/.test(value)
}
/** 上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id> */
export const extractAppleIDFromPath = (path: string, mediaType: string): string => {
const trimmed = String(path ?? '').replace(/^\/+|\/+$/g, '')
if (!trimmed) return ''
const segments = trimmed.split('/')
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
if (segment !== mediaType && segment !== `${mediaType}s`) continue
if (i + 2 < segments.length) return segments[i + 2]
if (i + 1 < segments.length) return segments[i + 1]
}
return ''
}
/** 上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底 */
export const extractAppleID = (link: string, mediaType: string): string => {
const raw = String(link ?? '').trim()
if (!raw) return ''
if (isAppleRawID(raw, mediaType)) return raw
// 先按 URL 解析(Go url.Parse 的宽松语义:无协议头时把 '?' 前当 path)
let path = ''
let query = ''
try {
const parsed = new URL(raw)
path = parsed.pathname || ''
query = parsed.search || ''
} catch {
const idx = raw.indexOf('?')
path = idx >= 0 ? raw.slice(0, idx) : raw
query = idx >= 0 ? raw.slice(idx) : ''
}
if (path) {
if (mediaType === 'song') {
const songID = new URLSearchParams(query).get('i')?.trim()
if (songID) return songID
}
const fromPath = extractAppleIDFromPath(path, mediaType)
if (fromPath) return fromPath
}
const patterns: RegExp[] = [
new RegExp(`${mediaType}/[^/]+/(\\d+)`),
new RegExp(`${mediaType}s?/[^/]+/[^/]+/(\\d+)`),
/\/(\d+)$/,
/[?&]i=(\d+)/,
]
if (mediaType === 'playlist') {
patterns.push(
/playlists?\/[^/?#]+\/([a-zA-Z0-9._-]+)/,
/playlists?\/([a-zA-Z0-9._-]+)(?:[?#]|$)/,
)
}
for (const pattern of patterns) {
const match = pattern.exec(raw)
if (match && match[1] != null) return match[1]
}
return ''
}
// ---------------- 共享请求 ----------------
/** 歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>) */
export const fetchAppleSongResource = async(songId: string, include = ''): Promise<any> => {
const cfg = getAppleConfig()
const params: Record<string, string> = { extend: 'extendedAssetUrls' }
if (include) params.include = include
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/songs/${songId}`, params)
const data = body?.data ?? []
return data.length ? data[0] : null
}
export default {
parseCookieParts,
getAppleConfig,
fetchAppleToken,
getAppleToken,
invalidateAppleToken,
appleAmpGet,
appleAmpGetTracked,
appleSongFromCatalog,
appleAlbumToPlaylist,
applePlaylistToPlaylist,
extractAppleID,
isAppleRawID,
extractAppleIDFromPath,
}
+582
View File
@@ -0,0 +1,582 @@
// 酷狗登录 —— token 绑定 / 扫码 / 短信验证码 / 账号密码(与 MakcRe/KuGouMusicApi 协议对齐)
// 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA;
// 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
// sid/edt 行为指纹,指纹由 kgSimulate.ts 服务端生成,无需浏览器 WASM);
// token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from './store.ts'
import { generateSimulate } from './kgSimulate.ts'
import type { KgCredentials } from './store.ts'
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
const APPID = 1005
const CLIENTVER = 11430
// 登录专用域按参考实现固定 clientver 20489(qr/短信接口实测通过的版本)
const LOGIN_CLIENTVER = 20489
const WEB_SALT = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt'
const SRC_APPID = 2919
const QR_URL_PREFIX = 'https://h5.kugou.com/apps/loginQRCode/html/index.html'
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----`
const calculateMid = (guid) => BigInt(`0x${md5(guid)}`).toString()
const randomString = (n) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
export const androidSign = (params, data = '') => {
const s = Object.keys(params).sort()
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
.join('')
return md5(SALT_SIGN + s + data + SALT_SIGN)
}
/** RSA PKCS1 v1.5 加密(register_dev 专用;与登录的 RSA 裸填充不同) */
const rsaEncryptPkcs1Hex = (data: object) =>
crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(JSON.stringify(data), 'utf8')).toString('hex')
// ---- 设备 dfid 正式注册(userservice.kugou.com /risk/v2/r_register_dev)----
// 随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
// 协议(参考 KuGouMusicApi register_dev.js):设备画像 → AES-128-CBC(随机 6 位 key,
// key=md5(key)[0:16]、iv=md5(key)[16:32]) → base64 作 body;p = RSA-PKCS1({aes:key, uid, token})。
// 响应为原始二进制密文(非 JSON),按 base64 解码后同密钥 AES 解密得 {status, data:{dfid}}。
const playlistAesEncrypt = (data: object) => {
const key = randomString(6)
const keyHex = md5(key)
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
return {
key,
str: Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]).toString('base64'),
}
}
const playlistAesDecrypt = (rawBase64: string, key: string) => {
const keyHex = md5(key)
const d = crypto.createDecipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
const out = Buffer.concat([d.update(Buffer.from(rawBase64, 'base64')), d.final()]).toString('utf8')
try { return JSON.parse(out) } catch { return out }
}
/** 注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍) */
export const registerDfid = async() => {
const dev = getDevice()
if ((dev as any).dfidRegistered) return dev
const cred = getCredentials('kg') as KgCredentials | null
const userid = Number(cred?.userid || 0)
const deviceMap = {
availableRamSize: 4983533568, availableRomSize: 48114719, availableSDSize: 48114717,
basebandVer: '', batteryLevel: 100, batteryStatus: 3,
brand: 'Redmi', buildSerial: 'unknown', device: 'marble',
imei: dev.guid, imsi: '', manufacturer: 'Xiaomi', uuid: dev.guid,
accelerometer: false, accelerometerValue: '', gravity: false, gravityValue: '',
gyroscope: false, gyroscopeValue: '', light: false, lightValue: '',
magnetic: false, magneticValue: '', orientation: false, orientationValue: '',
pressure: false, pressureValue: '', step_counter: false, step_counterValue: '',
temperature: false, temperatureValue: '',
}
const enc = playlistAesEncrypt(deviceMap)
const p = rsaEncryptPkcs1Hex({ aes: enc.key, uid: userid, token: cred?.token || '' })
const params: Record<string, any> = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
part: 1, platid: 1, p,
}
params.signature = androidSign(params, enc.str)
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://userservice.kugou.com/risk/v2/r_register_dev?${qs}`, {
method: 'post',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
body: enc.str,
timeout: 15000,
}).promise as any
// 响应为二进制密文:raw base64 → AES 解密
const raw = r.raw ? Buffer.from(r.raw).toString('base64') : Buffer.from(JSON.stringify(r.body ?? '')).toString('base64')
const body = playlistAesDecrypt(raw, enc.key)
if (body?.status !== 1 || !body.data?.dfid) {
throw new Error(`dfid 注册失败(status ${body?.status ?? '未知'} ${body?.error ?? ''})`.trim())
}
const updated = { ...dev, dfid: String(body.data.dfid), dfidRegistered: true }
setCredentials('_kg_device', updated)
return updated
}
/** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */
export const getDevice = () => {
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
if (!dev.mid) {
dev.mid = calculateMid(dev.guid)
setCredentials('_kg_device', dev)
}
return dev
}
/** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域);withHeaders 时返回 { body, headers } */
const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {}, withHeaders = false }: {
params?: Record<string, any>
data?: any
router?: string
host?: string
headers?: Record<string, string>
withHeaders?: boolean
}) => {
const dev = getDevice()
const params = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
...extraParams,
}
const signature = androidSign(params, data ? JSON.stringify(data) : '')
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`${host}${url}?${qs}&signature=${signature}`, {
method: 'post',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
...(router ? { 'x-router': router } : {}),
...extraHeaders,
},
body: data ?? undefined,
timeout: 15000,
}).promise
if (withHeaders) return { body: r.body ?? {}, headers: (r as any).headers ?? {} }
return r.body ?? {}
}
/**
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
* p = RSA 原始加密(零填充 128 字节){ token, clienttime }
*/
export const verifyToken = async({ token, userid }) => {
const unixSec = Math.floor(Date.now() / 1000)
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload])
const p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase()
const body = await gatewayRequest('/v3/get_my_info', {
router: 'usercenter.kugou.com',
params: { plat: 1 },
data: { visit_time: unixSec, usertype: 1, p, userid: Number(userid) || 0 },
})
if (body.status !== 1 || !body.data) {
const err = new Error(body.error ?? 'token 无效或已过期')
;(err as any).status = 401
throw err
}
return {
userid: body.data.userid ?? Number(userid),
nickname: body.data.nickname ?? body.data.username ?? null,
vip: body.data.vip ? Boolean(body.data.vip) : null,
}
}
/** 绑定并验证 kg token */
export const loginByToken = async({ token, userid }) => {
const profile = await verifyToken({ token, userid })
setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname })
return profile
}
// ---- 扫码登录(login-user.kugou.com,web 签名,盐与 H5 网页端一致)----
const webSign = (params) => {
const s = Object.keys(params).map(k => `${k}=${params[k]}`).sort().join('')
return md5(`${WEB_SALT}${s}${WEB_SALT}`)
}
/** login-user.kugou.com GET 请求(web 签名,与取流共用同一设备指纹) */
const loginUserGet = async(url, extraParams = {}) => {
const dev = getDevice()
const params = {
dfid: dev.dfid, mid: dev.mid, uuid: '-',
appid: APPID, clientver: LOGIN_CLIENTVER, clienttime: Math.floor(Date.now() / 1000),
...extraParams,
}
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://login-user.kugou.com${url}?${qs}&signature=${webSign(params)}`, {
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F',
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
timeout: 15000,
}).promise
const body = r.body ?? {}
if (body.status !== 1) {
const err = new Error(body.error ?? '酷狗登录服务请求失败')
;(err as any).status = 502
throw err
}
return body
}
/** 保存登录凭据并拉取账号资料(扫码/短信登录共用;资料校验失败不阻断登录) */
const applyKgLogin = async({ token, userid }) => {
try {
const profile = await verifyToken({ token, userid })
setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname })
return profile
} catch {
const uid = Number(userid) || 0
setCredentials('kg', { token, userid: uid })
return { userid: uid, nickname: null, vip: null }
}
}
/** 创建扫码登录 key 与二维码(qrimg 为可直接展示的 data URL) */
export const qrKey = async() => {
const body = await loginUserGet('/v2/qrcode', {
appid: 1001, type: 1, plat: 4,
qrcode_txt: `${QR_URL_PREFIX}?appid=${APPID}&`,
srcappid: SRC_APPID,
})
const key = body.data?.qrcode
if (!key) throw new Error('获取二维码 key 失败')
const qrurl = `${QR_URL_PREFIX}?qrcode=${key}`
const qrimg = body.data.qrcode_img
|| await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '')
return { key, qrurl, qrimg }
}
/**
* 轮询扫码状态(沿用登录路由的 800/801/802/803 语义,酷狗原生为 0/1/2/4)
* @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)
*/
export const qrCheck = async(key) => {
const body = await loginUserGet('/v2/get_userinfo_qrcode', {
plat: 4, appid: APPID, srcappid: SRC_APPID, qrcode: key,
})
const qrStatus = body.data?.status
if (qrStatus === 4) {
const { token, userid } = body.data
if (!token) throw new Error('登录响应中缺少 token')
return { code: 803, message: '登录成功', profile: await applyKgLogin({ token, userid }) }
}
const map = { 0: [800, '二维码已过期'], 1: [801, '等待扫码'], 2: [802, '已扫码,待确认'] }
const [code, message] = map[qrStatus] ?? [qrStatus, `未知状态 ${qrStatus}`]
return { code, message }
}
// ---- 手机验证码登录 ----
// 参考 KuGouMusicApi captcha_sent / login_cellphone:签名数据中 mobile 打码
// (前 2 位 + ***** + 末位),完整号码与验证码走 AES 随机密钥加密;
// pk = RSA 裸加密 {clienttime_ms, key},服务端解出密钥后还原 params。
const signLoginKey = (clienttimeMs) => md5(`${APPID}${SALT_SIGN}${LOGIN_CLIENTVER}${clienttimeMs}`)
/** 发送短信验证码(businessid 5 = 登录场景) */
export const sendCaptcha = async(mobile) => {
const body = await gatewayRequest('/v7/send_mobile_code', {
router: 'login.user.kugou.com',
params: { clientver: LOGIN_CLIENTVER },
data: { businessid: 5, mobile: String(mobile), plat: 3 },
})
if (body.status !== 1) {
const err = new Error(body.error ?? body.msg ?? '验证码发送失败')
;(err as any).status = 502
throw err
}
return { sent: true }
}
/** 手机验证码登录(mobile + code,成功后自动保存凭据并返回账号资料) */
export const cellphoneLogin = async({ mobile, code }) => {
const clienttimeMs = Date.now()
const enc = aesEncryptRandom({ mobile: String(mobile), code: String(code) })
const body = await gatewayRequest('/v7/login_by_verifycode', {
host: 'https://loginserviceretry.kugou.com',
params: { clientver: LOGIN_CLIENTVER },
data: {
plat: 1,
support_multi: 1,
t1: 0,
t2: 0,
clienttime_ms: clienttimeMs,
mobile: `${String(mobile).slice(0, 2)}*****${String(mobile).slice(10, 11)}`,
key: signLoginKey(clienttimeMs),
pk: rsaEncryptHex({ clienttime_ms: clienttimeMs, key: enc.key }).toUpperCase(),
params: enc.str,
t3: 'MCwwLDAsMCwwLDAsMCwwLDA=',
},
headers: {
'User-Agent': 'Android16-1070-11440-130-0-LOGIN-wifi',
'support-calm': '1',
},
})
if (body.status !== 1) {
const err = new Error(body.error ?? `登录失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
const decrypted = body.data?.secu_params ? aesDecryptHex(body.data.secu_params, enc.key) : null
const token = typeof decrypted === 'object' ? decrypted?.token : decrypted
if (!token) throw new Error('登录响应解密失败')
return applyKgLogin({ token, userid: decrypted?.userid ?? body.data?.userid ?? 0 })
}
// ---- token 自动续期 ----
// login_by_token 协议:
// p3 = AES({clienttime, token}),密钥由固定串派生;params = AES({})(随机密钥);
// pk = RSA 裸加密 {clienttime_ms, key: 随机密钥}(右侧零填充 128 字节);
// 成功响应 data.secu_params 用随机密钥 AES 解密得到新 token。
const AES_KEY_SEED = '90b8382a1bb4ccdcf063102053fd75b8'
const AES_IV = 'f063102053fd75b8'
const aesCbcEncryptHex = (data: string, keyHex: string, ivHex: string) => {
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(keyHex, 'utf8'), Buffer.from(ivHex, 'utf8'))
return Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]).toString('hex')
}
const aesEncryptRandom = (data: string | object) => {
const tempKey = randomString(16)
const keyHex = md5(tempKey)
return {
str: aesCbcEncryptHex(typeof data === 'string' ? data : JSON.stringify(data), keyHex, keyHex.slice(16, 32)),
key: tempKey,
}
}
const aesDecryptHex = (hex: string, tempKey: string) => {
const keyHex = md5(tempKey)
const d = crypto.createDecipheriv('aes-256-cbc', Buffer.from(keyHex, 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
const out = Buffer.concat([d.update(Buffer.from(hex, 'hex')), d.final()])
const text = out.toString('utf8')
try { return JSON.parse(text) } catch { return text }
}
/** RSA 裸加密(零填充 128 字节,与参考实现一致:负载在前、零在后) */
const rsaEncryptHex = (data: object) => {
const payload = Buffer.from(JSON.stringify(data))
if (payload.length > 128) throw new Error('RSA 负载超长')
const padded = Buffer.concat([payload, Buffer.alloc(128 - payload.length)])
return crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex')
}
/**
* 刷新 kg token(login_by_token),成功后写回凭据存储。
* 无登录态时返回 { skipped: true };网关拒绝(token 已失效等)抛错。
*/
export const refreshLogin = async() => {
const cred = getCredentials('kg') as KgCredentials | null
if (!cred?.token || cred.userid === '' || cred.userid === 0 || cred.userid === '0') {
return { skipped: true }
}
const clienttime = Math.floor(Date.now() / 1000)
const p3 = aesCbcEncryptHex(JSON.stringify({ clienttime, token: cred.token }), md5(AES_KEY_SEED), AES_IV)
const enc = aesEncryptRandom({})
const pk = rsaEncryptHex({ clienttime_ms: clienttime * 1000, key: enc.key })
const body = await gatewayRequest('/v5/login_by_token', {
router: 'login.user.kugou.com',
params: { clientver: 20349 },
data: {
p3,
params: enc.str,
userid: Number(cred.userid) || 0,
need_toneinfo: 1,
clienttime_ms: clienttime * 1000,
dfid: '-',
dev: 'SDY-AN00',
plat: 1,
pk,
t1: '0',
gitversion: 'a23c277',
t2: '0',
t3: 'MCwwLDEsMSwwLDYsMSw2LDA=',
},
headers: {
'kg-thash': '6a6a1ba',
'User-Agent': 'Android12-AndroidPhone-20349-201-0-ting#958959317/661004247-LOGIN-wifi',
'support-calm': '1',
},
})
if (body.error_code !== 0 || !body.data?.secu_params) {
throw new Error(`刷新失败(code ${body.error_code}${body.error ? `: ${body.error}` : ''})`)
}
const decrypted = aesDecryptHex(body.data.secu_params, enc.key)
if (!decrypted?.token) throw new Error('刷新响应解密失败')
setCredentials('kg', { token: decrypted.token })
return { refreshed: true }
}
// ---- 账号密码登录(/v9/login_by_pwd)+ 20028 验证码挑战 ----
// 参考 KuGouMusicApi login.js / get_verify_info.js / verify_user_info.js 与 issue #206:
// 密码与验证码走 AES 随机密钥加密(params/pk);风控触发时响应 error_code=20028,
// 事件 id 在响应头 ssa-code(gz_tx_event_xxx);/verifyservice/v3/get_verify_info
// 按 data.url/v_type 区分验证类型(23 腾讯滑块、32 手机验证码、22 活体、34 人脸…);
// 验证结果经 /v4/verify_user_info 提交(verifycode 格式 KGCodeXX|{json} 或短信码),
// sid/edt 为行为指纹(kgSimulate 服务端生成),随后重试登录(短信码经 code 字段随密文上送)。
// login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
const PWD_T1 = '562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c03a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5adcdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac'
const PWD_T2 = '31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294aeebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db1400fdf49b7cf7e26be2e5af4da7830cc3be4'
const PWD_T3 = 'MCwwLDAsMCwwLDAsMCwwLDA='
const PWD_CLIENTVER = 11440
// 验证码类型(get_verify_info 的 data.url 前缀 / v_type)
const VERIFY_VTYPES: Record<string, string> = {
16: 'QQ 绑定验证', 18: '微信绑定验证', 22: '活体认证', 23: '滑块/图形验证码', 32: '手机短信验证码', 34: '人脸核身',
}
const VERIFY_URL_TYPES: Record<string, string> = {
KGCodeGT: '极验', KGCodeTX: '腾讯', KGCodeKG: '酷狗旋转', KGCodeKG2: '酷狗滑块', KGCodeSM: '数美', KGCodeYD: '易盾',
}
export interface VerifyChallenge {
eventid: string
v_type: number
vTypeLabel: string
url?: string
provider?: string
txappid?: string
sessionid?: string
sid: string
edt: string
}
/** 查询验证事件详情(/verifyservice/v3/get_verify_info) */
export const getVerifyInfo = async(eventid: string) => {
const body = await gatewayRequest('/verifyservice/v3/get_verify_info', {
data: {
eventid, userid: Number(getCredentials('kg')?.userid ?? 0), platid: 2, rtype: 1,
wasm: 1, i: '', sid: '', edt: '',
},
})
if (body.status !== 1 && body.error_code !== 0) {
throw new Error(body.error ?? `查询验证信息失败(code ${body.error_code ?? '未知'})`)
}
return body.data ?? {}
}
/** 提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码) */
export const verifyUserInfo = async({ eventid, v_type, verifycode, sid, edt }: {
eventid: string
v_type: number
verifycode: string
sid?: string
edt?: string
}) => {
const dev = getDevice()
const userid = Number(getCredentials('kg')?.userid ?? 0)
const fp = sid && edt ? { sid, edt } : generateSimulate(dev.mid, userid, dev.dfid)
const dataMap: Record<string, any> = {
eventid, userid, platid: 2, v_type: Number(v_type) || 23, wasm: 1, i: '',
sid: fp.sid, edt: fp.edt,
}
if (dataMap.v_type === 32) {
// 手机短信验证码:明文 code + 密文 params 均携带
const enc = aesEncryptRandom({ code: verifycode })
Object.assign(dataMap, {
code: verifycode, verifycode,
pk: rsaEncryptHex({ key: enc.key }), params: enc.str,
})
} else {
// 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
const enc = aesEncryptRandom({})
Object.assign(dataMap, {
verifycode,
pk: rsaEncryptHex({ key: enc.key }), params: enc.str,
})
}
const body = await gatewayRequest('/v4/verify_user_info', {
host: 'https://verifyservice.kugou.com',
params: { clientver: 11510 },
data: dataMap,
})
if (body.status !== 1) {
const err = new Error(body.error ?? `验证提交失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
return { verified: true }
}
/**
* 账号密码登录(/v9/login_by_pwd)
* 成功返回账号资料;命中 20028 风控返回 VerifyChallenge(先 /login/kg/verify 提交验证,
* 短信码场景再带 code 重试本接口)
*/
export const passwordLogin = async({ username, password, code }: { username: string, password: string, code?: string }) => {
// 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
await registerDfid().catch(() => {})
const clienttimeMs = Date.now()
const enc = aesEncryptRandom({ pwd: String(password), code: String(code || ''), clienttime_ms: clienttimeMs })
const { body, headers } = await gatewayRequest('/v9/login_by_pwd', {
router: 'login.user.kugou.com',
params: { clientver: PWD_CLIENTVER },
data: {
plat: 1,
support_multi: 1,
clienttime_ms: clienttimeMs,
t1: PWD_T1,
t2: PWD_T2,
t3: PWD_T3,
dev: 'SDY-AN00',
username: String(username),
params: enc.str,
pk: rsaEncryptHex({ clienttime_ms: clienttimeMs, key: enc.key }).toUpperCase(),
},
headers: {
'User-Agent': `Android16-1070-${PWD_CLIENTVER}-130-0-LOGIN-wifi`,
'support-calm': '1',
},
withHeaders: true,
}) as { body: any, headers: Record<string, any> }
if (body.status === 1 && body.data?.secu_params) {
const decrypted = aesDecryptHex(body.data.secu_params, enc.key)
const token = typeof decrypted === 'object' ? decrypted?.token : decrypted
if (!token) throw new Error('登录响应解密失败')
return applyKgLogin({ token, userid: decrypted?.userid ?? body.data?.userid ?? 0 })
}
if (body.error_code === 20028) {
const eventid = String(headers?.['ssa-code'] ?? headers?.['SSA-CODE'] ?? body.ssaCode ?? '')
if (!eventid) {
throw new Error('触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录')
}
const dev = getDevice()
const userid = Number(getCredentials('kg')?.userid ?? 0)
const { sid, edt } = generateSimulate(dev.mid, userid, dev.dfid)
const info = await getVerifyInfo(eventid).catch(() => ({}) as any)
const vType = Number(info.v_type ?? 23)
const url: string = String(info.url ?? '')
const provider = Object.keys(VERIFY_URL_TYPES).find(k => url.includes(k))
const challenge: VerifyChallenge = {
eventid,
v_type: vType,
vTypeLabel: VERIFY_VTYPES[vType] ?? `未知类型 ${vType}`,
url: url || undefined,
provider: provider ? VERIFY_URL_TYPES[provider] : undefined,
txappid: info.txappid,
sessionid: info.sessionid,
sid,
edt,
}
return challenge
}
const err = new Error(body.error ?? `登录失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
/** 当前 kg 登录资料(未登录返回 null) */
export const status = () => {
const cred = getCredentials('kg')
return cred?.token ? { userid: cred.userid, nickname: cred.nickname ?? null } : null
}
+116
View File
@@ -0,0 +1,116 @@
// 酷狗行为指纹模拟(sid/edt 生成)——移植 MakcRe/KuGouMusicApi util/generate_simulate.js
// 用途:20028 二次验证时替代浏览器 WASM 上报行为数据。
// 协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流>
// edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64
// sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制)
// 事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔,
// 每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。
import crypto from 'node:crypto'
const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex')
const SIM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF
lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/
W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA
2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW
PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc
56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa
rQIDAQAB
-----END PUBLIC KEY-----`
const SIM_IV = 'kugousecurity123'
const ri = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const randomString = (n: number) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
let sentinel = 0xffffffff - ri(0, 20)
const f3 = (t: number | string, i: number, x: number, y: number) => `3,${t},${i},${x},${y}`
const f5 = (t: number | string, i: number) => `5,${t},${i}`
const f6 = (t: number | string, i: number, x: number, y: number) => `6,${t},${i},${x},${y}`
// 三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛
const bezierPath = (sx: number, sy: number, ex: number, ey: number, n: number) => {
const c1x = sx + (ex - sx) * 0.3 + ri(-80, 80)
const c1y = sy + (ey - sy) * 0.2 + ri(-60, 60)
const c2x = sx + (ex - sx) * 0.7 + ri(-60, 60)
const c2y = sy + (ey - sy) * 0.8 + ri(-40, 40)
const pts: Array<{ x: number, y: number }> = []
for (let i = 0; i <= n; i++) {
const t = i / n
const u = 1 - t
const x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
const y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
const jitter = Math.max(0.5, 3 - t * 2.5)
pts.push({ x: x + (Math.random() - 0.5) * jitter, y: y + (Math.random() - 0.5) * jitter })
}
return pts
}
const generateEdtData = ({ startX, startY, endX, endY, mousePoints }: {
startX: number, startY: number, endX: number, endY: number, mousePoints: number
}) => {
const entries: string[] = []
let ts = 0
let ei = 0
entries.push(f5(0, 0), f5(sentinel, 0), f5(0, 0), f5(sentinel, 0))
ts += ri(5, 20)
entries.push(f6(ts, ei, 750, 500), f6(sentinel, ei, 750, 500))
ei++
for (let i = 0; i < 3; i++) {
ts += ri(80, 600)
entries.push(f5(ts, ei), f5(sentinel, ei))
ei++
}
const path = bezierPath(startX, startY, endX, endY, mousePoints)
let si = 0
for (let i = 0; i < path.length; i++) {
const { x, y } = path[i]
ts += ri(8, 50)
const rx = Math.round(x)
const ry = Math.round(y)
entries.push(f3(ts, si, rx, ry), f3(sentinel, si, rx, ry))
if (i > 0 && i % 12 === 0) {
ts += ri(20, 60)
entries.push(f5(ts, ei), f5(sentinel, ei))
ei++
}
si = (si + 1) % 2
}
ts += ri(5, 30)
entries.push(f3(ts, 1, Math.round(endX + ri(-5, 5)), Math.round(endY + ri(-5, 5))), f3(sentinel, 1, Math.round(endX), Math.round(endY)))
return entries.join(':')
}
/** 生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送 */
export const generateSimulate = (mid: string | number, userid: string | number, dfid: string, webglHash?: string) => {
sentinel = 0xffffffff - ri(0, 20)
const key = md5(randomString(16)).slice(0, 16)
const points = ri(30, 60)
const startX = ri(200, 600)
const startY = ri(200, 500)
const endX = ri(500, 700)
const endY = ri(80, 150)
const webgl = webglHash || md5(randomString(32))
const plaintext = `mid=${mid || 0};userid=${userid || 0};dfid=${dfid || 0};webgl=${webgl};webdriver=0;ts=${Date.now()};data=${generateEdtData({ startX, startY, endX, endY, mousePoints: points })}`
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(key, 'utf8'), Buffer.from(SIM_IV, 'utf8'))
const edt = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]).toString('base64')
const sid = crypto.publicEncrypt(
{ key: SIM_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(key, 'utf8'),
).toString('base64')
return { edt, sid }
}
export default { generateSimulate }
+148
View File
@@ -0,0 +1,148 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_passport_device: {...} }
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const DATA_DIR = process.env.DATA_DIR
? process.env.DATA_DIR
: join(dirname(fileURLToPath(import.meta.url)), '../../data')
const DATA_FILE = join(DATA_DIR, 'auth.json')
export interface WyCredentials {
cookie: string
musicU: string
userid?: number
nickname?: string | null
avatar?: string | null
vipType?: number | null
updatedAt?: string
}
export interface KgCredentials {
token: string
userid: number | string
nickname?: string | null
updatedAt?: string
}
export interface KgDevice {
guid: string
mid: string
dfid: string
updatedAt?: string
}
export interface TxCredentials {
cookie: string
// App 抓包的完整凭据(可选):提供后可自动续期(music.login.LoginServer),
// 仅浏览器 cookie 时 qm_keyst 无法主动续期,只能做 VIP 检测
openId?: string
accessToken?: string
refreshToken?: string
refreshKey?: string
uin?: string
vipType?: 'svip' | 'vip' | 'normal' | null
updatedAt?: string
}
export interface XmCredentials {
// www.ximalaya.com 登录 Cookie(须含 1&_token,从已登录浏览器请求头复制)
// baseInfo 付费内容授权依赖它;无法主动续期,失效后需重新复制绑定
cookie: string
updatedAt?: string
}
export interface MeCredentials {
// App 登录 Cookie(须含 token=,从猫耳 App 抓包或已登录 m.missevan.com 浏览器复制)
// App API 付费声音授权依赖它;无法主动续期
cookie: string
updatedAt?: string
}
export interface LrCredentials {
// m.lrts.me 登录会话 Cookie(/login/lr 账号密码登录后由服务端保存)
// 取流接口必须携带(免费章节也要求登录态)
cookie: string
phone?: string
updatedAt?: string
}
let cache: Record<string, any> = {}
let loaded = false
const load = (): Record<string, any> => {
if (loaded) return cache
if (!existsSync(DATA_FILE)) {
cache = {}
loaded = true
return cache
}
try {
cache = JSON.parse(readFileSync(DATA_FILE, 'utf8'))
} catch (err) {
// 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
// 否则下一次 setCredentials 就会用空表覆盖掉本可恢复的文件。
const backup = `${DATA_FILE}.corrupt-${Date.now()}`
try {
renameSync(DATA_FILE, backup)
console.error(`[auth/store] ${DATA_FILE} 解析失败,已备份为 ${backup},本次以空凭据启动: ${(err as Error).message}`)
} catch {
console.error(`[auth/store] ${DATA_FILE} 解析失败且备份未成功,本次以空凭据启动: ${(err as Error).message}`)
}
cache = {}
}
loaded = true
return cache
}
const save = (data: Record<string, any>): void => {
cache = data
loaded = true
mkdirSync(dirname(DATA_FILE), { recursive: true })
// 原子写:先落同目录临时文件(0600,仅属主可读),再 rename 覆盖。
// 直接覆写会在进程中途被杀时留下截断 JSON —— 那正是「凭据全丢」的触发路径。
const tmp = `${DATA_FILE}.${process.pid}.tmp`
try {
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
renameSync(tmp, DATA_FILE)
} catch (err) {
try { unlinkSync(tmp) } catch { /* 清理失败不掩盖原始错误 */ }
throw err
}
}
export const getCredentials = (platform: string): any => load()[platform] ?? null
export const setCredentials = (platform: string, credentials: Record<string, any>): void => {
const data = load()
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
save(data)
}
export const clearCredentials = (platform: string): void => {
const data = load()
delete data[platform]
save(data)
}
/** wy 登录 cookie 字符串(未登录返回空串) */
export const getWyCookie = (): string => (getCredentials('wy') as WyCredentials | null)?.cookie || ''
/** kg 登录态(未登录返回 null) */
export const getKgAuth = (): { token: string; userid: number | string } | null => {
const cred = getCredentials('kg') as KgCredentials | null
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
}
/** tx(QQ) 登录态:cookie 与其中的 uin(未登录返回 null) */
export const getTxAuth = (): { cookie: string; uin: string } | null => {
const cred = getCredentials('tx') as TxCredentials | null
if (!cred?.cookie) return null
const uin = cred.cookie.match(/uin=(\d+)/)?.[1] ?? '0'
return { cookie: cred.cookie, uin }
}
+294
View File
@@ -0,0 +1,294 @@
// QQ音乐凭据 —— cookie 解析、扫码登录、VIP 检测与登录态续期(music.login.LoginServer)
// 登录态续期走本项目既有的 musicu.fcg 未签名通道(与 apis/tx.ts 一致);
// Login/VipLogin 模块若拒绝未签名请求则以 code != 0 失败,
// 此时保留原凭据并抛错,由调用方记录日志。
// 续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试,
// 失败率较高;完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。
// 扫码登录(参考 sansenjian/qq-music-api):ptlogin 扫码 → check_sig →
// graph.qq.com OAuth authorize 换 code → QQConnectLogin.LoginServer/QQLogin 换凭据,
// 成功响应体一般携带 refresh_key 等完整字段,可走自动续期。
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from './store.ts'
import type { TxCredentials } from './store.ts'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
Referer: 'https://y.qq.com/',
}
/** 解析 cookie 中的关键字段(uin / qm_keyst 即 musickey / tmeLoginType) */
export const parseCookie = (cookie: string) => ({
uin: cookie.match(/uin=(\d+)/)?.[1] ?? '0',
musickey: cookie.match(/qm_keyst=([^;]+)/)?.[1] ?? '',
tmeLoginType: cookie.match(/tmeLoginType=(\d+)/)?.[1] ?? '',
})
const musicuRequest = async(data: object, cred: TxCredentials | null) => {
const r = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: { ...HEADERS, ...(cred?.cookie ? { cookie: cred.cookie } : {}) },
body: data,
timeout: 15000,
}).promise
return r.body ?? {}
}
/**
* 检测当前账号 VIP 等级(vip_login_base),结果写入凭据存储。
* 成功返回 'svip' / 'vip' / 'normal';请求失败返回 null(不改动已存值)。
*/
export const checkVip = async() => {
const cred = getCredentials('tx') as TxCredentials | null
if (!cred?.cookie) return null
const { uin } = parseCookie(cred.cookie)
const body = await musicuRequest({
comm: { ct: '19', cv: '1859', uin },
req: {
module: 'VipLogin.VipLoginInter',
method: 'vip_login_base',
param: {},
},
}, cred)
if (body.req?.code !== 0) return null
const identity = body.req.data?.identity ?? {}
const vipType = identity.HugeVip ? 'svip' : identity.LMFlag ? 'vip' : 'normal'
setCredentials('tx', { vipType })
return vipType
}
/**
* 续期 tx 登录态(music.login.LoginServer),成功后用新 musickey 替换 cookie 中的
* qm_keyst 并写回凭据存储(附带 openId 等抓包字段时一并更新)。随后顺带检测 VIP。
* 无登录态返回 { skipped: true };网关拒绝抛错。
*/
export const refreshLogin = async() => {
const cred = getCredentials('tx') as TxCredentials | null
if (!cred?.cookie) return { skipped: true }
const { uin, musickey } = parseCookie(cred.cookie)
if (!musickey) throw new Error('cookie 缺少 qm_keyst 字段')
const body = await musicuRequest({
comm: { ct: '19', cv: '1859', uin },
req: {
module: 'music.login.LoginServer',
method: 'Login',
param: {
...{ musicid: Number(uin) || 0, musickey, loginMode: 2, expired_in: 0 },
// App 抓包的完整凭据(存在才携带)
...(cred.openId ? { openid: cred.openId } : {}),
...(cred.accessToken ? { access_token: cred.accessToken } : {}),
...(cred.refreshToken ? { refresh_token: cred.refreshToken } : {}),
...(cred.refreshKey ? { refresh_key: cred.refreshKey } : {}),
},
},
}, cred)
if (body.req?.code !== 0 || !body.req.data?.musickey) {
throw new Error(`刷新失败(code ${body.req?.code}${body.req?.msg ? `: ${body.req.msg}` : ''})`)
}
const data = body.req.data
// musickey 可能含 $ 等字符,必须用函数替换(字符串替换的 $&/$' 会被转义展开)
const cookie = cred.cookie.replace(/qm_keyst=[^;]*/, () => `qm_keyst=${data.musickey}`)
setCredentials('tx', {
cookie,
uin: String(data.musicid ?? uin),
...(data.openid ? { openId: String(data.openid) } : {}),
...(data.access_token ? { accessToken: String(data.access_token) } : {}),
...(data.refresh_key ? { refreshKey: String(data.refresh_key) } : {}),
})
const vipType = await checkVip().catch(() => null)
return { refreshed: true, ...(vipType ? { vipType } : {}) }
}
// ---------------- 扫码登录 ----------------
/** ptqrtoken:hash33(qrsig) */
const hash33 = (str: string): number => {
let hash = 0
for (let i = 0; i < str.length; ++i) hash += (hash << 5) + str.charCodeAt(i)
return 2147483647 & hash
}
/** g_tk:p_skey 的 5381 哈希 */
const getGtk = (skey: string): number => {
let hash = 5381
for (let i = 0; i < skey.length; ++i) hash += (hash << 5) + skey.charCodeAt(i)
return 2147483647 & hash
}
const randomGuid = (): string => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16).toUpperCase()
})
/** 登录链路共享的 cookie 罐:同名后到优先,存「name=value」对 */
class CookieJar {
private jar = new Map<string, string>()
merge(setCookie: string[] | undefined) {
for (const item of setCookie ?? []) {
const pair = item.split(';')[0]
const name = pair.split('=')[0]?.trim()
if (name && pair.includes('=')) this.jar.set(name, pair)
}
}
get(name: string) { return this.jar.get(name)?.split('=').slice(1).join('=') ?? '' }
entries() { return this.jar.entries() }
header() { return [...this.jar.values()].join('; ') }
}
const ptloginRequest = async(path: string, query: Record<string, string>, cookie: string) => {
const r = await httpFetch(`https://ssl.ptlogin2.qq.com/${path}?${new URLSearchParams(query)}`, {
method: 'get',
...(cookie ? { headers: { cookie } } : {}),
timeout: 15000,
}).promise
if (r.statusCode !== 200) {
const err = new Error(`ptlogin ${path} 失败(HTTP ${r.statusCode})`) as any
err.statusCode = r.statusCode
throw err
}
return r
}
/** 创建扫码登录二维码(客户端扫码后轮询 qrCheck),qrsig 需原样回传 */
export const qrShow = async() => {
const r = await httpFetch(
'https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4' +
`&t=${Math.random()}&daid=383&pt_3rd_aid=100497308&u1=${encodeURIComponent('https://graph.qq.com/oauth2.0/login_jump')}`,
{ method: 'get', timeout: 15000 },
).promise
const qrsig = (r.headers['set-cookie'] ?? []).map(c => c.match(/qrsig=([^;]+)/)?.[1]).find(Boolean)
if (!qrsig) throw new Error('获取二维码失败(响应缺少 qrsig)')
return { qrimg: `data:image/png;base64,${r.raw.toString('base64')}`, qrsig }
}
/**
* 轮询扫码状态(与 wy 扫码一致的状态码语义)
* @returns {{ code: 800|801|802|803, message?: string, uin?, vipType? }} 800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存)
*/
export const qrCheck = async(qrsig: string) => {
if (!qrsig) {
const err = new Error('缺少 qrsig') as any
err.status = 422
throw err
}
const jar = new CookieJar()
jar.merge([`qrsig=${qrsig}`])
// 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中
// qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理
let poll
try {
poll = await ptloginRequest('ptqrlogin', {
u1: 'https://graph.qq.com/oauth2.0/login_jump',
ptqrtoken: String(hash33(qrsig)),
ptredirect: '0', h: '1', t: '1', g: '1',
from_ui: '1', ptlang: '2052',
action: `0-0-${Date.now()}`,
js_ver: '23111510', js_type: '1',
pt_uistyle: '40', aid: '716027609', daid: '383',
pt_3rd_aid: '100497308',
o1vId: randomGuid(),
}, jar.header())
} catch (err: any) {
if (err.statusCode === 403) return { code: 800, message: '二维码已失效' }
throw err
}
jar.merge(poll.headers['set-cookie'])
const text = typeof poll.body === 'string' ? poll.body : ''
const cb = text.match(/ptuiCB\('(\d+)','[^']*','([^']*)'/)
const state = cb?.[1] ?? ''
if (state !== '0') {
const states: Record<string, [number, string]> = {
65: [800, '二维码已失效'],
66: [801, '等待扫码'],
67: [802, '已扫码,待确认'],
68: [802, '授权中'],
}
const [code, message] = states[state] ?? [801, '等待扫码']
return { code, message }
}
// 2. check_sig 换 p_skey(g_tk 需要)
const checkSigUrl = cb?.[2]
if (!checkSigUrl?.startsWith('http')) throw new Error('登录检查失败(未返回 check_sig 地址)')
const checkSig = await httpFetch(checkSigUrl, {
method: 'get',
headers: { cookie: jar.header() },
timeout: 15000,
}).promise
jar.merge(checkSig.headers['set-cookie'])
const pSkey = jar.get('p_skey')
if (!pSkey) throw new Error('登录检查失败(响应缺少 p_skey)')
// 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台)
const authorize = await httpFetch('https://graph.qq.com/oauth2.0/authorize', {
method: 'post',
headers: { Referer: 'https://graph.qq.com/oauth2.0/authorize', cookie: jar.header() },
form: {
response_type: 'code',
client_id: '100497308',
redirect_uri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/',
scope: 'get_user_info,get_app_friends',
state: 'state',
switch: '',
from_ptlogin: '1',
src: '1',
update_auth: '1',
openapi: '1010_1030',
g_tk: String(getGtk(pSkey)),
auth_time: String(Date.now()),
ui: randomGuid(),
},
timeout: 15000,
}).promise
jar.merge(authorize.headers['set-cookie'])
const code = String(authorize.headers.location ?? '').match(/[?&]code=([^&]+)/)?.[1]
if (!code) throw new Error('OAuth 授权失败(未取得 code)')
// 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段),
// 同时响应 Set-Cookie 也会下发 y.qq.com 登录 cookie
const login = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: { ...HEADERS, cookie: jar.header() },
body: {
comm: { g_tk: getGtk(pSkey), platform: 'yqq', ct: 24, cv: 0 },
req: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param: { code } },
},
timeout: 15000,
}).promise
jar.merge(login.headers['set-cookie'])
const data = login.body?.req?.data ?? {}
const musickey = data.musickey || jar.get('qm_keyst')
if (!musickey) throw new Error('登录失败(未取得 musickey)')
// ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉
const uin = String(data.musicid || jar.get('uin')).replace(/^o0*/, '') || '0'
// 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声)
const pairs = [`uin=${uin}`, `qm_keyst=${musickey}`]
if (data.encryptUin) pairs.push(`euin=${data.encryptUin}`)
for (const [name, pair] of jar.entries()) {
if (!['qrsig', 'ptui_loginuin', 'uin', 'qm_keyst', 'euin'].includes(name)) pairs.push(pair)
}
setCredentials('tx', {
cookie: pairs.join('; '),
uin,
...(data.openid ? { openId: String(data.openid) } : {}),
...(data.access_token ? { accessToken: String(data.access_token) } : {}),
...(data.refresh_token ? { refreshToken: String(data.refresh_token) } : {}),
...(data.refresh_key ? { refreshKey: String(data.refresh_key) } : {}),
})
const vipType = await checkVip().catch(() => null)
return { code: 803, message: '登录成功', uin, ...(vipType ? { vipType } : {}) }
}
+142
View File
@@ -0,0 +1,142 @@
// 网易云登录 —— 扫码 / 手机号,weapi 加密请求
// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts'
import { weapi } from '../wy/utils/crypto.ts'
import { getWyCookie, setCredentials } from './store.ts'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
const requestWeapi = async(path, data, cookie = getWyCookie()) => {
const r = await httpFetch(`https://music.163.com/weapi${path}`, {
method: 'post',
headers: {
'User-Agent': UA,
origin: 'https://music.163.com',
Referer: 'https://music.163.com/',
...(cookie ? { cookie } : {}),
},
form: weapi(data),
timeout: 15000,
}).promise
if (r.statusCode !== 200) throw new Error('网易请求失败')
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
}
/** 明文 api 请求(二维码接口只提供该形态,weapi 形态已 404) */
const requestApi = async(path, data) => {
const r = await httpFetch(`https://music.163.com${path}`, {
method: 'post',
headers: {
'User-Agent': UA,
origin: 'https://music.163.com',
Referer: 'https://music.163.com/',
},
form: data,
timeout: 15000,
}).promise
if (r.statusCode !== 200) throw new Error('网易请求失败')
return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] }
}
/** 从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U */
const extractLoginCookie = (setCookie) => {
const joined = setCookie.map(c => c.split(';')[0]).filter(Boolean).join('; ')
const musicU = /MUSIC_U=([^;]+)/.exec(joined)?.[1]
return { cookie: joined, musicU }
}
/** 登录成功后拉取账号资料 */
const fetchProfile = async() => {
const { body } = await requestWeapi('/api/w/nuser/account/get', {})
return {
userid: body?.account?.id ?? null,
nickname: body?.profile?.nickname ?? null,
avatar: body?.profile?.avatarUrl ?? null,
vipType: body?.profile?.vipType ?? null,
}
}
/** 应用登录 cookie(成功后调用) */
const applyLogin = async(setCookie) => {
const { cookie, musicU } = extractLoginCookie(setCookie)
if (!musicU) throw new Error('登录响应中缺少 MUSIC_U')
const profile = await fetchProfile()
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 创建扫码登录 key 与二维码内容 */
export const qrKey = async() => {
const { body } = await requestApi('/api/login/qrcode/unikey', { type: 3 })
if (body?.code !== 200 || !body.unikey) throw new Error('获取二维码 key 失败')
const key = body.unikey
const qrurl = `https://music.163.com/login?codekey=${key}`
const qrimg = await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '')
return { key, qrurl, qrimg }
}
/**
* 轮询扫码状态
* @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)
*/
export const qrCheck = async(key) => {
const { body, setCookie } = await requestApi('/api/login/qrcode/client/login', { key, type: 3 })
if (body.code === 803) {
const profile = await applyLogin(setCookie)
return { code: 803, message: '登录成功', profile }
}
const messages = { 800: '二维码已过期', 801: '等待扫码', 802: '已扫码,待确认' }
return { code: body.code, message: messages[body.code] ?? body.message ?? '未知状态' }
}
/**
* 手机号登录(密码为明文,传输前本地 MD5;或直接传验证码 captcha)
*/
export const cellphoneLogin = async({ phone, password, captcha, countrycode = '86' }) => {
const data = {
type: '1',
https: 'true',
phone,
countrycode,
remember: 'true',
[captcha ? 'captcha' : 'password']: captcha ?? crypto.createHash('md5').update(password ?? '').digest('hex'),
}
const { body, setCookie } = await requestWeapi('/api/w/login/cellphone', data)
if (body.code !== 200) {
const err = new Error(body.message ?? body.msg ?? '登录失败')
;(err as any).status = 401
throw err
}
const profile = await applyLogin(setCookie)
return profile
}
/**
* 手动绑定 cookie(从已登录的 music.163.com 浏览器请求头复制,须含 MUSIC_U)。
* 绑定前用 account/get 实测校验,失效直接报 401,与 kg 绑定 token 同款语义。
*/
export const loginByCookie = async(cookieInput: string) => {
const cookie = String(cookieInput || '').trim()
const musicU = /MUSIC_U=([^;\s]+)/.exec(cookie)?.[1]
if (!musicU) throw Object.assign(new Error('cookie 需包含 MUSIC_U 字段'), { status: 422 })
const { body } = await requestWeapi('/api/w/nuser/account/get', {}, cookie)
if (!body?.account?.id) {
throw Object.assign(new Error('cookie 无效或已过期(MUSIC_U 校验未通过)'), { status: 401 })
}
const profile = {
userid: body.account.id,
nickname: body.profile?.nickname ?? null,
avatar: body.profile?.avatarUrl ?? null,
vipType: body.profile?.vipType ?? null,
}
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 当前账号资料(未登录返回 null) */
export const status = async() => {
if (!getWyCookie()) return null
return fetchProfile()
}
+180
View File
@@ -0,0 +1,180 @@
// 千千音乐专辑
// 搜索:/v1/search?type=3(特殊字符触发 errno 23001 时用清洗后的关键词重试,见 searchAlbumItems)
// 详情:/v1/album/info?albumAssetCode=P...(P 码可直接用;纯数字旧 id 走 /v1/album/albumid2psid 转换)
// 专辑链接:https://music.91q.com/album/<albumAssetCode>
import { APP_ID, apiGet, apiOk, joinArtists, normalizeAlbumAssetCode, mapTrack, releaseDate10, sanitizeAlbumKeyword, albumLink } from './util.ts'
export interface QianAlbumSearchItem {
albumAssetCode?: string
title?: string
pic?: string
introduce?: string
releaseDate?: string
genre?: string
lang?: string
artist?: any[]
trackList?: any[]
}
const mapAlbumSummary = (item: QianAlbumSearchItem) => {
const id = normalizeAlbumAssetCode(item?.albumAssetCode)
return {
id,
name: String(item?.title ?? '').trim(),
cover: String(item?.pic ?? '').trim(),
trackCount: Array.isArray(item?.trackList) ? item.trackList.length : 0,
creator: joinArtists(item?.artist),
description: String(item?.introduce ?? '').trim(),
releaseDate: releaseDate10(item?.releaseDate),
genre: String(item?.genre ?? '').trim(),
lang: String(item?.lang ?? '').trim(),
link: id ? albumLink(id) : '',
source: 'bd' as const,
}
}
/** 单次专辑搜索;23001(词非法)时抛出带 retryWithSanitized 标记的错误 */
const searchAlbumOnce = async(word: string, page = 1, limit = 10) => {
const body = await apiGet('/v1/search', {
word,
type: '3',
pageNo: String(Math.max(1, page)),
pageSize: String(Math.max(1, limit)),
appid: APP_ID,
})
if (!apiOk(body)) {
const err: any = new Error(body?.errmsg || `千千专辑搜索失败(errno ${body?.errno})`)
err.retryWithSanitized = Number(body?.errno) === 23001
throw err
}
const list = body?.data?.typeAlbum
return Array.isArray(list) ? (list as QianAlbumSearchItem[]) : []
}
/** 上游 searchAlbumItems:原词失败(23001)时用 sanitizeQianqianAlbumKeyword 清洗后重试一次 */
export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Promise<QianAlbumSearchItem[]> => {
const words = [String(keyword ?? '').trim()]
const sanitized = sanitizeAlbumKeyword(keyword)
if (sanitized && sanitized !== words[0]) words.push(sanitized)
let lastErr: Error | null = null
for (const word of words) {
try {
return await searchAlbumOnce(word, page, limit)
} catch (err: any) {
lastErr = err
// 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
if (!err?.retryWithSanitized) break
}
}
if (lastErr) throw lastErr
return []
}
/** 搜索专辑(返回歌单/专辑统一摘要形状) */
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
const word = String(keyword ?? '').trim()
const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
if (!word) return empty
const items = await searchAlbumItems(word, page, limit)
const list = items
.map(mapAlbumSummary)
.filter((album) => album.id)
.slice(0, limit)
return { source: 'bd' as const, list, total: items.length, page, limit }
}
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
export const resolveAlbumAssetCode = async(id: string): Promise<string> => {
const raw = String(id ?? '').trim()
if (!raw) throw new Error('千千专辑 id 为空')
const normalized = normalizeAlbumAssetCode(raw)
if (normalized) return normalized
const body = await apiGet('/v1/album/albumid2psid', { albumid: raw, appid: APP_ID })
if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑 id 转换失败(errno ${body?.errno})`)
for (const item of Array.isArray(body?.data) ? body.data : []) {
const psid = normalizeAlbumAssetCode(item?.psid)
if (psid) return psid
}
throw new Error('千千专辑 asset code 未找到')
}
const fetchAlbumInfo = async(id: string) => {
const albumId = await resolveAlbumAssetCode(id)
const body = await apiGet('/v1/album/info', { albumAssetCode: albumId, appid: APP_ID })
if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑详情获取失败(errno ${body?.errno})`)
const data = body?.data ?? {}
if (!normalizeAlbumAssetCode(data?.albumAssetCode)) throw new Error('千千专辑不存在或已下架')
return { albumId, data }
}
/** 专辑详情(MusicServer 统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片) */
export const getAlbumDetail = async(id: string, page = 1, limit = 30) => {
const { albumId, data } = await fetchAlbumInfo(id)
const title = String(data?.title ?? '').trim()
const cover = String(data?.pic ?? '').trim()
const creator = joinArtists(data?.artist)
const all = Array.isArray(data?.trackList) ? data.trackList : []
const list = all
.map((item: any) => {
const song = mapTrack(item, { albumId, albumName: title, img: cover, singer: creator })
return {
...song,
// 专辑内曲目补上时长/序号(上游 Extra.track / sort)
track: Number(item?.sort) > 0 ? Number(item.sort) : undefined,
}
})
.filter((song: any) => song.songmid)
const start = (Math.max(1, page) - 1) * Math.max(1, limit)
const paged = list.slice(start, start + Math.max(1, limit))
const albumInfo = {
id: albumId,
name: title,
img: cover,
desc: String(data?.introduce ?? '').trim(),
author: creator,
releaseDate: releaseDate10(data?.releaseDate),
genre: String(data?.genre ?? '').trim(),
lang: String(data?.lang ?? '').trim(),
total: list.length,
}
return {
source: 'bd' as const,
info: albumInfo,
list: paged,
page,
limit,
total: list.length,
allPage: Math.ceil(list.length / Math.max(1, limit)) || 1,
}
}
/** 上游 GetAlbumSongs:只取曲目列表 */
export const getAlbumSongs = async(id: string) => {
const { list } = await getAlbumDetail(id, 1, 10000)
return list
}
/** 上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态 */
export const parseAlbumLink = async(link: string) => {
const raw = String(link ?? '').trim()
const patterns = [
/music\.91q\.com\/album\/([A-Za-z0-9]+)/,
/albumAssetCode=([A-Za-z0-9]+)/,
/albumid=(\d+)/,
]
for (const pattern of patterns) {
const match = pattern.exec(raw)
if (match) return getAlbumDetail(match[1])
}
throw new Error('无效的千千专辑链接')
}
export default { searchAlbum, searchAlbumItems, getAlbumDetail, getAlbumSongs, parseAlbumLink, resolveAlbumAssetCode }
+86
View File
@@ -0,0 +1,86 @@
// 千千音乐(music.91q.com)平台模块
// 上游 music-lib/qianqian 的 Node/TS 移植。匿名可用面(2026-10-02 实测):
// 搜索(type=1)/ 专辑搜索+详情(type=3、album/info)/ 歌单详情+分类+分类歌单 /
// 歌词(song/info 返回 txt URL)/ 免费曲目 128k/320k/flac 明文直链(tracklink 签名通道)。
// 限制:
// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay;
// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表;
// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选)。
import bdApi from '../apis/bd.ts'
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import { getSongInfo } from './musicInfo.ts'
import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import { getQianCookie } from './util.ts'
const bd = {
musicSearch,
getMusicUrl(songInfo, type) {
return bdApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo) {
return lyric.getLyric(songInfo)
},
// 千千无独立封面接口;搜索结果/专辑/歌单均自带 pic,缺失时回退 song/info
async getPic(songInfo) {
if (songInfo?.img) return songInfo.img
try {
const info = await getSongInfo(String(songInfo?.songmid ?? ''))
return info?.img || null
} catch {
return null
}
},
getMusicInfo(songInfo) {
return getSongInfo(String(songInfo?.songmid ?? ''))
},
getMusicDetailPageUrl(songInfo) {
const tsid = String(songInfo?.songmid ?? '').trim()
return tsid ? `https://music.91q.com/song/${tsid}` : 'https://music.91q.com/'
},
album: {
getAlbumDetail: album.getAlbumDetail,
searchAlbum: album.searchAlbum,
parseAlbumLink: album.parseAlbumLink,
},
// MusicServer 的 songList 契约:getList(sortId, tagId, page) / getTags()
// 千千无“榜单”概念,这里以歌单分类作为标签体系(tagId 为 /v1/tracklist/category 的 subCate id)
songList: {
getList(sortId, tagId, page = 1) {
const cate = String(tagId || sortId || '').trim()
return playlist.getCategoryPlaylists(/^\d+$/.test(cate) ? cate : '', page, 30)
},
getTags() {
return playlist.getCategories()
},
},
playlist: {
searchPlaylist: playlist.searchPlaylist,
getPlaylistDetail: playlist.getPlaylistDetail,
getPlaylistSongs: playlist.getPlaylistSongs,
getCategories: playlist.getCategories,
getCategoryPlaylists: playlist.getCategoryPlaylists,
parsePlaylistLink: playlist.parsePlaylistLink,
},
parse,
}
/** 凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流) */
export const status = () => {
const cookie = getQianCookie()
return cookie ? { logged: true } : { logged: false }
}
export default bd
+34
View File
@@ -0,0 +1,34 @@
// 千千音乐歌词
// 链路:/v1/song/info → data[0].lyric 为歌词文本 URL(static-qianqian.taihe.com/*.txt)→ 直接下载文本。
// 返回 { lyric, tlyric: '' }(千千只有原文歌词,无翻译)。
import { httpFetch } from '../common/request.ts'
import { getSongInfo } from './musicInfo.ts'
import { requestHeaders } from './util.ts'
export const getLyric = (songInfo: any) => {
const requestObj: any = { promise: null, cancelHttp() {} }
const tsid = String(songInfo?.songmid ?? songInfo?.TSID ?? '').trim()
let lyricReq: any = null
requestObj.cancelHttp = () => {
lyricReq?.cancelHttp?.()
lyricReq = null
}
requestObj.promise = (async() => {
if (!tsid) throw new Error('千千歌词需要 songmid(TSID)')
const info = await getSongInfo(tsid)
const lyricUrl = info?.lyricUrl
if (!lyricUrl) throw new Error('千千该歌曲暂无歌词')
lyricReq = httpFetch(lyricUrl, { headers: requestHeaders(), timeout: 15000 })
const { statusCode, body, raw } = await lyricReq.promise as any
if (statusCode != 200) throw new Error(`千千歌词下载失败(HTTP ${statusCode})`)
const lyric = typeof body === 'string' ? body : raw?.toString('utf8') ?? ''
return { lyric, tlyric: '' }
})()
return requestObj
}
export default { getLyric }
+26
View File
@@ -0,0 +1,26 @@
// 千千音乐单曲信息(/v1/song/info)
// 接口返回 data[]:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL)
import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
/** 由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl) */
export const getSongInfo = async(tsid: string) => {
const id = String(tsid ?? '').trim()
if (!id) throw new Error('千千歌曲 id(TSID)为空')
const body = await apiGet('/v1/song/info', { TSID: id, appid: APP_ID })
if (!apiOk(body) && !Array.isArray(body?.data)) {
throw new Error(body?.errmsg || `千千歌曲信息获取失败(errno ${body?.errno})`)
}
const item = Array.isArray(body?.data) ? body.data[0] : null
if (!item) throw new Error('千千歌曲不存在或已下架')
const song = mapTrack(item)
return {
...song,
// 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词)
songmid: song.songmid || id,
lyricUrl: String(item?.lyric ?? '').trim(),
raw: item,
}
}
export default { getSongInfo }
+35
View File
@@ -0,0 +1,35 @@
// 千千音乐搜索(/v1/search?word=&type=1&pageNo=&pageSize=,appid + 签名)
// 响应 data.typeTrack[]:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip
// 注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay,
// 以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。
import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
export const search = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'bd' as const }
if (!word) return empty
const body = await apiGet('/v1/search', {
word,
type: '1',
pageNo: String(page),
pageSize: String(limit),
appid: APP_ID,
})
if (!apiOk(body) && Number(body?.errno) !== 23001) {
throw new Error(body?.errmsg || `千千搜索失败(errno ${body?.errno})`)
}
const tracks = Array.isArray(body?.data?.typeTrack) ? body.data.typeTrack : []
const total = Number(body?.data?.total) || tracks.length
const haveMore = Number(body?.data?.haveMore) === 1
return {
list: tracks.map((item: any) => mapTrack(item)).filter((song: any) => song.songmid),
allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1),
total,
limit,
source: 'bd' as const,
}
}
export default { search }
+35
View File
@@ -0,0 +1,35 @@
// 千千音乐链接解析
// 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)
// 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid=
// 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*id= / 裸歌单 id
import { getDownloadUrl } from '../apis/bd.ts'
import { getSongInfo } from './musicInfo.ts'
import { parseAlbumLink } from './album.ts'
import { parsePlaylistLink } from './playlist.ts'
import { firstNonEmpty } from './util.ts'
const TSID_RE = /music\.91q\.com\/song\/(\w+)/
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
export const parseSong = async(link: string) => {
const match = TSID_RE.exec(String(link ?? ''))
if (!match) throw new Error('无效的千千歌曲链接')
const tsid = match[1]
const song: any = await getSongInfo(tsid)
try {
const hit = await getDownloadUrl(tsid)
song.url = hit.url
song.type = hit.type
} catch {
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
}
delete song.lyricUrl
delete song.raw
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'bd' as const }
}
export const parseAlbum = async(link: string) => parseAlbumLink(link)
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+158
View File
@@ -0,0 +1,158 @@
// 千千音乐歌单
// 详情:/v1/tracklist/info?id=&type=0&pageNo=&pageSize=(支持分页,默认 pageSize=20)
// 分类:/v1/tracklist/category(group.categoryName + subCate[])
// 分类歌单:/v1/tracklist/list?subCateId=&pageNo=&pageSize=(不传 subCateId 为全部)
// 搜索:上游用 /v1/search?type=6,但 2026-10 实测服务端已下线该搜索类型
// (errno 23001 "不被支持的搜索类型(10000)"),官网搜索页也只有 单曲/歌手/专辑 三个 tab;
// 上游在 !state 时静默返回空列表,本实现保持一致并保留 typeSonglist 解析以备恢复。
import { APP_ID, apiGet, apiOk, firstNonEmpty, mapTrack, playlistLink } from './util.ts'
const mapPlaylistSummary = (item: any) => {
const id = item?.id == null ? '' : String(item.id)
const rawDesc = String(item?.desc ?? '').trim()
const tagList = Array.isArray(item?.tagList) ? item.tagList : []
return {
id,
name: String(item?.title ?? '').trim(),
cover: String(item?.pic ?? '').trim(),
trackCount: Number(item?.trackCount ?? 0) || 0,
creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName),
description: rawDesc || tagList.join('、'),
link: id ? playlistLink(id) : '',
source: 'bd' as const,
}
}
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
const word = String(keyword ?? '').trim()
const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
if (!word) return empty
const body = await apiGet('/v1/search', {
word,
type: '6',
pageNo: String(page),
pageSize: String(limit),
appid: APP_ID,
})
if (!body?.state) return empty
const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : []
const list = items.map(mapPlaylistSummary).filter((item: any) => item.id)
return { source: 'bd' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
}
/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const playlistId = String(id ?? '').trim()
if (!playlistId) throw new Error('千千歌单 id 为空')
const body = await apiGet('/v1/tracklist/info', {
id: playlistId,
type: '0',
pageNo: String(Math.max(1, page)),
pageSize: String(Math.max(1, limit)),
appid: APP_ID,
})
if (!apiOk(body)) throw new Error(body?.errmsg || `千千歌单获取失败(errno ${body?.errno})`)
const data = body?.data ?? {}
const rawTracks = Array.isArray(data?.trackList) ? data.trackList : []
const list = rawTracks.map((item: any) => mapTrack(item)).filter((song: any) => song.songmid)
const title = firstNonEmpty(data?.title, playlistId)
const tagList = Array.isArray(data?.tagList) ? data.tagList : []
const cover = firstNonEmpty(data?.pic, list[0]?.img)
const creator = firstNonEmpty(data?.creator, data?.author, data?.userName, data?.nickName, data?.ownerName)
const description = firstNonEmpty(data?.desc, data?.description) || tagList.join('、')
const total = Number(data?.trackCount) || list.length
return {
source: 'bd' as const,
info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) },
list,
page,
limit,
total,
allPage: Math.ceil(total / Math.max(1, limit)) || 1,
}
}
/** 上游 GetPlaylistSongs:只取曲目列表 */
export const getPlaylistSongs = async(id: string) => {
const { list } = await getPlaylistDetail(id, 1, 1000)
return list
}
/**
* 上游 GetPlaylistCategories
* 返回 { source, list: [{ id, name, group, count }] },首项为 "全部"(id='')。
*/
export const getCategories = async() => {
const body = await apiGet('/v1/tracklist/category', { appid: APP_ID })
if (!apiOk(body)) throw new Error(body?.errmsg || `千千歌单分类获取失败(errno ${body?.errno})`)
const list: Array<{ id: string; name: string; group: string; count?: number }> = [
{ id: '', name: '全部', group: '全部' },
]
for (const group of Array.isArray(body?.data) ? body.data : []) {
const groupName = String(group?.categoryName ?? '').trim()
for (const item of Array.isArray(group?.subCate) ? group.subCate : []) {
const id = String(item?.id ?? '').trim()
const name = String(item?.categoryName ?? '').trim()
if (!id || !name) continue
list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 })
}
}
return { source: 'bd' as const, list }
}
/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */
export const getCategoryPlaylists = async(categoryId: string | number, page = 1, limit = 30) => {
const cateId = String(categoryId ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.min(100, Math.max(1, Number(limit) || 30))
const params: Record<string, any> = { appid: APP_ID, pageNo: String(pageNo), pageSize: String(pageSize) }
if (cateId) params.subCateId = cateId
const body = await apiGet('/v1/tracklist/list', params)
if (!apiOk(body)) throw new Error(body?.errmsg || `千千分类歌单获取失败(errno ${body?.errno})`)
const result = Array.isArray(body?.data?.result) ? body.data.result : []
const list = result
.map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId }))
.filter((item: any) => item.id)
return {
source: 'bd' as const,
list,
total: Number(body?.data?.total) || list.length,
page: pageNo,
limit: pageSize,
}
}
/** 上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id */
export const parsePlaylistLink = async(link: string) => {
const raw = String(link ?? '').trim()
const patterns = [
/music\.91q\.com\/(?:songlist|tracklist|playlist)\/([A-Za-z0-9]+)/,
/(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)/,
]
for (const pattern of patterns) {
const match = pattern.exec(raw)
if (match) return getPlaylistDetail(match[1])
}
if (raw && !raw.includes('/')) return getPlaylistDetail(raw)
throw new Error('无效的千千歌单链接')
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
parsePlaylistLink,
}
+222
View File
@@ -0,0 +1,222 @@
// 千千音乐(music.91q.com / 91q.com)公共请求层
// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign:
// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选,未登录留空)。
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts'
import { getCredentials } from '../auth/store.ts'
export const APP_ID = '16073360'
export const SECRET = '0b50b02fd0d73a9c4c8c3a781c30845f'
export const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
export const REFERER = 'https://music.91q.com/player'
export const API_BASE = 'https://music.91q.com'
/** 上游音质档位顺序(3000 = flac,其余为 kbps) */
export const RATE_ORDER = ['3000', '320', '128', '64']
/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */
export const getQianCookie = (): string => {
const cred = getCredentials('bd') as { cookie?: string } | null
return cred?.cookie ? String(cred.cookie) : ''
}
/** 上游 signParams:写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5 */
export const signParams = (params: Record<string, any>): Record<string, any> => {
const signed: Record<string, any> = { ...params, timestamp: String(Math.floor(Date.now() / 1000)) }
const keys = Object.keys(signed).sort()
const payload = keys.map((key) => `${key}=${signed[key]}`).join('&') + SECRET
signed.sign = crypto.createHash('md5').update(payload, 'utf8').digest('hex')
return signed
}
export const signedUrl = (path: string, params: Record<string, any>): string =>
`${API_BASE}${path}?${new URLSearchParams(signParams(params) as any).toString()}`
export const requestHeaders = (): Record<string, string> => {
const cookie = getQianCookie()
return {
'User-Agent': USER_AGENT,
Referer: REFERER,
Accept: 'application/json, text/plain, */*',
...(cookie ? { Cookie: cookie } : {}),
}
}
/** 发起带签名的 GET,返回可取消的请求对象;promise resolve 为响应 JSON body */
export const apiRequest = (path: string, params: Record<string, any>) => {
const req = httpFetch(signedUrl(path, params), { headers: requestHeaders(), timeout: 15000 })
req.promise = req.promise.then(({ statusCode, body }: any) => {
if (statusCode != 200) throw new Error(`千千音乐接口失败(HTTP ${statusCode})`)
return body ?? {}
})
return req
}
/** 发起带签名的 GET,promise resolve 为响应 JSON body */
export const apiGet = (path: string, params: Record<string, any>): Promise<any> =>
apiRequest(path, params).promise as Promise<any>
/** 上游状态判定:state=true 或 errno=22000 视为成功 */
export const apiOk = (body: any): boolean => body?.state === true || Number(body?.errno) === 22000
/** tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path) */
export const trackLink = (tsid: string, rate: string, register?: (req: any) => void): Promise<any> => {
const req = apiRequest('/v1/song/tracklink', { TSID: tsid, appid: APP_ID, rate })
register?.(req)
return req.promise as Promise<any>
}
/** 时间格式 mm:ss(duration 为秒;0 显示 0) */
export const intervalOf = (duration: any): string | number => {
const seconds = Number(duration)
if (!Number.isFinite(seconds) || seconds <= 0) return 0
const m = Math.trunc(seconds / 60)
const s = Math.trunc(seconds % 60)
return `${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}`
}
/** 千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接) */
export const joinArtists = (artists: any): string => {
if (!Array.isArray(artists) || !artists.length) return ''
const names: string[] = []
const seen = new Set<string>()
const add = (name: any) => {
const n = String(name ?? '').trim()
if (!n || seen.has(n)) return
seen.add(n)
names.push(n)
}
for (const item of artists) if (Number(item?.artistType) === 38) add(item?.name)
if (!names.length) for (const item of artists) add(item?.name)
return names.join('、')
}
/** 上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P */
export const normalizeAlbumAssetCode = (id: any): string => {
const s = String(id ?? '').trim()
if (s.length < 2) return ''
return s[0] === 'p' || s[0] === 'P' ? `P${s.slice(1)}` : ''
}
/** 上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(服务端 23001 时重试用) */
export const sanitizeAlbumKeyword = (keyword: any): string => {
const trimmed = String(keyword ?? '').trim()
if (!trimmed) return ''
return trimmed
.replace(/[::"'“”‘’()()[\]【】,,/\\\-.]/g, ' ')
.split(/\s+/)
.filter(Boolean)
.join(' ')
}
export const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
const s = String(value ?? '').trim()
if (s) return s
}
return ''
}
/** 上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD) */
export const releaseDate10 = (raw: any): string => {
const s = String(raw ?? '').trim()
return s.length >= 10 ? s.slice(0, 10) : s
}
/** 上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps) */
export const rateStats = (rateFileInfo: any, duration: number): { size: number; bitrate: number } => {
for (const rate of RATE_ORDER) {
const info = rateFileInfo?.[rate]
const size = Number(info?.size ?? 0)
if (!info || !(size > 0)) continue
if (duration > 0) return { size, bitrate: Math.trunc((size * 8) / 1000 / duration) }
return { size, bitrate: rate === '3000' ? 800 : Number(rate) }
}
return { size: 0, bitrate: 0 }
}
// 千千 rateFileInfo → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
const RATE_LABEL: Record<string, '128k' | '320k' | 'flac'> = { '128': '128k', '320': '320k', '3000': 'flac' }
export const rateTypes = (rateFileInfo: any) => {
const types: Array<{ type: '128k' | '320k' | 'flac'; size: string }> = []
const _types: Partial<Record<'128k' | '320k' | 'flac', { size: string }>> = {}
for (const rate of ['128', '320', '3000']) {
const info = rateFileInfo?.[rate]
const size = Number(info?.size ?? 0)
if (!info || !(size > 0)) continue
const label = RATE_LABEL[rate]
const sizeText = sizeMB(size)
types.push({ type: label, size: sizeText })
_types[label] = { size: sizeText }
}
return { types, _types }
}
const sizeMB = (size: number): string => {
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
}
export interface MapTrackOptions {
albumId?: string
albumName?: string
img?: string | null
singer?: string
}
/**
* 千千曲目字段 → MusicServer 统一 Song 形状
* 搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
* 歌单详情用 TSID + albumTitle + pic。
* 注:上游 Search 会跳过 isVip != 0 的曲目;MusicServer 聚合列表保留它们并标记 payPlay,
* 取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
*/
export const mapTrack = (item: any, opts: MapTrackOptions = {}) => {
const duration = Number(item?.duration ?? 0)
const stats = rateStats(item?.rateFileInfo, duration)
const { types, _types } = rateTypes(item?.rateFileInfo)
const albumId = firstNonEmpty(normalizeAlbumAssetCode(item?.albumAssetCode), opts.albumId)
return {
songmid: firstNonEmpty(item?.TSID, item?.assetId, item?.tsid),
name: String(item?.title ?? '').trim(),
singer: firstNonEmpty(joinArtists(item?.artist), opts.singer),
albumId,
albumName: firstNonEmpty(item?.albumTitle, opts.albumName),
interval: intervalOf(duration),
size: stats.size || 0,
bitrate: stats.bitrate || 0,
payPlay: Number(item?.isVip ?? 0) !== 0 || Number(item?.isPaid ?? 0) !== 0,
img: firstNonEmpty(item?.pic, opts.img) || null,
lrc: null,
lrcx: '',
types,
_types,
source: 'bd' as const,
}
}
export const songLink = (tsid: string): string => `https://music.91q.com/song/${tsid}`
export const albumLink = (id: string): string => `https://music.91q.com/album/${id}`
export const playlistLink = (id: string): string => `https://music.91q.com/songlist/${id}`
export default {
APP_ID,
SECRET,
getUserAgent: () => USER_AGENT,
getQianCookie,
signParams,
signedUrl,
apiRequest,
apiOk,
trackLink,
mapTrack,
rateTypes,
rateStats,
sanitizeAlbumKeyword,
normalizeAlbumAssetCode,
}
+39
View File
@@ -0,0 +1,39 @@
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili)
// 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/
// 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts)
// 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。
// 取流入口在 sdk/apis/bili.ts(getMusicUrl),songmid 约定 `<bvid>|<cid>`。
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import pic from './pic.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import biliApi from '../apis/bili.ts'
import { parseSongId } from './util.ts'
const bili = {
musicSearch,
getMusicUrl(songInfo, type) {
return biliApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
getPic(songInfo) {
return pic.getPic(songInfo)
},
playlist,
parse,
getMusicDetailPageUrl(songInfo) {
if (songInfo?.link) return String(songInfo.link)
const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid
if (!bvid) return 'https://www.bilibili.com/'
const page = Number(songInfo?.page) || 0
return `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}`
},
}
// 登录态(sdk/auth/store.ts 的 bili 凭据,扫码登录写入)
export { status } from './login.ts'
export default bili
+163
View File
@@ -0,0 +1,163 @@
// Bilibili 扫码登录(上游 bilibili/login.go 的 CreateQRLogin/CheckQRLogin + account.go 的 IsVipAccount)
// 接口:
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
// 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。
// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
import QRCode from 'qrcode'
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts'
import { REFERER, UA } from './util.ts'
import { resetVipCache } from '../apis/bili.ts'
const QR_GENERATE_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate'
const QR_POLL_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll'
/** 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制 */
const randomChinaIp = (): string => {
const first = [36, 42, 58, 60, 101, 106, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 171, 175, 180, 182, 183, 210, 211, 218, 219, 220, 221, 222, 223][Math.floor(Math.random() * 35)]
return `${first}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}`
}
const qrHeaders = (): Record<string, string> => {
const ip = randomChinaIp()
return {
'User-Agent': UA,
Referer: REFERER,
'X-Forwarded-For': ip,
'X-Real-IP': ip,
}
}
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
const raw = headers?.['set-cookie']
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
const jar: Record<string, string> = {}
for (const value of values) {
const pair = String(value).split(';')[0].trim()
const idx = pair.indexOf('=')
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
}
return jar
}
/** crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备) */
const cookieMapFromCrossDomain = (crossUrl: string): Record<string, string> => {
const jar: Record<string, string> = {}
try {
const url = new URL(String(crossUrl || ''))
for (const name of ['DedeUserID', 'DedeUserID__ckMd5', 'SESSDATA', 'bili_jct', 'sid']) {
const value = url.searchParams.get(name)
if (value) jar[name] = value
}
} catch {
// 非 URL 时忽略
}
return jar
}
const cookieString = (jar: Record<string, string>): string =>
Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
/** 创建扫码登录会话:返回 { key, qrurl, qrimg, expiresAt }(qrimg 可直接展示) */
export const createQRLogin = async() => {
const { statusCode, body } = await httpFetch(QR_GENERATE_API, { headers: qrHeaders(), timeout: 15000 }).promise as any
if (statusCode !== 200 || !body || typeof body !== 'object') {
throw new Error(`获取B站登录二维码失败(HTTP ${statusCode})`)
}
if (body.code !== 0 || !String(body.data?.qrcode_key || '').trim()) {
throw new Error(`获取B站登录二维码失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
}
const key = String(body.data.qrcode_key)
const url = String(body.data.url || '')
const qrimg = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 2, width: 360 }).catch(() => '')
return {
key,
qrurl: url,
qrimg,
// 上游 QRLoginSession.ExpiresAt:3 分钟有效期
expiresAt: Math.floor(Date.now() / 1000) + 180,
}
}
/** 拉取账号资料(nav;失败不阻断登录) */
const fetchProfile = async(cookie: string) => {
try {
const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', {
headers: { 'User-Agent': UA, Referer: REFERER, Cookie: cookie },
timeout: 15000,
}).promise as any
const data = body?.data ?? {}
return {
mid: data.mid ?? null,
nickname: data.uname ?? null,
vipType: Number(data.vipType) || 0,
vipStatus: Number(data.vipStatus) || 0,
}
} catch {
return { mid: null, nickname: null, vipType: 0, vipStatus: 0 }
}
}
/**
* 轮询扫码状态
* @returns {{ code: 800|801|802|803, message: string, status: string, profile? }}
* 800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)
*/
export const checkQRLogin = async(key: string) => {
const qrcodeKey = String(key || '').trim()
if (!qrcodeKey) throw new Error('B站扫码 key 为空')
const { statusCode, headers, body } = await httpFetch(`${QR_POLL_API}?qrcode_key=${encodeURIComponent(qrcodeKey)}`, {
headers: { 'User-Agent': UA, Referer: REFERER },
timeout: 15000,
}).promise as any
if (statusCode !== 200) {
throw Object.assign(new Error(`轮询B站扫码状态失败(HTTP ${statusCode})`), { status: 502 })
}
const data = body?.data ?? {}
const code = Number(data.code)
if (code === 0) {
const jar = { ...cookieMapFromCrossDomain(data.url), ...cookieMapFromHeaders(headers) }
const cookie = cookieString(jar)
if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试')
const profile = await fetchProfile(cookie)
setCredentials('bili', {
cookie,
refreshToken: String(data.refresh_token || ''),
mid: profile.mid,
nickname: profile.nickname,
vipType: profile.vipType,
vipStatus: profile.vipStatus,
})
resetVipCache()
return { code: 803, message: '登录成功', status: 'success', profile }
}
const statusMap: Record<number, [number, string, string]> = {
86038: [800, '二维码已过期', 'expired'],
86090: [802, '已扫码,待确认', 'scanned'],
86101: [801, '等待扫码', 'waiting'],
}
const [mappedCode, message, status] = statusMap[code] ?? [800, String(data.message || `未知状态 ${code}`), 'failed']
return { code: mappedCode, message, status }
}
/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */
export const status = () => {
const cred = getCredentials('bili') as any
return cred?.cookie
? {
logged: true,
nickname: cred.nickname ?? null,
mid: cred.mid ?? null,
vipType: cred.vipType ?? 0,
updatedAt: cred.updatedAt,
}
: { logged: false }
}
export default { createQRLogin, checkQRLogin, status }
+12
View File
@@ -0,0 +1,12 @@
// Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串)
// B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bili 有响应而不是 404。
export const getLyric = (_songInfo?: any, ..._rest: any[]) => {
const requestObj = {
promise: Promise.resolve({ lyric: '', tlyric: '' }),
cancelHttp() {},
}
return requestObj
}
export default { getLyric }
+57
View File
@@ -0,0 +1,57 @@
// Bilibili 搜索(上游 bilibili/song.go 的 `Search`)
// 接口: https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=&page=&page_size=
// 上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),本移植保持一致,
// 只是把逐个串行改成了 4 并发;单条 view 失败按上游语义跳过该条。
// 实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;风控时返回 -412/-352,会抛错由路由兜底。
import { apiGet, cleanTitle, fetchView, makeSong, mapLimit } from './util.ts'
const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type'
export const search = async(keyword: string, page = 1, limit = 20) => {
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 50)
const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}`
const { statusCode, body } = await apiGet(url)
if (statusCode !== 200 || !body || typeof body !== 'object') {
throw new Error(`B站搜索失败(HTTP ${statusCode})`)
}
if (body.code !== 0) {
throw new Error(`B站搜索失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
}
const items: any[] = body.data?.result ?? []
const songs = await mapLimit(items, 4, async(item: any) => {
if (!item?.bvid) return null
const view = await fetchView(item.bvid)
const pages: any[] = view?.pages ?? []
if (!pages.length) return null
const first = pages[0]
const rootTitle = cleanTitle(item.title)
let displayTitle = first.part || ''
if (!displayTitle) displayTitle = rootTitle
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
return makeSong({
bvid: item.bvid,
cid: first.cid,
name: displayTitle,
artist: item.author || view?.owner?.name || '',
album: item.bvid,
duration: first.duration,
cover: item.pic || view?.pic || '',
page: 1,
})
})
const list = songs.filter(Boolean)
const total = Number(body.data?.numResults) || list.length
const numPages = Number(body.data?.numPages) || 0
return {
list,
allPage: numPages || p + (list.length >= l ? 1 : 0),
total,
limit: l,
source: 'bili' as const,
}
}
export default { search }
+72
View File
@@ -0,0 +1,72 @@
// Bilibili 链接解析(上游 bilibili/song.go 的 `Parse`)
// 支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
// 与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parsePlaylistLink。
// 上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
import { extractAid, extractBvid, extractPage, fetchPageList, fetchView, makeSong, normalizeCover } from './util.ts'
import { getAudioUrl } from '../apis/bili.ts'
import { parsePlaylistLink } from './playlist.ts'
export const parseSong = async(link: string) => {
const raw = String(link || '').trim()
const bvid = extractBvid(raw)
const aid = extractAid(raw)
if (!bvid && !aid) throw new Error('无效的B站链接:未找到 BV/av 号')
const view = await fetchView(bvid || aid)
const pages: any[] = view?.pages ?? []
// 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if (view?.ugc_season || pages.length > 1) {
throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)')
}
if (pages.length <= 1) {
try {
const pageList = await fetchPageList(view.bvid || bvid)
if (pageList.length > 1) throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)')
} catch (err: any) {
if (String(err?.message || '').includes('歌单解析')) throw err
// 分P列表接口失败时按上游语义忽略
}
}
if (!pages.length) throw new Error('未找到视频分P')
let page = extractPage(raw)
if (page > pages.length) page = 1
const target = pages[page - 1]
const rootTitle = view.title || ''
let displayTitle = target?.part || ''
if (pages.length === 1 && !displayTitle) displayTitle = rootTitle
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
const cid = String(target?.cid ?? '')
const song: any = makeSong({
bvid: view.bvid || bvid,
cid,
name: displayTitle,
artist: view?.owner?.name || '',
album: view.bvid || bvid,
duration: target?.duration,
cover: normalizeCover(view?.pic || ''),
page,
})
// 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if (cid) {
try {
const audio = await getAudioUrl(song.bvid, cid)
if (audio?.urls?.[0]) {
song.url = audio.urls[0]
song.type = audio.type
}
} catch {
// 忽略:元数据仍可返回
}
}
return song
}
/** 歌单/合集链接解析(上游 bilibili/playlist.go 的 `ParsePlaylist`) */
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parsePlaylist }
+17
View File
@@ -0,0 +1,17 @@
// Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic
import { fetchView, normalizeCover, parseSongId } from './util.ts'
export const getPic = async(songInfo: any): Promise<string> => {
const direct = String(songInfo?.img || '')
if (/^https?:/i.test(direct)) return direct
const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid
if (!bvid) return ''
try {
const view = await fetchView(bvid)
return normalizeCover(view?.pic || '')
} catch {
return ''
}
}
export default { getPic }
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more