Compare commits

..
Author SHA1 Message Date
snowy-yang 0df18f048b fix(server): 修复路由遮蔽、流式背压与鉴权限流顺序
- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
2026-10-23 23:40:55 +08:00
snowy-yang 39565a3913 fix(server): Unix 默认组合在 rloop 不可用时退回 uvicorn+uvloop
- pick_server(auto) 按平台组合选择:Windows granian+winloop;Unix granian+rloop → uvicorn+uvloop → 已安装项
- 回退 uvicorn 时打印原因(未安装 granian / rloop 不可用)
- 测试补充 Unix 回退分支与 Windows auto 选择;README 默认矩阵同步
2026-10-23 22:31:47 +08:00
snowy-yang bcfc1022eb feat(server): 平台默认组合——Windows granian+winloop,Unix granian+rloop
- granian[reload,rloop,uvloop,winloop] 改为默认依赖,uv sync 即具备完整启动栈
- auto 在启动器内解析为具体循环:granian Windows→winloop / Unix→rloop(缺省回退 uvloop/asyncio);
  uvicorn Unix→uvloop / Windows→asyncio
- 启动日志打印实际 server+loop;测试覆盖平台默认与回退;README 默认矩阵更新
2026-10-23 22:23:30 +08:00
snowy-yang 0cae770ace feat(server): 支持 Granian 与 rloop/uvloop/winloop 事件循环
- 新增 scripts/serve.py 启动器:server auto/uvicorn/granian,循环可用性与平台校验(显式不可用直接报错)
- pyproject 增加 granian 可选 extra(granian[reload,rloop,uvloop,winloop],平台 marker 由 granian 维护)
- tests/test_serve.py 覆盖服务器/循环选择与命令拼装;README 补充启动方式、循环矩阵与多 worker 限制
2026-10-23 22:12:08 +08:00
snowy-yang e168f43b81 chore(release): v2.0.0 —— Python 版版本号与 README 对齐
- pyproject/app.__init__/OpenAPI 统一 2.0.0(main.py 改为读取 __version__)
- README 切换为 v2.0(Python)说明:uv 快速开始、app/ 结构、音源加载未移植、已知限制更新
2026-10-23 21:59:17 +08:00
snowy-yang dfc06bf4de feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
2026-10-23 21:55:45 +08:00
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
347 changed files with 37342 additions and 3150 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 '\\\\.\\\\./\\\\.\\\\./\")"
]
}
}
+11
View File
@@ -22,6 +22,10 @@ Thumbs.db
# ZCode 本地目录 # ZCode 本地目录
.zcode/ .zcode/
# Agent 工作目录(本机记忆日志、工具权限配置;含本机绝对路径,不入库)
.workbuddy/
.claude/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
@@ -38,3 +42,10 @@ coverage/
# 以下规则仅作保险,防止旧的发射产物被误生成后提交。 # 以下规则仅作保险,防止旧的发射产物被误生成后提交。
src/**/*.js src/**/*.js
sdk/**/*.js sdk/**/*.js
# Python(uv)
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
+1
View File
@@ -0,0 +1 @@
3.12
+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 附最小复现脚本);本节线索建议复核后再动手。第四组「测试与文档」专项审查仍在进行,其领域(测试有效性、覆盖缺口、文档一致性)本报告已通过独立核对覆盖主要结论。
+151 -64
View File
@@ -1,24 +1,89 @@
# 音乐服务器(MusicServer) # 音乐服务器(MusicServer)
多平台音乐聚合 API 服务器,纯 Node.js 实现,无需浏览器环境。 多平台音乐聚合 API 服务器,无需浏览器环境。
> **v2.0(Python 版,当前默认分支)**:服务端已全量迁移到 Python 3.12 + FastAPI,14 个平台、
> 各平台登录通道与 Node 版 v1.x 功能对齐(外部音源脚本加载暂未移植)。
> Node/TypeScript 实现(v1.x)保留在 `master` 分支。
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)+ 有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr)(有声平台不参与聚合搜索) - **多平台聚合**:酷我(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` 等新增能力位)。
## 快速开始(v2.0 Python)
依赖 [uv](https://docs.astral.sh/uv/)(Python 3.12,见 `.python-version`):
```bash ```bash
pnpm install uv sync # 安装依赖(FastAPI/uvicorn/aiohttp/cryptography/qrcode 等)
pnpm start # 默认端口 16688,可用 PORT 环境变量修改(需 Node 22.18+,直接运行 .ts) uv run uvicorn app.main:app --host 0.0.0.0 --port 16688
pnpm dev # 开发模式:node --watch 热重载,改动 src/、sdk/ 代码自动重启 # 默认端口 16688;也可用 PORT 环境变量 + --port $PORT
pnpm test # 类型检查(tsc)+ 冒烟测试(自动启动/关闭本地服务器) uv run ruff check app && uv run ruff format --check app # 规范检查
pnpm typecheck # 仅类型检查,不运行 uv run pyright # 类型检查(基础模式)
uv run pytest # 单元测试(tests/)
``` ```
环境变量(`PORT`、`SOURCE_*` 等)可写入项目根目录的 `.env` 文件(已 gitignore),启动时自动加载;已存在的真实环境变量优先于 `.env` 文件值。 环境变量(`PORT`、`AUTH_KEY`、`RATE_LIMIT`、`DATA_DIR` 等)可写入项目根目录的 `.env` 文件(已 gitignore),启动时自动加载;已存在的真实环境变量优先于 `.env` 文件值。登录凭据持久化在 `data/auth.json`(0600,原子写入)。
Node/TypeScript 版(v1.x,`master` 分支):`pnpm install && pnpm start`(需 Node 22.18+,直接运行 `.ts`)。
### 服务器与事件循环(uvicorn / granian)
`scripts/serve.py` 统一启动入口。默认组合按平台确定:
| 平台 | 默认服务器 | 默认事件循环 |
| --- | --- | --- |
| Windows | granian | winloop(未安装则 asyncio) |
| Linux / macOS | granian;rloop 不可用时退回 uvicorn | rloop;退回 uvicorn 时 uvloop(再退 asyncio) |
| Windows + `--server uvicorn` | uvicorn | asyncio(uvicorn 不支持 winloop) |
| Linux / macOS + `--server uvicorn` | uvicorn | uvloop(未安装则 asyncio) |
事件循环可用 `auto / asyncio / rloop / uvloop / winloop`,参数与环境变量等价
(`SERVER` / `LOOP` / `HOST` / `PORT` / `WORKERS` / `RELOAD`)。`auto` 会在启动器内解析成上表中的具体组合,
启动日志会打印实际使用的 server+loop;显式请求当前平台不可用的循环会直接报错并列出可用项(不会静默降级)。
```bash
uv sync # 默认依赖已含 granian[rloop/uvloop/winloop/reload]
uv run python scripts/serve.py # Windows → granian+winloop;Unix → granian+rloop(缺 rloop 退 uvicorn+uvloop)
uv run python scripts/serve.py --server uvicorn # Unix → uvicorn+uvloop;Windows → uvicorn+asyncio
uv run python scripts/serve.py --loop rloop # 仅 Unix(Rust 事件循环)
uv run python scripts/serve.py --loop uvloop # 仅 Unix
uv run python scripts/serve.py --loop winloop # 仅 Windows
uv run python scripts/serve.py --server granian --reload # 热重载(强制单 worker)
```
| 事件循环 | 平台 | 说明 |
| --- | --- | --- |
| `asyncio` | 全平台 | 标准库,始终可用 |
| `rloop` | Unix | Rust 事件循环(`rloop` 包,Windows 无 wheel) |
| `uvloop` | Unix | libuv 事件循环 |
| `winloop` | Windows | uvloop 的 Windows 实现 |
granian 也支持显式指定与默认不同的循环(如 Unix 上 `--loop uvloop`、Windows 上 `--loop asyncio`)。
## HTTP 接口 ## HTTP 接口
@@ -26,10 +91,10 @@ pnpm typecheck # 仅类型检查,不运行
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 | | `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr/apple);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
| `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) | | `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `GET /stream/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) | | `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` | 榜单列表 |
@@ -39,7 +104,14 @@ pnpm typecheck # 仅类型检查,不运行
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) | | `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
| `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx/xm) | | `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/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | | `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
| `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) | | `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) |
@@ -49,10 +121,13 @@ pnpm typecheck # 仅类型检查,不运行
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | | `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) | | `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) | | `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
| `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(**抖音 App** 扫码,登录后解锁完整曲目与无损取流) |
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) | | `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) | | `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) | | `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
| `POST /login/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
| `POST /login/{mg,bd,joox,apple,5sing}` | 绑定对应平台 cookie(body `{"cookie": "..."}`;apple 需含 `media-user-token`、joox 通常需 `wmid`/`session_key`;服务端不做在线校验) |
| `DELETE /login/{wy,kg,tx,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) |
| `DELETE /login/{mg,bd,joox,apple,5sing}` | 清除对应平台 cookie 凭据 |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -66,13 +141,18 @@ pnpm typecheck # 仅类型检查,不运行
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
- **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流 - **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
- **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目
- **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流 - **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流 - **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目 - **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态) - **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
- **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` 查询参数。 `/url` 在平台直连失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
## 调用鉴权与限流 ## 调用鉴权与限流
@@ -91,63 +171,70 @@ X-Server-Key: <AUTH_KEY> # 统一验证头(全部路径)
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。 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(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。 **v2.0 未移植外部音源加载**(`SOURCE_ENABLED` / `sources/*.js` / `*.mjs` 候选链与 isolated-vm 沙箱不在本分支)。
需要时请使用 `master` 分支的 v1.x(详见该分支 README 与 `docs/source-ivm.md`、`docs/source-simple.md`)。
**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。 ## 项目结构(v2.0)
**简单 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/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) app/ Python 应用(FastAPI,Python 3.12)
server.ts HTTP 服务器入口(pnpm start 实际启动文件) main.py HTTP 入口(装配中间件/异常处理/OpenAPI/路由 + lifespan)
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) config.py 环境变量、.env 加载、端口/鉴权/限流/数据目录
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 抖音扫码、xm/me cookie 绑定) routes/
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK) music.py 业务路由(搜索/取流/歌词/封面/榜单/歌单/专辑/歌手/解析/流代理)
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) login.py 登录路由(wy/kg/tx/bili 扫码与短信账密、xm/me cookie、lr 账密、mg/bd/joox/apple/5sing cookie)
source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载) refresh.py 登录态自动续期(启动后延时首轮 + 每 24h,kg/tx)
system.py /health、/sources
http/ http/
api-error.ts 业务错误类型(404/502 等 → 统一 JSON 响应) api_error.py 业务错误(统一 JSON 响应)
auth_gate.py 调用秘钥鉴权(AUTH_KEY,全路径防扫描;/stream/* 可用短时签名)
rate_limit.py per-IP 滑动窗口限流(RATE_LIMIT)
envelope.py 成功响应信封 { code: 0, data }
stream_sign.py /stream/* 流地址短时签名(HMAC,10 分钟)
mg_stream.py 咪咕加密流票据与逐块解密管道
openapi/ openapi/
components.ts OpenAPI 共享 schema(与路由校验同源) components.py OpenAPI 共享 schema
ui.ts ReDoc 页面模板 ui.py ReDoc 页面
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy/qs 五平台) sdk/
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts) __init__.py 平台注册表、聚合搜索、跨平台精确匹配
apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy/qs) types.py 契约类型(SongInfo / MusicUrlResult 等)
auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token/续期、tx.ts 扫码/cookie/续期) auth_store.py 凭据持久化(data/auth.json,0600 + 原子替换)
common/ SDK 基础设施 common/ request(aiohttp 直连,不跟随 302、保留多值 Set-Cookie)、
request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) utils(size_formate/format_play_time/as_dict 等)、qrcode
utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等) auth/ 登录协议(wy/kg/kg_simulate/tx/lr/bili)
message.ts 请求错误消息 apis/ 各平台官方直连取流(14 平台,含 CDN 选址/档位降级)
kg/ kw/ tx/ wy/ qs/ 平台模块(搜索/歌词/榜单/歌单等;qs 含字节系参数签名与音频解密 crypto/) platforms/ 平台模块(每平台一个包,MODULE: Platform + 子模块)
test/ scripts/
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口) serve.py 启动器(uvicorn / granian,事件循环 rloop/uvloop/winloop 选择与校验)
source-simple-test.ts 简单 API 音源脚本加载器测试(离线) docs/
PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求)
pyproject.toml uv 项目定义(默认 granian/uvicorn 依赖、ruff、pyright、pytest 配置)
tests/
conftest.py 测试隔离(临时 DATA_DIR,不触碰真实凭据)
test_serve.py 启动器服务器/循环选择逻辑测试
test_http.py HTTP 回归(CORS/限流/路由/流式管道/登录存储;TS smoke 尚未全量移植)
``` ```
> 分层依赖严格单向:`src` → `sdk`,`sdk` 内部自包含(不反向依赖 src)。源码统一为 TypeScript(import 一律写 `.ts` 后缀),由 Node ≥22.18 的原生类型剥离直接运行——**无编译产物**,改完即跑;tsc 仅用于类型检查(`pnpm typecheck`)。代码只使用可剥离的 TS 语法(无 enum/namespace/参数属性),新增代码也须遵守。 > 分层依赖单向:`routes` → `sdk`,`sdk` 内部自包含。全源码 `.py` 由 uv 管理的 CPython 3.12 直接运行,
> 无编译产物;`ruff` 负责风格与静态检查,`pyright` 负责类型检查。TS 版对照见 `master` 分支。
## 已知限制 ## 已知限制
- **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。 - **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `app/sdk/apis/kg.py` 头注释。
- **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。 - **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 抖音扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。 - **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。 - **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
- **xm 取流**:签名依赖 `hdaa.shuzilm.cn` 的设备上报;请求层必须直连(不走系统代理,Node 版语义一致)。
- **5sing 歌词**:2026-10-23 实测匿名 `newget` 不返回 `dynamicWords`(搜索结果与多个关键词均如此,Node 版同样),拿不到时接口如实报错。
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 曾整体 502,2026-10-23 实测已恢复。
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
- **多 worker**:限流计数、登录续期调度、咪咕加密流票据都是进程内状态;`WORKERS>1` 时各自独立
(续期会重复执行、`/stream/mg` 票据可能落到别的 worker)。默认单 worker,多 worker 部署需自行加粘性会话。
- 平台接口随时可能变更,遇失效时可对照 `app/sdk/apis/` 内的接口文档调整。
+12
View File
@@ -0,0 +1,12 @@
"""MusicServer —— 多平台音乐聚合 API(Python 版)。
包布局:
app/config.py 环境变量与路径
app/sdk/ 平台 SDK(HTTP 请求、工具、凭据、14 个平台适配器)
app/http/ 服务端横切能力(错误、鉴权、限流、流签名、咪咕解密代理)
app/routes/ 业务路由
app/openapi/ OpenAPI 领域模型
app/main.py FastAPI 应用入口
"""
__version__ = "2.0.0"
+65
View File
@@ -0,0 +1,65 @@
"""环境变量与路径配置。
对应原 src/env.ts:读取项目根目录 .env,已存在的真实环境变量优先。
必须在任何读取 os.environ 的模块导入之前调用 load_env()(main.py 第一行)。
"""
from __future__ import annotations
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _parse_line(line: str) -> tuple[str, str] | None:
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith("export "):
line = line[7:].strip()
key, sep, value = line.partition("=")
if not sep:
return None
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
return (key, value) if key else None
def load_env(path: Path | None = None) -> None:
"""加载 .env(不覆盖已存在的环境变量);缺失或解析失败时静默跳过。"""
env_path = path or ROOT / ".env"
if not env_path.exists():
return
try:
text = env_path.read_text(encoding="utf-8")
except OSError as err: # 与 process.loadEnvFile 的容错一致
print(f"[env] .env 读取失败,已忽略: {err}")
return
for line in text.splitlines():
parsed = _parse_line(line)
if parsed:
os.environ.setdefault(parsed[0], parsed[1])
def data_dir() -> Path:
"""凭据存储目录(DATA_DIR 可覆盖,测试用)。"""
custom = os.environ.get("DATA_DIR")
return Path(custom) if custom else ROOT / "data"
def port() -> int:
"""默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间。"""
return int(os.environ.get("PORT") or 16688)
def auth_key() -> str:
return os.environ.get("AUTH_KEY") or ""
def rate_limit() -> int:
"""每 IP 每分钟请求数,0 关闭(默认 120)。"""
raw = os.environ.get("RATE_LIMIT")
return 120 if raw is None or raw == "" else int(raw)
+1
View File
@@ -0,0 +1 @@
"""服务端横切能力:错误、鉴权、限流、流签名、咪咕解密代理。"""
+29
View File
@@ -0,0 +1,29 @@
"""API 业务错误 —— 路由处理中抛出,由全局异常处理器转换为对应 HTTP 状态码的 JSON 响应。"""
from __future__ import annotations
from typing import Any
class ApiError(Exception):
"""业务错误:status 同时作为响应体中的 code。"""
def __init__(self, status: int, msg: str, detail: Any = None) -> None:
super().__init__(msg)
self.status = status
self.code = status
self.detail = detail
def not_found(msg: str, detail: Any = None) -> ApiError:
"""资源不存在(未知路由 / 不支持的平台)。"""
return ApiError(404, msg, detail)
def upstream(msg: str, detail: Any = None) -> ApiError:
"""上游平台请求失败。"""
return ApiError(502, msg, detail)
def bad_request(msg: str, detail: Any = None) -> ApiError:
return ApiError(400, msg, detail)
+55
View File
@@ -0,0 +1,55 @@
"""全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)。
验证头统一为 X-Server-Key;全部路径(含文档页/健康检查/404)都要求秘钥,
未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
/stream/* 流代理例外:播放器/下载器直接 GET 媒体地址、无法携带自定义头,
改用短时签名(?exp=&sig=,见 stream_sign.py)——不落裸秘钥到 URL/日志,
签名泄露仅能在时效窗口内拉当条流;能带头的客户端仍可用 X-Server-Key。
"""
from __future__ import annotations
import hmac
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.http.stream_sign import verify_stream_sign
def _safe_equal(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())
async def auth_gate(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
auth_key = getattr(request.app.state, "auth_key", "") or ""
if not auth_key: # 未配置秘钥:完全不鉴权
return await call_next(request)
pathname = request.url.path
header = request.headers.get("x-server-key", "")
if pathname.startswith("/stream/"):
query = request.query_params
ok = (header and _safe_equal(header, auth_key)) or (
not header and verify_stream_sign(auth_key, pathname, query.get("exp"), query.get("sig"))
)
if ok:
return await call_next(request)
return JSONResponse(
{"code": 401, "msg": "无效或缺少调用秘钥:X-Server-Key 头,或 /url 返回流地址自带的 exp/sig 签名"},
status_code=401,
)
if not header or not _safe_equal(header, auth_key):
# 统一 401 文案(不区分路径存在与否,防扫描探测)
return JSONResponse(
{"code": 401, "msg": "无效或缺少调用秘钥:请在 X-Server-Key 请求头携带"},
status_code=401,
)
return await call_next(request)
+22
View File
@@ -0,0 +1,22 @@
"""统一成功信封 { code: 0, data }。
独立成模块是为了避免 routes → main → routes 的循环导入。
"""
from __future__ import annotations
import json
from typing import Any
from fastapi.responses import JSONResponse
class Envelope(JSONResponse):
"""成功响应:{ "code": 0, "data": <payload> }。"""
def render(self, content: Any) -> bytes:
return json.dumps({"code": 0, "data": content}, ensure_ascii=False).encode("utf-8")
def ok(data: Any, status_code: int = 200) -> Envelope:
return Envelope(data, status_code=status_code)
+197
View File
@@ -0,0 +1,197 @@
"""咪咕加密流 —— 票据登记与 Z3D 流式解密(对应原 src/http/mg-stream.ts + sdk/mg/util.ts 解密部分)。
统一入口为 /stream/:source/:id,source=mg 时 id 即此处签发的短票据。
不把上游 URL 塞进播放地址(path 参数限长,且 URL 含时效签名与中文路径),
票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
Z3D 解密算法(与原始 TS 一致):
streamKey = MD5(AES128CBC(AndroidAppKey, ENCRYPTED_APP_CODE) + fileKey) 的大写十六进制(32 字节 ASCII)
plain[i] = (cipher[i] - streamKey[i % 32]) & 0xFF
密文可能以 3 字节 `Z3D` 魔数开头,需先剥离;已解密的数据(音频魔数开头)原样透传。
"""
from __future__ import annotations
import hashlib
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from urllib.parse import urljoin
import aiohttp
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
TTL_SECONDS = 10 * 60
STREAM_CACHE_LIMIT = 200
MIGU_STREAM_UA = "Android_migu/7.41.13 okhttp/3.12.13"
# 咪咕 Android 客户端的 app key 与加密后的 app code(服务端下发,非机密凭据)
_ANDROID_APP_KEY = b"308202333082019c"
_ENCRYPTED_APP_CODE = bytes(
[
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
]
) # fmt: skip
_Z3D_IV = bytes(range(16))
@dataclass
class MiguStreamTicket:
url: str
file_key: str
created_at: float
_tickets: dict[str, MiguStreamTicket] = {}
def _prune() -> None:
now = time.time()
for key in [k for k, t in _tickets.items() if now - t.created_at > TTL_SECONDS]:
_tickets.pop(key, None)
# 超出容量时按插入顺序淘汰最旧票据
while len(_tickets) > STREAM_CACHE_LIMIT:
oldest = next(iter(_tickets), None)
if oldest is None:
break
_tickets.pop(oldest, None)
def register_migu_stream(url: str, file_key: str) -> str:
"""登记一条加密流,返回可直接放入 /stream/mg/:id 的短票据 id。"""
_prune()
ticket_id = uuid.uuid4().hex
_tickets[ticket_id] = MiguStreamTicket(url=url, file_key=file_key, created_at=time.time())
return ticket_id
def take_migu_stream(ticket_id: str) -> MiguStreamTicket | None:
"""取出票据(过期返回 None)。"""
ticket = _tickets.get(ticket_id)
if not ticket:
return None
if time.time() - ticket.created_at > TTL_SECONDS:
_tickets.pop(ticket_id, None)
return None
return ticket
def is_migu_audio_magic(data: bytes) -> bool:
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
if len(data) < 4:
return False
head4 = data[:4]
if head4 in (b"fLaC", b"OggS"):
return True
if head4 == b"RIFF" and len(data) >= 12 and data[8:12] == b"WAVE":
return True
if data[:3] == b"ID3":
return True
if len(data) >= 12 and data[4:8] == b"ftyp":
return True
return data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
def migu_z3d_stream_key(file_key: str) -> bytes:
"""streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制(32 字节 ASCII)。"""
key = str(file_key or "").strip()
if not key:
raise RuntimeError("空的 z3d fileKey")
# Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,这里同样禁用填充,否则尾块校验失败
decryptor = Cipher(algorithms.AES(_ANDROID_APP_KEY), modes.CBC(_Z3D_IV)).decryptor()
app_code = decryptor.update(_ENCRYPTED_APP_CODE) + decryptor.finalize()
digest = hashlib.md5(app_code + key.encode("utf-8")).hexdigest().upper()
return digest.encode("ascii")
def decrypt_z3d(data: bytes, file_key: str) -> bytes:
"""整段解密(非流式场景;已是明文则原样返回)。"""
if not data:
raise RuntimeError("空的 z3d 数据")
if is_migu_audio_magic(data):
return data
payload = data[3:] if data[:3] == b"Z3D" else data
if not payload:
raise RuntimeError("剥离 Z3D 魔数后无数据")
stream_key = migu_z3d_stream_key(file_key)
plain = bytes((byte - stream_key[index & 31]) & 0xFF for index, byte in enumerate(payload))
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain
class Z3DStreamDecoder:
"""按块增量解密:跨块保持全局偏移,首块剥离 `Z3D` 魔数并校验结果。"""
def __init__(self, file_key: str) -> None:
self._stream_key = migu_z3d_stream_key(file_key)
self._offset = 0
self._pending = b"" # 首块不足 3 字节时暂存,用于判断 `Z3D` 魔数
self._checked = False
def feed(self, chunk: bytes) -> bytes:
if not chunk:
return b""
data = self._pending + chunk
self._pending = b""
if self._offset == 0 and len(data) < 3:
self._pending = data
return b""
if self._offset == 0 and data[:3] == b"Z3D":
data = data[3:]
base = self._offset
self._offset += len(data)
plain = bytes((byte - self._stream_key[(base + index) & 31]) & 0xFF for index, byte in enumerate(data))
if not self._checked and plain:
self._checked = True
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain
def migu_stream_content_type(url: str) -> str:
"""按扩展名推断明文的 content-type。"""
if ".flac" in url:
return "audio/flac"
if ".wav" in url:
return "audio/wav"
if ".m4a" in url:
return "audio/mp4"
return "audio/mpeg"
async def stream_decrypted(
url: str,
feed: Callable[[bytes], bytes],
on_chunk: Callable[[bytes], Awaitable[None]],
*,
max_redirects: int = 3,
) -> None:
"""流式下载密流并逐块解密后交给 on_chunk。
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
on_chunk 为 async,调用方可用有界队列形成背压,避免慢客户端时丢块/死锁。
"""
timeout = aiohttp.ClientTimeout(total=300, sock_read=60)
headers = {"User-Agent": MIGU_STREAM_UA, "Referer": "https://music.migu.cn/"}
current = url
for _ in range(max_redirects + 1):
# trust_env=False:与 common/request 保持一致,不读系统代理(Windows 上 aiohttp 会走 IE 设置)
async with aiohttp.ClientSession(timeout=timeout, trust_env=False) as session:
async with session.get(current, headers=headers, allow_redirects=False) as resp:
if 300 <= resp.status < 400 and (location := resp.headers.get("location")):
current = urljoin(current, location)
continue
if resp.status != 200:
raise RuntimeError(f"上游返回 {resp.status}")
async for chunk in resp.content.iter_any():
plain = feed(chunk)
if plain:
await on_chunk(plain)
return
raise RuntimeError("重定向次数过多")
+47
View File
@@ -0,0 +1,47 @@
"""全局限流 —— per-IP 滑动窗口(内存实现,无额外依赖)。
RATE_LIMIT 环境变量:每 IP 每分钟允许的请求数(默认 120;设 0 关闭)。
在鉴权之前计数(401 爆破秘钥同样受限);超限返回 429 + Retry-After。
"""
from __future__ import annotations
import time
from starlette.requests import Request
from starlette.responses import JSONResponse
WINDOW_SECONDS = 60.0
def make_rate_limiter(limit_per_minute: int):
hits: dict[str, list[float]] = {}
last_cleanup = 0.0
async def middleware(request: Request, call_next):
nonlocal last_cleanup
if request.method == "OPTIONS" or limit_per_minute <= 0:
return await call_next(request)
now = time.monotonic()
# 惰性清理:每窗口顺带删除已过期的 IP 记录,防内存无限增长
if now - last_cleanup > WINDOW_SECONDS:
threshold = now - WINDOW_SECONDS
for ip in [ip for ip, times in hits.items() if not times or times[-1] <= threshold]:
hits.pop(ip, None)
last_cleanup = now
ip = request.client.host if request.client else "unknown"
times = [t for t in hits.get(ip, []) if t > now - WINDOW_SECONDS]
times.append(now)
hits[ip] = times
if len(times) > limit_per_minute:
retry_after = max(1, int(times[0] + WINDOW_SECONDS - now) + 1)
return JSONResponse(
{"code": 429, "msg": f"请求过于频繁(限 {limit_per_minute} 次/分钟),请 {retry_after} 秒后重试"},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)
return middleware
+38
View File
@@ -0,0 +1,38 @@
"""流代理签名 —— /stream/* 的无头客户端兼容方案。
/url 返回流地址时附加 `?exp=<秒级时间戳>&sig=<HMAC-SHA256(authKey, "path:exp") 前 32 位>`。
与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口。
"""
from __future__ import annotations
import hashlib
import hmac
import time
# 流地址有效期(秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
STREAM_SIGN_TTL = 10 * 60
def _sign(auth_key: str, data: str) -> str:
return hmac.new(auth_key.encode(), data.encode(), hashlib.sha256).hexdigest()[:32]
def sign_stream(auth_key: str, path: str) -> str:
"""生成流地址的签名参数(path 为不含 query 的路径,如 /stream/mg/123)。"""
exp = int(time.time() + STREAM_SIGN_TTL)
return f"exp={exp}&sig={_sign(auth_key, f'{path}:{exp}')}"
def verify_stream_sign(auth_key: str, path: str, exp: object, sig: object) -> bool:
"""校验流地址签名(常量时间比较 + 时效窗口)。"""
try:
exp_num = int(float(str(exp)))
except (TypeError, ValueError, OverflowError):
return False
sig_str = str(sig or "")
if not sig_str:
return False
if time.time() > exp_num: # 过期即拒(客户端重新 /url 取新地址)
return False
return hmac.compare_digest(_sign(auth_key, f"{path}:{exp_num}"), sig_str)
+196
View File
@@ -0,0 +1,196 @@
"""FastAPI 应用装配 —— 中间件、异常处理、OpenAPI 文档、路由注册。
与 Node 版语义对齐:
* 成功响应统一包成 { code: 0, data }(由路由处理函数调用 `ok()` 显式构造,
文档页、OpenAPI 规范与 /stream 媒体流为 raw 响应)
* 业务错误 { code: <HTTP 状态码>, msg, detail? }
* 422 参数校验失败、404 路由或平台不存在、405 方法不允许(带 Allow 头)、502 上游失败
* 全部响应 Cache-Control: no-store
"""
from __future__ import annotations
import json
import re
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app import __version__, config
from app.http.api_error import ApiError
from app.http.auth_gate import auth_gate
from app.http.rate_limit import make_rate_limiter
from app.openapi.components import COMPONENT_SCHEMAS
from app.openapi.ui import redoc_html
from app.sdk.common.request import close_session
API_TITLE = "MusicServer 聚合音乐 API"
API_DESCRIPTION = (
"多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。"
"支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)/ bd(千千)/ "
"joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 "
"xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。"
)
@asynccontextmanager
async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""启动期副作用 —— 登录态自动续期(启动后延时首刷 + 每 24h 一次,与 TS 版一致)。"""
from app.routes.refresh import start_refresh_scheduler, stop_refresh_scheduler
start_refresh_scheduler()
try:
yield
finally:
await stop_refresh_scheduler()
await close_session()
def create_app() -> FastAPI:
config.load_env()
app = FastAPI(
title=API_TITLE,
description=API_DESCRIPTION,
version=__version__,
lifespan=_lifespan,
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url=None,
openapi_tags=[
{"name": "搜索", "description": "歌曲搜索与跨平台匹配"},
{"name": "歌曲资源", "description": "播放地址、歌词、封面"},
{"name": "榜单", "description": "排行榜"},
{"name": "歌单", "description": "歌单与热搜"},
{"name": "歌手专辑", "description": "歌手信息、歌曲与专辑详情"},
{"name": "登录", "description": "账号登录与状态"},
{"name": "系统", "description": "健康检查、平台信息、文档"},
],
)
app.state.started_at = time.time()
app.state.auth_key = config.auth_key()
# ---- 中间件顺序(后注册者在外层):CORS → no-store → 限流 → 鉴权 ----
# 限流必须在鉴权之外:错误秘钥的 401 也要计数,才能真正防爆破。
app.middleware("http")(auth_gate)
app.middleware("http")(make_rate_limiter(config.rate_limit()))
@app.middleware("http")
async def _no_store(request: Request, call_next):
response = await call_next(request)
response.headers["Cache-Control"] = "no-store"
return response
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
max_age=600,
)
# ---- 异常处理 ----
@app.exception_handler(ApiError)
async def _api_error(_: Request, exc: ApiError):
payload: dict[str, Any] = {"code": exc.status, "msg": str(exc)}
if exc.detail is not None:
payload["detail"] = exc.detail
return JSONResponse(payload, status_code=exc.status)
@app.exception_handler(RequestValidationError)
async def _validation_error(_: Request, exc: RequestValidationError):
errors = json.loads(json.dumps(exc.errors(), default=str))
msg = "参数校验失败"
if errors:
first = errors[0]
loc = ".".join(str(part) for part in first.get("loc", []) if part not in ("body", "query", "path"))
msg = f"{loc}: {first.get('msg', msg)}" if loc else str(first.get("msg", msg))
return JSONResponse({"code": 422, "msg": msg, "detail": errors}, status_code=422)
@app.exception_handler(StarletteHTTPException)
async def _http_error(request: Request, exc: StarletteHTTPException):
if exc.status_code == 404:
allowed = _allowed_methods(request)
if allowed:
return JSONResponse(
{"code": 405, "msg": f"方法 {request.method} 不允许,请使用 {', '.join(allowed)}"},
status_code=405,
headers={"Allow": ", ".join(allowed)},
)
return JSONResponse({"code": 404, "msg": f"接口不存在: {request.url.path}"}, status_code=404)
detail = exc.detail if isinstance(exc.detail, str) else "请求失败"
return JSONResponse({"code": exc.status_code, "msg": detail}, status_code=exc.status_code)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception):
print(f"[{time.strftime('%Y-%m-%dT%H:%M:%S')}] {request.method} {request.url.path} 内部错误: {exc!r}")
return JSONResponse({"code": 500, "msg": "服务器内部错误"}, status_code=500)
# ---- 文档与入口 ----
@app.get("/redoc", include_in_schema=False)
async def redoc():
return HTMLResponse(redoc_html(API_TITLE))
@app.get("/", include_in_schema=False)
async def root():
return RedirectResponse("/docs", status_code=302)
# ---- 业务路由 ----
from app.routes.login import router as login_router
from app.routes.music import router as music_router
from app.routes.system import router as system_router
app.include_router(music_router)
app.include_router(login_router)
app.include_router(system_router)
_install_openapi(app)
return app
def _allowed_methods(request: Request) -> list[str]:
"""路径存在但方法不允许 → 405 的 Allow 列表(OPTIONS/HEAD 不计入)。"""
path = request.url.path
methods: list[str] = []
for route in request.app.routes:
pattern = getattr(route, "path", "")
if not pattern or not _match_path(pattern, path):
continue
for method in getattr(route, "methods", None) or []:
if method not in {"OPTIONS", "HEAD"} and method not in methods:
methods.append(method)
return methods
def _match_path(pattern: str, path: str) -> bool:
"""把 `/url/{source}/{id}` 这类路径模板转成正则做匹配。"""
regex = re.sub(r"\{[^}]+\}", "\u0000", pattern)
regex = re.escape(regex).replace("\u0000", "[^/]+")
return re.fullmatch(regex, path) is not None
def _install_openapi(app: FastAPI) -> None:
"""把领域模型挂进 components,并保留 FastAPI 自动生成的路径参数文档。"""
from fastapi.openapi.utils import get_openapi
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(title=API_TITLE, version=__version__, description=API_DESCRIPTION, routes=app.routes)
components = schema.setdefault("components", {}).setdefault("schemas", {})
for name, definition in COMPONENT_SCHEMAS.items():
components.setdefault(name, definition)
app.openapi_schema = schema
return schema
app.openapi = custom_openapi # type: ignore[method-assign]
app = create_app()
+394
View File
@@ -0,0 +1,394 @@
"""OpenAPI 领域模型 —— 注册为 FastAPI 共享 components schema。
路由的 response 通过 `{$ref: "#/components/schemas/名称"}` 引用。
注:additionalProperties: true 保证序列化时不剥离未声明字段。
"""
from __future__ import annotations
from typing import Any
def ref(name: str) -> dict[str, Any]:
return {"$ref": f"#/components/schemas/{name}"}
COMPONENT_SCHEMAS: dict[str, Any] = {
"ErrorEnvelope": {
"type": "object",
"properties": {
"code": {"type": "integer", "description": "与 HTTP 状态码一致", "example": 422},
"msg": {"type": "string", "description": "错误描述"},
},
"additionalProperties": True,
"required": ["code", "msg"],
},
"QualityInfo": {
"type": "object",
"properties": {"size": {"type": "string", "example": "9.8M"}},
"additionalProperties": True,
},
"SongType": {
"type": "object",
"properties": {
"type": {"type": "string", "example": "320k", "enum": ["128k", "320k", "flac", "flac24bit"]},
"size": {"type": "string", "example": "9.8M"},
},
"additionalProperties": True,
},
"SongInfo": {
"type": "object",
"properties": {
"songmid": {"type": "string", "description": "平台歌曲 id(kg 平台为 hash)"},
"hash": {"type": "string", "description": "kg 平台的歌曲 hash"},
"copyrightId": {"type": "string", "description": "平台版权 id(原 mg 平台使用)"},
"albumId": {"type": "string"},
"name": {"type": "string", "description": "歌名"},
"singer": {"type": "string", "description": "歌手"},
"source": {"type": "string", "description": "来源平台 id"},
"interval": {"type": "string", "description": "时长,格式 mm:ss"},
"albumName": {"type": "string"},
"img": {"type": ["string", "null"], "description": "封面地址"},
"types": {"type": "array", "items": ref("SongType"), "description": "可用音质列表"},
"_types": {"type": "object", "additionalProperties": ref("QualityInfo")},
"typeUrl": {"type": "object"},
},
"additionalProperties": True,
},
"SearchResult": {
"type": "object",
"properties": {
"source": {"type": "string", "description": "来源平台 id,聚合搜索时为 all"},
"list": {"type": "array", "items": ref("SongInfo")},
"page": {"type": "integer"},
"limit": {"type": "integer"},
},
"additionalProperties": True,
},
"FindResult": {
"type": "object",
"properties": {
"list": {"type": "array", "items": ref("SongInfo"), "description": "按匹配度排序的歌曲列表"},
},
"additionalProperties": True,
},
"MusicUrlResult": {
"type": "object",
"properties": {
"type": {"type": "string", "example": "320k", "description": "实际命中的音质(降级链可能低于请求音质)"},
"url": {"type": "string", "description": "播放地址"},
"via": {
"type": "string",
"example": "kw",
"description": "实际命中通道:平台 id / kw=酷我兜底;直接命中时不返回该字段",
},
},
"additionalProperties": True,
},
"LyricInfo": {
"type": "object",
"properties": {
"lyric": {"type": "string", "description": "原文歌词(LRC)"},
"tlyric": {"type": "string", "description": "翻译歌词"},
"rlyric": {"type": "string", "description": "罗马音歌词(部分平台)"},
"yrc": {"type": "string", "description": "逐字歌词(部分平台)"},
},
"additionalProperties": True,
"required": ["lyric", "tlyric"],
},
"PicResult": {
"type": "object",
"properties": {"url": {"type": "string", "description": "封面地址"}},
"additionalProperties": True,
},
"BoardItem": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "榜单 id,用于榜单详情接口"},
"name": {"type": "string"},
"bangid": {"type": "string", "description": "平台原始榜单 id"},
},
"additionalProperties": True,
},
"BoardListResult": {
"type": "object",
"properties": {"list": {"type": "array", "items": ref("BoardItem")}},
"additionalProperties": True,
},
"SingerInfo": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "歌手名"},
"desc": {"type": ["string", "null"], "description": "歌手简介"},
"avatar": {"type": ["string", "null"], "description": "头像地址"},
"gender": {"type": ["string", "null"], "description": "性别(man/woman)"},
},
"additionalProperties": True,
},
"SingerInfoResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"id": {"type": "string", "description": "平台歌手 id"},
"info": ref("SingerInfo"),
"count": {
"type": "object",
"properties": {
"music": {"type": ["integer", "null"], "description": "歌曲数"},
"album": {"type": ["integer", "null"], "description": "专辑数"},
},
"additionalProperties": True,
},
},
"additionalProperties": True,
},
"SingerAlbumItem": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "专辑 id,可用于 /album/:source/:id"},
"mid": {"type": "string", "description": "tx 平台专辑 mid"},
"count": {"type": ["integer", "null"], "description": "专辑曲目数"},
"info": {
"type": "object",
"properties": {
"name": {"type": "string"},
"author": {"type": ["string", "null"]},
"img": {"type": ["string", "null"]},
"desc": {"type": ["string", "null"]},
},
"additionalProperties": True,
},
},
"additionalProperties": True,
},
"SingerAlbumListResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"list": {"type": "array", "items": ref("SingerAlbumItem")},
"page": {"type": "integer"},
"limit": {"type": "integer"},
"total": {"type": ["integer", "string", "null"]},
},
"additionalProperties": True,
},
"SingerSongListResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"list": {"type": "array", "items": ref("SongInfo")},
"page": {"type": "integer"},
"limit": {"type": "integer"},
"total": {"type": ["integer", "string", "null"]},
},
"additionalProperties": True,
},
"AlbumInfo": {
"type": "object",
"properties": {
"name": {"type": ["string", "null"], "description": "专辑名"},
"img": {"type": ["string", "null"], "description": "专辑封面"},
"desc": {"type": ["string", "null"], "description": "专辑简介"},
"author": {"type": ["string", "null"], "description": "专辑作者"},
},
"additionalProperties": True,
},
"AlbumDetailResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"info": ref("AlbumInfo"),
"list": {"type": "array", "items": ref("SongInfo"), "description": "专辑曲目"},
"page": {"type": "integer"},
"limit": {"type": ["integer", "string", "null"]},
"total": {"type": ["integer", "string", "null"]},
},
"additionalProperties": True,
},
"SongItem": {
"type": "object",
"properties": {
"singer": {"type": "string"},
"name": {"type": "string"},
"albumName": {"type": "string"},
"albumId": {"type": "string"},
"source": {"type": "string"},
"interval": {"type": "string"},
"songmid": {"type": "string"},
"hash": {"type": "string"},
"img": {"type": "string"},
"types": {"type": "array", "items": ref("SongType")},
},
"additionalProperties": True,
},
"BoardDetailResult": {
"type": "object",
"properties": {
"list": {"type": "array", "items": ref("SongItem")},
"total": {"type": "integer"},
"page": {"type": "integer"},
"limit": {"type": "integer"},
"source": {"type": "string"},
},
"additionalProperties": True,
},
"HotSearchResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"list": {"type": "array", "items": {"type": "string"}},
},
"additionalProperties": True,
},
"SongListSummary": {
"type": "object",
"properties": {
"play_count": {"type": "string", "description": "播放量(已格式化)"},
"id": {"type": "string", "description": "歌单 id"},
"author": {"type": "string"},
"name": {"type": "string"},
"time": {"type": "string"},
"img": {"type": "string"},
"total": {"type": "integer", "description": "歌曲数"},
"desc": {"type": "string"},
"source": {"type": "string"},
},
"additionalProperties": True,
},
"SongListResult": {
"type": "object",
"properties": {
"list": {"type": "array", "items": ref("SongListSummary")},
"total": {"type": "integer"},
"page": {"type": "integer"},
"limit": {"type": "integer"},
"source": {"type": "string"},
},
"additionalProperties": True,
},
"SourceInfo": {
"type": "object",
"properties": {
"id": {"type": "string", "example": "kw"},
"name": {"type": "string", "example": "酷我音乐"},
"qualities": {"type": "array", "items": {"type": "string"}, "description": "支持的音质"},
"capabilities": {
"type": "object",
"properties": {
"search": {"type": "boolean"},
"url": {"type": "boolean"},
"lyric": {"type": "boolean"},
"pic": {"type": "boolean"},
"boards": {"type": "boolean"},
"hotSearch": {"type": "boolean"},
"songList": {"type": "boolean"},
"singer": {"type": "boolean", "description": "支持歌手接口(/singer/:source/:id)"},
"album": {"type": "boolean", "description": "支持专辑接口(/album/:source/:id)"},
"albumSearch": {"type": "boolean", "description": "支持专辑搜索(/album/:source/search/:keyword)"},
"playlist": {"type": "boolean", "description": "支持歌单详情(/playlist/:source/:id)"},
"playlistSearch": {"type": "boolean", "description": "支持歌单搜索"},
"playlistCategories": {"type": "boolean", "description": "支持歌单分类"},
"parse": {"type": "boolean", "description": "支持链接解析(/parse/:source)"},
"userPlaylist": {"type": "boolean", "description": "支持个人歌单(/user-playlist/:source)"},
},
"additionalProperties": True,
},
},
"additionalProperties": True,
},
"SourcesResult": {
"type": "object",
"properties": {"platforms": {"type": "array", "items": ref("SourceInfo")}},
"additionalProperties": True,
},
"PlaylistSummary": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "歌单/专辑 id"},
"name": {"type": "string"},
"cover": {"type": "string"},
"trackCount": {"type": "integer"},
"playCount": {"type": "integer"},
"creator": {"type": "string"},
"description": {"type": "string"},
"link": {"type": "string"},
"source": {"type": "string"},
},
"additionalProperties": True,
},
"PlaylistSearchResult": {
"type": "object",
"properties": {
"list": {"type": "array", "items": ref("PlaylistSummary")},
"total": {"type": "integer"},
"page": {"type": "integer"},
"limit": {"type": "integer"},
"source": {"type": "string"},
},
"additionalProperties": True,
},
"PlaylistDetailResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"info": ref("AlbumInfo"),
"list": {"type": "array", "items": ref("SongItem")},
"page": {"type": "integer"},
"limit": {"type": ["integer", "string", "null"]},
"total": {"type": ["integer", "string", "null"]},
},
"additionalProperties": True,
},
"PlaylistCategoriesResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"group": {"type": "string"},
"hot": {"type": "boolean"},
},
"additionalProperties": True,
},
},
},
"additionalProperties": True,
},
"ParseResult": {
"type": "object",
"properties": {
"source": {"type": "string"},
"type": {"type": "string", "description": "解析类型:song / album / playlist"},
"song": ref("SongItem"),
"playlist": ref("PlaylistSummary"),
"list": {"type": "array", "items": ref("SongItem"), "description": "album/playlist 解析时的曲目"},
"total": {"type": "integer"},
},
"additionalProperties": True,
},
"UserPlaylistResult": {
"type": "object",
"properties": {
"list": {"type": "array", "items": ref("PlaylistSummary")},
"page": {"type": "integer"},
"limit": {"type": "integer"},
"total": {"type": "integer"},
"source": {"type": "string"},
},
"additionalProperties": True,
},
"HealthResult": {
"type": "object",
"properties": {
"status": {"type": "string", "example": "ok"},
"uptime": {"type": "integer", "description": "运行秒数"},
"platforms": {"type": "integer", "description": "已接入平台数"},
},
"additionalProperties": True,
},
}
+21
View File
@@ -0,0 +1,21 @@
"""ReDoc 文档页。"""
from __future__ import annotations
REDOC_CDN = "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
def redoc_html(title: str, spec_url: str = "/openapi.json") -> str:
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} (ReDoc)</title>
<style>body {{ margin: 0; padding: 0; }}</style>
</head>
<body>
<redoc spec-url="{spec_url}" hide-download-button="true"></redoc>
<script src="{REDOC_CDN}"></script>
</body>
</html>"""
+432
View File
@@ -0,0 +1,432 @@
"""登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/密码/token,tx 扫码/cookie,
xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json。
成功返回 `ok(data)`({ code: 0, data });可预期失败抛 ApiError,其它异常按 502 上报
(与原实现 `fail(reply, err)` 的 `err.status ?? 502` 一致)。
"""
from __future__ import annotations
from typing import Any
from app.http.api_error import ApiError, not_found, upstream
from app.http.envelope import ok
from app.routes.refresh import refresh_all
from app.sdk import auth as auth_mod
from app.sdk.auth_store import clear_credentials, get_credentials, get_tx_auth, set_credentials
from fastapi import APIRouter, Body, Path
router = APIRouter()
LOGIN_TAG = "登录"
async def _call(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""调用平台登录实现并包成信封;ApiError 原样抛(保留状态码),其余转 502。"""
try:
return ok(await fn(*args, **kwargs))
except ApiError:
raise
except NotImplementedError as err:
raise ApiError(501, f"该登录方式尚未移植:{err}") from err
except Exception as err:
raise upstream(str(err)) from err
def _body(description: str) -> Any:
# embed=True:与 TS 契约一致,所有登录接口都以 JSON 对象传参({"key": ...}),
# 而不是把单个参数当成裸 JSON 值。多参数时 FastAPI 本就自动嵌套,行为不变。
return Body(..., embed=True, description=description)
# ---------- 网易云 ----------
@router.post(
"/login/wy/qr",
tags=[LOGIN_TAG],
summary="获取网易云扫码登录二维码",
description="用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。",
)
async def wy_qr() -> Any:
return await _call(auth_mod.wy.qr_key)
@router.post(
"/login/wy/qr/check",
tags=[LOGIN_TAG],
summary="轮询网易云扫码状态",
description="code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。",
)
async def wy_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
return await _call(auth_mod.wy.qr_check, key)
@router.post(
"/login/wy/cellphone",
tags=[LOGIN_TAG],
summary="网易云手机号登录",
description="password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。",
)
async def wy_cellphone(
phone: str = _body("手机号"),
password: str | None = Body(None, description="明文密码"),
captcha: str | None = Body(None, description="短信验证码"),
countrycode: str = Body("86", description="国家码"),
) -> Any:
return await _call(auth_mod.wy.cellphone_login, phone, password, captcha, countrycode)
@router.post(
"/login/wy",
tags=[LOGIN_TAG],
summary="绑定网易云 cookie",
description="cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。"
"绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。",
)
async def wy_bind(cookie: str = _body("浏览器 cookie")) -> Any:
return await _call(auth_mod.wy.login_by_cookie, cookie)
@router.delete("/login/wy", tags=[LOGIN_TAG], summary="退出网易云登录")
async def wy_logout() -> Any:
clear_credentials("wy")
return ok({"logged": False})
# ---------- 酷狗 ----------
@router.post(
"/login/kg",
tags=[LOGIN_TAG],
summary="绑定酷狗 token",
description="token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。"
"推荐改用 /login/kg/qr 扫码或 /login/kg/cellphone 短信验证码登录,无需抓包。",
)
async def kg_bind(token: str = _body("酷狗 token"), userid: str | int = _body("酷狗 userid")) -> Any:
return await _call(auth_mod.kg.login_by_token, token, userid)
@router.post("/login/kg/qr", tags=[LOGIN_TAG], summary="获取酷狗扫码登录二维码")
async def kg_qr() -> Any:
return await _call(auth_mod.kg.qr_key)
@router.post("/login/kg/qr/check", tags=[LOGIN_TAG], summary="轮询酷狗扫码状态")
async def kg_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
return await _call(auth_mod.kg.qr_check, key)
@router.post(
"/login/kg/captcha",
tags=[LOGIN_TAG],
summary="发送酷狗短信验证码",
description="向手机号发送登录验证码(有频控,勿频繁调用),随后用 /login/kg/cellphone 提交。",
)
async def kg_captcha(mobile: str = _body("手机号")) -> Any:
return await _call(auth_mod.kg.send_captcha, mobile)
@router.post("/login/kg/cellphone", tags=[LOGIN_TAG], summary="酷狗手机验证码登录")
async def kg_cellphone(mobile: str = _body("手机号"), code: str = _body("短信验证码")) -> Any:
return await _call(auth_mod.kg.cellphone_login, mobile, code)
@router.post(
"/login/kg/password",
tags=[LOGIN_TAG],
summary="酷狗账号密码登录",
description="触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):"
"滑块类需浏览器完成图形验证后调 /login/kg/verify 再重试;短信类(v_type=32)先提交短信码再带 code 重试。",
)
async def kg_password(
username: str = _body("账号"),
password: str = _body("密码"),
code: str | None = Body(None, description="短信验证码(20028 短信验证通过后重试登录时携带)"),
) -> Any:
return await _call(auth_mod.kg.password_login, username, password, code)
@router.post("/login/kg/verify", tags=[LOGIN_TAG], summary="提交酷狗风控验证结果")
async def kg_verify(
eventid: str = _body("needVerify 返回的 eventid"),
v_type: int = _body("验证类型(needVerify 返回的 v_type)"),
verifycode: str = _body("图形验证码凭证或短信验证码"),
sid: str | None = Body(None, description="行为指纹 sid(省略时服务端生成)"),
edt: str | None = Body(None, description="行为指纹 edt(省略时服务端生成)"),
) -> Any:
return await _call(auth_mod.kg.verify_user_info, eventid, v_type, verifycode, sid, edt)
@router.delete("/login/kg", tags=[LOGIN_TAG], summary="退出酷狗登录")
async def kg_logout() -> Any:
clear_credentials("kg")
return ok({"logged": False})
# ---------- QQ 音乐 ----------
@router.post(
"/login/tx/qr",
tags=[LOGIN_TAG],
summary="获取 QQ 音乐扫码登录二维码",
description="返回 qrimg(data URL)可直接展示,qrsig 需原样回传。",
)
async def tx_qr() -> Any:
return await _call(auth_mod.tx.qr_show)
@router.post("/login/tx/qr/check", tags=[LOGIN_TAG], summary="轮询 QQ 音乐扫码状态")
async def tx_qr_check(qrsig: str = _body("qrShow 返回的 qrsig")) -> Any:
return await _call(auth_mod.tx.qr_check, qrsig)
@router.post(
"/login/tx",
tags=[LOGIN_TAG],
summary="绑定 QQ 音乐 cookie",
description="cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),"
"用于解锁 tx 取流的 320k/无损音质。",
)
async def tx_bind(
cookie: str = _body("浏览器 cookie"),
openId: str | None = Body(None, description="App 抓包的 openId(用于自动续期)"),
accessToken: str | None = Body(None, description="App 抓包的 accessToken"),
refreshToken: str | None = Body(None, description="App 抓包的 refreshToken"),
refreshKey: str | None = Body(None, description="App 抓包的 refreshKey"),
) -> Any:
import re
match = re.search(r"uin=(\d+)", cookie)
if not match or not re.search(r"qm_keyst=\S+", cookie):
raise ApiError(422, "cookie 需包含 uin 与 qm_keyst 字段")
uin = match.group(1)
set_credentials(
"tx",
{
"cookie": cookie,
"uin": uin,
"openId": openId,
"accessToken": accessToken,
"refreshToken": refreshToken,
"refreshKey": refreshKey,
},
)
return ok({"logged": True, "uin": uin})
@router.delete("/login/tx", tags=[LOGIN_TAG], summary="退出 QQ 音乐登录")
async def tx_logout() -> Any:
clear_credentials("tx")
return ok({"logged": False})
# ---------- 喜马拉雅 / 猫耳 / 懒人听书 ----------
@router.post(
"/login/xm",
tags=[LOGIN_TAG],
summary="绑定喜马拉雅 cookie",
description="cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段)。",
)
async def xm_bind(cookie: str = _body("浏览器 cookie")) -> Any:
import re
if not re.search(r"1&_token=\S+", cookie):
raise ApiError(422, "cookie 需包含 1&_token 字段")
set_credentials("xm", {"cookie": cookie})
return ok({"logged": True})
@router.delete("/login/xm", tags=[LOGIN_TAG], summary="退出喜马拉雅登录")
async def xm_logout() -> Any:
clear_credentials("xm")
return ok({"logged": False})
@router.post(
"/login/me",
tags=[LOGIN_TAG],
summary="绑定猫耳FM token",
description="cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段)。",
)
async def me_bind(cookie: str = _body("浏览器 cookie")) -> Any:
import re
if not re.search(r"(?:^|;\s*)token=\S+", cookie):
raise ApiError(422, "cookie 需包含 token 字段")
set_credentials("me", {"cookie": cookie})
return ok({"logged": True})
@router.delete("/login/me", tags=[LOGIN_TAG], summary="退出猫耳FM登录")
async def me_logout() -> Any:
clear_credentials("me")
return ok({"logged": False})
@router.post(
"/login/lr",
tags=[LOGIN_TAG],
summary="懒人听书账号登录",
description="m.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化,"
"此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。",
)
async def lr_login(
account: str = _body("账号(手机号)"), password: str = _body("密码(明文传入,服务端自行编码)")
) -> Any:
return await _call(auth_mod.lr.login, account, password)
@router.delete("/login/lr", tags=[LOGIN_TAG], summary="退出懒人听书登录")
async def lr_logout() -> Any:
clear_credentials("lr")
return ok({"logged": False})
# ---------- 哔哩哔哩 ----------
@router.post(
"/login/bili/qr",
tags=[LOGIN_TAG],
summary="获取哔哩哔哩扫码登录二维码",
description="用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL。",
)
async def bili_qr() -> Any:
return await _call(auth_mod.bili.create_qr_login)
@router.post(
"/login/bili/qr/check",
tags=[LOGIN_TAG],
summary="轮询哔哩哔哩扫码状态",
description="status:waiting / scanned / success(自动保存凭据)/ expired / failed。"
"code 为映射后的状态码:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(与其它平台一致)。",
)
async def bili_qr_check(key: str = _body("createQRLogin 返回的 key")) -> Any:
return await _call(auth_mod.bili.check_qr_login, key)
@router.delete("/login/bili", tags=[LOGIN_TAG], summary="退出哔哩哔哩登录")
async def bili_logout() -> Any:
clear_credentials("bili")
return ok({"logged": False})
# ---------- 通用 ----------
@router.post(
"/login/refresh",
tags=[LOGIN_TAG],
summary="手动触发登录态续期",
description="对已登录的 kg/tx 执行 token/cookie 续期(与自动任务同一逻辑)。"
"未登录的平台跳过;失败的平台在对应字段返回 error,不影响其他平台。",
)
async def login_refresh() -> Any:
return ok(await refresh_all())
# 这些平台没有独立登录协议,只能从浏览器/App 复制 cookie(README 平台表已声明 cookie 登录)。
# 白名单限定,避免动态路由把任意平台写进凭据文件。
_COOKIE_LOGIN_SOURCES = {"mg", "bd", "joox", "apple", "5sing"}
@router.post(
"/login/{source}",
tags=[LOGIN_TAG],
summary="绑定平台 cookie",
description="mg/bd/joox/apple/5sing 用:从浏览器或 App 复制完整 cookie 字符串并持久化。"
"apple 需包含 media-user-token;joox 通常需 wmid/session_key。服务端不做在线校验。",
)
async def cookie_bind(
source: str = Path(..., description="平台 id(mg/bd/joox/apple/5sing)", examples=["bd"]),
cookie: str = Body(..., embed=True, description="完整 cookie 字符串"),
) -> Any:
if source not in _COOKIE_LOGIN_SOURCES:
raise not_found(f"不支持的 cookie 登录平台: {source}")
value = str(cookie or "").strip()
if not value:
raise ApiError(422, "cookie 不能为空")
set_credentials(source, {"cookie": value})
return ok({"logged": True})
@router.delete(
"/login/{source}",
tags=[LOGIN_TAG],
summary="退出平台登录(cookie 平台)",
)
async def cookie_logout(source: str = Path(..., description="平台 id(mg/bd/joox/apple/5sing)")) -> Any:
if source not in _COOKIE_LOGIN_SOURCES:
raise not_found(f"不支持的 cookie 登录平台: {source}")
clear_credentials(source)
return ok({"logged": False})
def _stored_status(package: str) -> dict[str, Any]:
"""按已存凭据判断登录态(xm/me/bd/joox/apple/5sing 用)。"""
cred = get_credentials(package)
if not cred:
return {"logged": False}
return {"logged": True, "updatedAt": cred.get("updatedAt")}
@router.get(
"/login/status",
tags=[LOGIN_TAG],
summary="查看登录状态",
description="wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据"
"(含 VIP 等级与最近更新时间),xm/me/lr/bd/joox/apple/5sing 为已存凭据。",
)
async def login_status() -> Any:
async def safe_wy() -> dict[str, Any]:
try:
result = await auth_mod.wy.status()
return {"logged": True, **result} if result else {"logged": False}
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
return {"logged": False}
async def safe_kg() -> dict[str, Any]:
cred = get_credentials("kg")
if not cred or not cred.get("token"):
return {"logged": False}
try:
result = await auth_mod.kg.status()
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
result = None
payload = {**(result or {}), "updatedAt": cred.get("updatedAt")}
return {"logged": True, **payload}
wy_result = await safe_wy()
tx = get_tx_auth()
tx_cred = get_credentials("tx") or {}
return ok(
{
"wy": wy_result,
"kg": await safe_kg(),
"tx": (
{
"logged": True,
"uin": tx["uin"],
"vipType": tx_cred.get("vipType", None),
"updatedAt": tx_cred.get("updatedAt"),
}
if tx
else {"logged": False}
),
"xm": _stored_status("xm"),
"me": _stored_status("me"),
"lr": _stored_status("lr"),
"bili": _stored_status("bili"),
"bd": _stored_status("bd"),
"joox": _stored_status("joox"),
"apple": _stored_status("apple"),
"5sing": _stored_status("5sing"),
}
)
+886
View File
@@ -0,0 +1,886 @@
"""业务路由 —— 搜索、取流、歌词、封面、榜单、歌单、歌手专辑、链接解析。
处理函数返回 `ok(data)`,由 main.Envelope 统一包成 { code: 0, data };
抛出 ApiError 由全局异常处理器转成对应 HTTP 状态码。
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Callable
from typing import Any
from app.http.api_error import ApiError, not_found, upstream
from app.http.envelope import ok
from app.http.mg_stream import (
Z3DStreamDecoder,
migu_stream_content_type,
register_migu_stream,
stream_decrypted,
take_migu_stream,
)
from app.http.stream_sign import sign_stream
from app.sdk import SOURCE_MODULES, SUPPORTED_SOURCES, find_music, support_quality
from app.sdk.types import cap as get_cap
from fastapi import APIRouter, Body, Path, Query, Request
from fastapi.responses import StreamingResponse
router = APIRouter()
QUALITIES = ["64k", "128k", "320k", "flac", "flac24bit"]
def _allowed_qualities(source: str) -> list[str]:
"""通用档位 + 平台自定义档位(如 5sing 的 sq/hq/lq)。"""
return list(dict.fromkeys([*QUALITIES, *support_quality.get(source, [])]))
# ---- 常用 OpenAPI 响应片段 ----
def envelope_ref(name: str) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"code": {"type": "integer", "description": "0 表示成功", "example": 0},
"data": {"$ref": f"#/components/schemas/{name}"},
},
"required": ["code", "data"],
}
ERROR_ENVELOPE = {"$ref": "#/components/schemas/ErrorEnvelope"}
def responses_for(
data_ref: str, *, not_found: bool = True, upstream_doc: bool = True
) -> dict[int | str, dict[str, Any]]:
docs: dict[int | str, dict[str, Any]] = {
"200": {"description": "成功", "content": {"application/json": {"schema": envelope_ref(data_ref)}}},
"422": {"description": "参数校验失败", "content": {"application/json": {"schema": ERROR_ENVELOPE}}},
}
if not_found:
docs["404"] = {"description": "接口或平台不存在", "content": {"application/json": {"schema": ERROR_ENVELOPE}}}
if upstream_doc:
docs["502"] = {"description": "上游平台失败", "content": {"application/json": {"schema": ERROR_ENVELOPE}}}
return docs
def get_source(source: str, capability: str | None = None) -> Any:
"""取平台模块(按能力位校验),不存在或无该能力 → 404。"""
module = SOURCE_MODULES.get(source)
if module is None or get_cap(module, capability) is None:
raise not_found(f"不支持的平台: {source}")
return module
def platform_path(source: str, capability: str | None = None, description: str = "平台 id(见 /sources)") -> Any:
return Path(..., description=f"{description}{f',需支持 {capability} 能力' if capability else ''}", examples=["kw"])
# ---------- 搜索 ----------
async def _search_handler(source: str, keyword: str, page: int, singer: str | None, limit: int) -> Any:
word = f"{keyword} {singer or ''}".strip()
if source != "all":
get_source(source, "music_search")
result = await search_platform(source, word, page, limit)
return {"source": source, "list": result, "page": page, "limit": limit}
# 聚合:排除有声平台,失败的平台跳过
import asyncio
targets = [
item["id"]
for item in SUPPORTED_SOURCES
if getattr(item["module"], "music_search", None) is not None and not item.get("no_aggregate")
]
async def one(platform: str):
try:
return await search_platform(platform, word, page, limit)
except Exception: # noqa: BLE001
return []
lists = await asyncio.gather(*(one(platform) for platform in targets))
return {"source": "all", "list": [song for chunk in lists for song in chunk], "page": page, "limit": limit}
async def search_platform(platform: str, word: str, page: int, limit: int) -> list[dict[str, Any]]:
try:
result = await SOURCE_MODULES[platform].music_search.search(word, page, limit)
except Exception as err:
raise upstream(str(err)) from err
return result.get("list") or []
@router.get(
"/search/{source}/{keyword}",
summary="搜索歌曲(第 1 页)",
description="source 为平台 id 或 all(聚合,排除 xm/me/qt/lr 有声平台,失败的平台跳过)。"
"path 直接跟搜索内容,第 1 页可省略页码段;歌名含空格/斜杠时请拆分——歌手走 singer 查询参数。",
tags=["搜索"],
responses=responses_for("SearchResult", not_found=False),
)
async def search_first(
source: str = platform_path("kw", "search"),
keyword: str = Path(..., description="搜索关键词(URL 编码的中文可直接使用)", examples=["稻香"]),
singer: str | None = Query(None, description="歌手名(与关键词拼接搜索)", examples=["周杰伦"]),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return ok(await _search_handler(source, keyword, 1, singer, limit))
@router.get(
"/search/{source}/{keyword}/{page}",
summary="搜索歌曲(指定页)",
description="同 /search/:source/:keyword,页码作为 path 段。",
tags=["搜索"],
responses=responses_for("SearchResult", not_found=False),
)
async def search_page(
source: str = platform_path("kw", "search"),
keyword: str = Path(..., description="搜索关键词", examples=["稻香"]),
page: int = Path(..., ge=1, description="页码"),
singer: str | None = Query(None, description="歌手名"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return ok(await _search_handler(source, keyword, page, singer, limit))
@router.get(
"/find/{source}/{keyword}",
summary="精确匹配歌曲",
description="按歌手、歌名、专辑、时长综合排序的精确匹配。source 为平台 id(只在单平台内匹配)或 all"
"(跨平台聚合,可用 excludeSource 跳过某平台;聚合自动排除 xm/me/qt/lr 有声平台)。",
tags=["搜索"],
responses=responses_for("FindResult"),
)
async def find(
source: str = Path(..., description="平台 id 或 all", examples=["all"]),
keyword: str = Path(..., description="歌名", examples=["稻香"]),
singer: str | None = Query(None, description="歌手名", examples=["周杰伦"]),
albumName: str | None = Query(None, description="专辑名", examples=["魔杰座"]),
interval: str | None = Query(None, description="时长(mm:ss),用于时长校验", examples=["03:43"]),
excludeSource: str | None = Query(None, description="source=all 时跳过该平台", examples=["kw"]),
) -> Any:
kwargs: dict[str, Any] = {
"name": keyword,
"singer": singer,
"album_name": albumName,
"interval": interval,
}
if source != "all":
get_source(source, "music_search")
kwargs["only"] = source
elif excludeSource:
get_source(excludeSource, "music_search")
kwargs["source"] = excludeSource
try:
return ok({"list": await find_music(**kwargs)})
except Exception as err:
raise upstream(str(err)) from err
# ---------- 歌曲资源 ----------
@router.get(
"/url/{source}/{id}",
summary="获取播放地址",
description="id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,xm/me 用声音 id,qt 为 <channelId>:<programId>"
"(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。"
"取流按「SDK 官方直连 → 酷我兜底」顺序尝试,酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),"
"kg 可由 hash 反查歌曲信息,其它平台需要兜底时传 name/singer/interval。"
"响应中的 via 字段标记实际命中通道(仅兜底时出现)。",
tags=["歌曲资源"],
responses=responses_for("MusicUrlResult"),
)
async def music_url(
request: Request,
source: str = platform_path("kw", "url"),
id: str = Path(..., description="平台歌曲 id", examples=["28423286"]),
quality: str = Query("128k", description="音质"),
albumId: str | None = Query(None, description="专辑 id"),
name: str | None = Query(None, description="歌名(酷我兜底匹配用)"),
singer: str | None = Query(None, description="歌手名(酷我兜底匹配用)"),
interval: str | None = Query(None, description="时长 mm:ss(酷我兜底匹配用)", examples=["04:17"]),
) -> Any:
allowed = _allowed_qualities(source)
if quality not in allowed:
raise ApiError(422, f"quality: 不支持的音质 {quality}({source} 可选:{', '.join(allowed)})")
module = get_source(source, "get_music_url")
song_info = {"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or "", "_types": {}}
def finish(result: dict[str, Any], via: str) -> dict[str, Any]:
if not result or not result.get("url"):
raise RuntimeError("获取播放地址失败")
raw_url = str(result["url"])
if result.get("encrypted") and result.get("fileKey"):
raw_url = f"/stream/mg/{register_migu_stream(raw_url, str(result['fileKey']))}"
# 相对地址按请求的协议与 host 补全(兼容本机/局域网播放器)
if raw_url.startswith("/"):
suffix = ""
auth_key = getattr(request.app.state, "auth_key", "")
if auth_key:
path = raw_url.split("?")[0]
suffix = f"{'&' if '?' in raw_url else '?'}{sign_stream(auth_key, path)}"
base = str(request.base_url).rstrip("/")
raw_url = f"{base}{raw_url}{suffix}"
payload: dict[str, Any] = {"type": result.get("type") or quality, "url": raw_url}
if via != source:
payload["via"] = via
return payload
errors: list[str] = []
try:
return ok(finish(await module.get_music_url(song_info, quality), source))
except Exception as err: # noqa: BLE001
errors.append(f"{source}: {err}")
# 酷我兜底:匿名可拿真无损的通道,按歌名/歌手/时长匹配同一首歌后取流
if source != "kw":
try:
meta: dict[str, Any] | None = {"name": name, "singer": singer, "interval": interval} if name else None
if not meta and source == "kg":
# kg 可用 hash 反查歌曲信息,无需调用方额外传参
kg_module = SOURCE_MODULES.get("kg")
lookup = getattr(kg_module, "get_music_info", None)
info = await lookup({"hash": id, "songmid": id}) if lookup else None
if info and info.get("name"):
meta = {
"name": str(info["name"]),
"singer": str(info["singer"]) if info.get("singer") else None,
"interval": str(info["interval"]) if info.get("interval") else None,
}
if meta and meta.get("name"):
best_list = await find_music(
name=str(meta["name"]),
singer=meta.get("singer"),
interval=meta.get("interval"),
only="kw",
)
best = best_list[0] if best_list else None
if best:
kw_module = SOURCE_MODULES["kw"]
res = await kw_module.get_music_url(
{"songmid": best["songmid"], "hash": best["songmid"], "_types": {}}, quality
)
return ok(finish(res, "kw"))
errors.append("kw 兜底: 未匹配到同名歌曲")
else:
errors.append("kw 兜底: 缺少歌名信息(传 name/singer/interval 查询参数可启用)")
except Exception as err: # noqa: BLE001
errors.append(f"kw 兜底: {err}")
raise upstream(f"取流失败({';'.join(errors)})")
@router.get(
"/stream/{source}/{id}",
summary="加密流解密代理(mg)",
description="取流响应中返回的相对地址(/url/mg 命中加密流时的地址),服务端下载上游密流并在本地解密后返回明文音频;"
"不接受客户端自行构造。id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。",
tags=["歌曲资源"],
response_class=StreamingResponse,
)
async def stream(source: str, id: str) -> Any:
if source != "mg":
raise not_found(f"不支持的加密流平台: {source}")
ticket = take_migu_stream(str(id))
if not ticket:
raise not_found("解密票据不存在或已过期(请重新调用 /url/mg 取流)")
return _stream_response(ticket.url, Z3DStreamDecoder(ticket.file_key))
def _stream_response(url: str, decoder: Z3DStreamDecoder) -> StreamingResponse:
"""异步队列桥接:下载协程生产解密块,StreamingResponse 消费块。
解密按块增量进行(跨块保持全局偏移),整段音频可达上百 MB,不能读进内存。
队列有界并由生产者 ``await queue.put`` 形成背压;客户端断开时整个任务被取消。
上游/解密失败会作为异常项传给消费者并中断响应,避免把截断音频伪装成正常结束。
"""
queue: asyncio.Queue[bytes | Exception | None] = asyncio.Queue(maxsize=32)
async def produce() -> None:
try:
await stream_decrypted(url, decoder.feed, queue.put)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - 响应头已发出,只能把错误交给消费者中断连接
print(f"[stream] 上游读取中断: {err!r}")
await queue.put(err)
return
await queue.put(None)
async def body():
task = asyncio.create_task(produce())
try:
while True:
item = await queue.get()
if item is None:
break
if isinstance(item, Exception):
raise item
yield item
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return StreamingResponse(
body(),
media_type=migu_stream_content_type(url),
headers={"Accept-Ranges": "none"},
)
@router.get(
"/lyric/{source}/{id}",
summary="获取歌词",
tags=["歌曲资源"],
responses=responses_for("LyricInfo"),
)
async def lyric(
source: str = platform_path("kw", "lyric"),
id: str = Path(..., description="平台歌曲 id"),
name: str | None = Query(None, description="歌名(部分平台搜索歌词时需要)"),
interval: str | None = Query(None, description="时长(mm:ss),部分平台搜索歌词时需要"),
) -> Any:
module = get_source(source, "get_lyric")
try:
result = await module.get_lyric(
{"songmid": id, "hash": id, "copyrightId": id, "name": name or "", "interval": interval or ""}
)
except Exception as err:
raise upstream(str(err)) from err
return ok(result or {"lyric": "", "tlyric": ""})
@router.get(
"/pic/{source}/{id}",
summary="获取封面",
tags=["歌曲资源"],
responses=responses_for("PicResult"),
)
async def pic(
source: str = platform_path("kw", "pic"),
id: str = Path(..., description="平台歌曲 id"),
albumId: str | None = Query(None, description="专辑 id(kg 平台需要)"),
) -> Any:
module = get_source(source, "get_pic")
try:
url = await module.get_pic({"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or ""})
except Exception as err:
raise upstream(str(err)) from err
return ok({"url": url})
# ---------- 榜单 ----------
@router.get(
"/boards/{source}",
summary="获取榜单列表",
tags=["榜单"],
responses=responses_for("BoardListResult"),
)
async def boards(source: str = platform_path("kw", "boards")) -> Any:
module = get_source(source, "leaderboard")
try:
return ok(await module.leaderboard.get_boards())
except Exception as err:
raise upstream(str(err)) from err
async def _board_handler(source: str, board_id: str, page: int) -> Any:
module = get_source(source, "leaderboard")
# 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化
bare = board_id[len(f"{source}__") :] if board_id.startswith(f"{source}__") else board_id
try:
return ok(await module.leaderboard.get_list(bare, page))
except Exception as err:
raise upstream(str(err)) from err
@router.get(
"/boards/{source}/{boardId}",
summary="获取榜单详情",
tags=["榜单"],
responses=responses_for("BoardDetailResult"),
)
async def board_first(
source: str = platform_path("kw", "boards"),
boardId: str = Path(
..., description="榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)", examples=["kw__93"]
),
) -> Any:
return await _board_handler(source, boardId, 1)
@router.get(
"/boards/{source}/{boardId}/{page}",
summary="获取榜单详情(指定页)",
tags=["榜单"],
responses=responses_for("BoardDetailResult"),
)
async def board_page(
source: str = platform_path("kw", "boards"),
boardId: str = Path(..., description="榜单 id"),
page: int = Path(..., ge=1, description="页码"),
) -> Any:
return await _board_handler(source, boardId, page)
# ---------- 热搜 & 歌单列表 ----------
@router.get(
"/hot-search/{source}",
summary="获取热搜词",
tags=["歌单"],
responses=responses_for("HotSearchResult"),
)
async def hot_search(source: str = platform_path("kw", "hot_search")) -> Any:
module = get_source(source, "hot_search")
try:
return ok(await module.hot_search.get_list())
except Exception as err:
raise upstream(str(err)) from err
async def _song_list_handler(source: str, page: int, sort_id: Any, tag_id: Any) -> Any:
module = get_source(source, "song_list")
sort_default = module.song_list.sort_list[0]["id"] if getattr(module.song_list, "sort_list", None) else None
try:
return ok(await module.song_list.get_list(sort_id if sort_id is not None else sort_default, tag_id, page))
except Exception as err:
raise upstream(str(err)) from err
@router.get(
"/song-list/{source}",
summary="获取歌单列表",
tags=["歌单"],
responses=responses_for("SongListResult"),
)
async def song_list_first(
source: str = platform_path("kw", "song_list"),
sortId: str | None = Query(None, description="排序 id(平台相关;省略时用平台默认)"),
tagId: str | None = Query(None, description="标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)"),
) -> Any:
return await _song_list_handler(source, 1, sortId, tagId)
@router.get(
"/song-list/{source}/{page}",
summary="获取歌单列表(指定页)",
tags=["歌单"],
responses=responses_for("SongListResult"),
)
async def song_list_page(
source: str = platform_path("kw", "song_list"),
page: int = Path(..., ge=1, description="页码"),
sortId: str | None = Query(None, description="排序 id"),
tagId: str | None = Query(None, description="标签 id"),
) -> Any:
return await _song_list_handler(source, page, sortId, tagId)
# ---------- 歌手 & 专辑 ----------
@router.get(
"/singer/{source}/{id}",
summary="获取歌手信息",
description="支持 kg/tx/wy(见 /sources 的 singer 能力位)。",
tags=["歌手专辑"],
responses=responses_for("SingerInfoResult"),
)
async def singer_info(
source: str = platform_path("kg", "singer"),
id: str = Path(..., description="平台歌手 id"),
) -> Any:
module = get_source(source, "singer")
try:
return ok(await module.singer.get_info(id))
except Exception as err:
raise upstream(str(err)) from err
@router.get(
"/singer/{source}/{id}/songs",
summary="获取歌手歌曲列表",
description="支持 kg/tx/wy。",
tags=["歌手专辑"],
responses=responses_for("SingerSongListResult"),
)
async def singer_songs_first(
source: str = platform_path("kg", "singer"),
id: str = Path(..., description="平台歌手 id"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _singer_songs(source, id, 1, limit)
@router.get(
"/singer/{source}/{id}/songs/{page}",
summary="获取歌手歌曲列表(指定页)",
description="支持 kg/tx/wy。",
tags=["歌手专辑"],
responses=responses_for("SingerSongListResult"),
)
async def singer_songs_page(
source: str = platform_path("kg", "singer"),
id: str = Path(..., description="平台歌手 id"),
page: int = Path(..., ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _singer_songs(source, id, page, limit)
async def _singer_songs(source: str, singer_id: str, page: int, limit: int) -> Any:
module = get_source(source, "singer")
try:
return ok(await module.singer.get_song_list(singer_id, page, limit))
except Exception as err:
raise upstream(str(err)) from err
@router.get(
"/singer/{source}/{id}/albums",
summary="获取歌手专辑列表",
description="支持 kg/tx/wy。",
tags=["歌手专辑"],
responses=responses_for("SingerAlbumListResult"),
)
async def singer_albums_first(
source: str = platform_path("kg", "singer"),
id: str = Path(..., description="平台歌手 id"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _singer_albums(source, id, 1, limit)
@router.get(
"/singer/{source}/{id}/albums/{page}",
summary="获取歌手专辑列表(指定页)",
description="支持 kg/tx/wy。",
tags=["歌手专辑"],
responses=responses_for("SingerAlbumListResult"),
)
async def singer_albums_page(
source: str = platform_path("kg", "singer"),
id: str = Path(..., description="平台歌手 id"),
page: int = Path(..., ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _singer_albums(source, id, page, limit)
async def _singer_albums(source: str, singer_id: str, page: int, limit: int) -> Any:
module = get_source(source, "singer")
try:
return ok(await module.singer.get_album_list(singer_id, page, limit))
except Exception as err:
raise upstream(str(err)) from err
async def _album_detail(source: str, album_id: str, page: int, limit: int) -> Any:
module = get_source(source, "album")
album = module.album
# 各平台适配器签名不同:kg 为 get_album_detail(id, page, limit),
# kw 为 get_album_list_detail(id, page)——第三参数是 retryNum,不能透传 limit
try:
if hasattr(album, "get_album_detail"):
return ok(await album.get_album_detail(album_id, page, limit))
return ok(await album.get_album_list_detail(album_id, page))
except Exception as err:
raise upstream(str(err)) from err
@router.get(
"/album/{source}/{id}",
summary="获取专辑详情与曲目列表",
description="支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple(见 /sources 的 album 能力位)。"
"kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。",
tags=["歌手专辑"],
responses=responses_for("AlbumDetailResult"),
)
async def album_first(
source: str = platform_path("kg", "album"),
id: str = Path(..., description="平台专辑 id(搜索结果中的 albumId)", examples=["123456"]),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _album_detail(source, id, 1, limit)
# 注意:静态段路由必须先于 `/{id}/{page}` 注册。Starlette 按注册顺序匹配,
# 否则 /album/{source}/search/{keyword} 会被 /album/{source}/{id}/{page} 捕获,
# keyword 落到 page 参数上直接 422。
@router.get(
"/album/{source}/search/{keyword}",
summary="搜索专辑",
description="支持 kg/kw/tx/wy/mg/bd/joox/apple(见 /sources 的 albumSearch 能力位)。",
tags=["歌手专辑"],
responses=responses_for("AlbumDetailResult", not_found=False),
)
async def album_search(
source: str = platform_path("kg", "album.search_album"),
keyword: str = Path(..., description="专辑名关键词", examples=["魔杰座"]),
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
module = get_source(source, "album.search_album")
try:
res = await module.album.search_album(keyword, page, limit)
except Exception as err:
raise upstream(str(err)) from err
if isinstance(res, list):
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
return ok({"source": source, **res})
@router.get(
"/album/{source}/{id}/{page}",
summary="获取专辑详情与曲目列表(指定页)",
tags=["歌手专辑"],
responses=responses_for("AlbumDetailResult"),
)
async def album_page(
source: str = platform_path("kg", "album"),
id: str = Path(..., description="平台专辑 id"),
page: int = Path(..., ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _album_detail(source, id, page, limit)
# ---------- 歌单 ----------
@router.get(
"/playlist/{source}/search/{keyword}",
summary="搜索歌单",
description="支持 kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。"
"部分平台该能力受限(如 bd 歌单搜索接口已下线、bili 需登录),失败时返回 502。",
tags=["歌单"],
responses=responses_for("PlaylistSearchResult"),
)
async def playlist_search(
source: str = platform_path("kw", "playlist.search_playlist"),
keyword: str = Path(..., description="歌单关键词", examples=["华语"]),
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
module = get_source(source, "playlist.search_playlist")
try:
res = await module.playlist.search_playlist(keyword, page, limit)
except Exception as err:
raise upstream(str(err)) from err
if isinstance(res, list):
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
return ok({"source": source, **res})
@router.get(
"/playlist/{source}/categories",
summary="获取歌单分类",
description="支持 kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。",
tags=["歌单"],
responses=responses_for("PlaylistCategoriesResult"),
)
async def playlist_categories(source: str = platform_path("kw", "playlist.get_categories")) -> Any:
module = get_source(source, "playlist.get_categories")
try:
res = await module.playlist.get_categories()
except Exception as err:
raise upstream(str(err)) from err
# 歌单分类返回形状不一({list}/裸数组),统一为 { source, list }
items = res if isinstance(res, list) else (res or {}).get("list") or []
return ok({"source": source, "list": items})
async def _category_playlists(source: str, category_id: str, page: int, limit: int) -> Any:
module = get_source(source, "playlist")
if not hasattr(module.playlist, "get_category_playlists"):
raise not_found(f"平台 {source} 不支持分类歌单")
try:
res = await module.playlist.get_category_playlists(category_id, page, limit)
except Exception as err:
raise upstream(str(err)) from err
if isinstance(res, list):
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
return ok({"source": source, **res})
@router.get(
"/playlist/{source}/categories/{categoryId}",
summary="获取分类下的歌单",
description="categoryId 为 /playlist/:source/categories 返回的分类 id(部分平台 id 即分类名)。",
tags=["歌单"],
responses=responses_for("PlaylistSearchResult"),
)
async def playlist_category_items(
source: str = platform_path("kw", "playlist.get_categories"),
categoryId: str = Path(..., description="分类 id(分类列表返回)", examples=["华语"]),
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _category_playlists(source, categoryId, page, limit)
async def _playlist_detail(source: str, playlist_id: str, page: int, limit: int) -> Any:
module = get_source(source, "playlist")
try:
return ok(await module.playlist.get_playlist_detail(playlist_id, page, limit))
except Exception as err: # noqa: BLE001
# 部分平台详情签名只有 id:退化为取全量后本地切片
try:
songs = await module.playlist.get_playlist_songs(playlist_id)
if not isinstance(songs, list):
raise err
start = (page - 1) * limit
return ok(
{
"source": source,
"info": {"name": playlist_id, "img": "", "desc": "", "author": ""},
"list": songs[start : start + limit],
"page": page,
"limit": limit,
"total": len(songs),
}
)
except Exception: # noqa: BLE001
raise upstream(str(err)) from err
@router.get(
"/playlist/{source}/{id}",
summary="获取歌单详情与曲目列表",
description="id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:<id>:<mid>:<bvid>、"
"joox/bd/mg 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。",
tags=["歌单"],
responses=responses_for("PlaylistDetailResult"),
)
async def playlist_first(
source: str = platform_path("kw", "playlist"),
id: str = Path(..., description="歌单 id(或平台歌单链接)", examples=["123456789"]),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _playlist_detail(source, id, 1, limit)
@router.get(
"/playlist/{source}/{id}/{page}",
summary="获取歌单详情与曲目列表(指定页)",
tags=["歌单"],
responses=responses_for("PlaylistDetailResult"),
)
async def playlist_page(
source: str = platform_path("kw", "playlist"),
id: str = Path(..., description="歌单 id"),
page: int = Path(..., ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
return await _playlist_detail(source, id, page, limit)
# ---------- 链接解析 ----------
async def _parse_handler(kind: str, source: str, link: str) -> Any:
module = get_source(source, "parse")
impl = getattr(module.parse, f"parse_{kind}", None)
if impl is None:
raise not_found(f"平台 {source} 不支持解析该类型链接")
try:
out = await impl(link)
except Exception as err:
raise upstream(str(err)) from err
if kind == "song":
if out and (out.get("list") or out.get("songs")):
songs = out.get("list") or out.get("songs") or []
return ok(
{
"source": source,
"type": "playlist",
"playlist": out.get("info") or out.get("playlist"),
"list": songs,
"total": len(songs),
}
)
return ok({"source": source, "type": "song", "song": out})
songs = (out or {}).get("list") or (out or {}).get("songs") or []
return ok(
{
"source": source,
"type": kind,
"playlist": (out or {}).get("info") or (out or {}).get("playlist") or out,
"list": songs,
"total": len(songs),
}
)
_PARSE_KINDS = {"song": "歌曲", "album": "专辑", "playlist": "歌单"}
def _make_parse_handler(kind: str) -> Callable[..., Any]:
"""闭包固定解析类型 —— 不能让 kind 出现在 handler 签名里,否则 FastAPI 会把它当 query 参数。"""
async def handler(
source: str = platform_path("kw", f"parse.parse_{kind}"),
link: str = Body(..., embed=True, description="平台链接或裸 id"),
) -> Any:
return await _parse_handler(kind, source, str(link).strip())
return handler
def _register_parse_routes() -> None:
for kind, label in _PARSE_KINDS.items():
router.add_api_route(
f"/parse/{kind}/{{source}}",
_make_parse_handler(kind),
methods=["POST"],
summary=f"解析{label}链接",
description="把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;"
"5sing 歌曲/歌单、apple/bili/joox/bd/mg 三类、kw/kg/tx/wy 部分支持。",
tags=["链接解析"],
responses=responses_for("ParseResult"),
name=f"parse_{kind}",
)
_register_parse_routes()
# ---------- 个人歌单 ----------
@router.get(
"/user-playlist/{source}",
summary="获取个人歌单列表",
description="需先登录(见 /login/*)。当前仅 tx/wy 有完整实现(见 /sources 的 userPlaylist 能力位);"
"未登录或平台不支持时返回 404/502。",
tags=["歌单"],
responses=responses_for("UserPlaylistResult"),
)
async def user_playlist(
source: str = platform_path("wy", "user_playlist.get_list"),
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(20, ge=1, le=100, description="每页条数"),
) -> Any:
module = get_source(source, "user_playlist.get_list")
try:
result = await module.user_playlist.get_list(page, limit)
except Exception as err:
raise upstream(str(err)) from err
items = result if isinstance(result, list) else (result or {}).get("list") or []
return ok({"source": source, "list": items, "page": page, "limit": limit})
+77
View File
@@ -0,0 +1,77 @@
"""登录态自动续期 —— 启动后延时刷一轮 + 每 24h 一次(对应原 src/refresh.ts)。
各平台凭据独立处理:未登录跳过,单个平台失败不影响另一个。
"""
from __future__ import annotations
import asyncio
import contextlib
from datetime import datetime
from typing import Any
from app.sdk.auth import kg as kg_auth
from app.sdk.auth import tx as tx_auth
INTERVAL_SECONDS = 24 * 60 * 60
# 启动后延迟触发,避开启动期与冒烟测试的快速起停
STARTUP_DELAY_SECONDS = 10
_running = False
_tasks: list[asyncio.Task[None]] = []
def _log(msg: str) -> None:
print(f"[{datetime.now().isoformat(timespec='milliseconds')}] [登录续期] {msg}")
async def refresh_all() -> dict[str, Any]:
"""手动/定时共用的续期入口,返回各平台结果(skipped / refreshed / error)。"""
global _running
if _running:
return {"skipped": True}
_running = True
try:
result: dict[str, Any] = {}
for name, fn in (("kg", kg_auth.refresh_login), ("tx", tx_auth.refresh_login)):
try:
res = await fn()
result[name] = res
if isinstance(res, dict) and res.get("skipped"):
_log(f"{name}: 未登录,跳过")
else:
_log(f"{name}: 登录态已刷新")
except NotImplementedError:
result[name] = {"skipped": True, "msg": "尚未移植"}
except Exception as err: # noqa: BLE001 - 单平台失败不影响其它平台
result[name] = {"error": str(err)}
_log(f"{name}: {err}")
return result
finally:
_running = False
async def _loop() -> None:
await asyncio.sleep(STARTUP_DELAY_SECONDS)
while True:
await refresh_all()
await asyncio.sleep(INTERVAL_SECONDS)
def start_refresh_scheduler() -> None:
"""注册定时任务(在应用 startup 时调用)。"""
try:
loop = asyncio.get_running_loop()
except RuntimeError: # 无事件循环(如导入期)时忽略
return
task = loop.create_task(_loop())
_tasks.append(task)
_log(f"已启动自动续期(启动后 {STARTUP_DELAY_SECONDS}s 首刷,此后每 {INTERVAL_SECONDS // 3600}h 一次)")
async def stop_refresh_scheduler() -> None:
while _tasks:
task = _tasks.pop()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+51
View File
@@ -0,0 +1,51 @@
"""系统路由 —— 健康检查与平台能力清单。"""
from __future__ import annotations
import time
from typing import Any
from app.http.envelope import ok
from app.sdk import SUPPORTED_SOURCES, support_quality
from app.sdk.platforms._base import capability_map
from fastapi import APIRouter, Request
router = APIRouter(tags=["系统"])
@router.get(
"/health",
summary="健康检查",
description="返回运行时长与已接入平台数,可用于探活与监控。",
)
async def health(request: Request) -> Any:
started_at = getattr(request.app.state, "started_at", None) or time.time()
return ok(
{
"status": "ok",
"uptime": round(time.time() - started_at),
"platforms": len(SUPPORTED_SOURCES),
}
)
@router.get(
"/sources",
summary="获取支持的平台",
description="返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList/"
"singer/album/albumSearch/playlist/playlistSearch/playlistCategories/parse/userPlaylist)。"
"音质为 SDK 官方直连接口的支持范围。",
)
async def sources() -> Any:
platforms: list[dict[str, Any]] = []
for item in SUPPORTED_SOURCES:
module = item["module"]
platforms.append(
{
"id": item["id"],
"name": item["name"],
"qualities": support_quality.get(item["id"], []),
"capabilities": capability_map(module),
}
)
return ok({"platforms": platforms})
+254
View File
@@ -0,0 +1,254 @@
"""SDK 聚合入口 —— 平台注册表、跨平台搜索与精确匹配。
对应原 sdk/index.ts。平台 id 对外为短码(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple),
sdk 目录与导入名使用平台名(bd → qian 目录历史已合并,见下)。
各平台包导出 `MODULE`(PlatformModule 实例),能力位按 snake_case 命名:
music_search / leaderboard / song_list / hot_search / comment / singer /
album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.utils import decode_name
# 通用工具函数(平台模块 `from app.sdk import format_play_time` 的解析目标)
__all__ = [
"SOURCE_MODULES",
"SUPPORTED_SOURCES",
"date_format",
"date_format2",
"decode_name",
"find_music",
"format_play_count",
"format_play_time",
"search_music",
"size_formate",
"sources",
"support_quality",
]
from app.sdk.common.utils import (
date_format,
date_format2,
format_play_count,
format_play_time,
size_formate,
)
_SINGERS_SEPARATOR = re.compile(r"、|&|;|;|/|,|,|\||\]|\[|!|!")
def _load_platforms() -> list[dict[str, Any]]:
"""按显示顺序加载平台包。导入失败即启动失败(与原 TS 静态导入一致)。"""
from app.sdk.platforms import apple, bd, bili, fivesing, joox, kg, kw, lr, me, mg, qt, tx, wy, xm
# no_aggregate:有声/播客类平台不参与聚合搜索与跨平台匹配,仅支持 source 直查
return [
{"id": "kw", "name": "酷我音乐", "module": kw.MODULE},
{"id": "kg", "name": "酷狗音乐", "module": kg.MODULE},
{"id": "tx", "name": "QQ音乐", "module": tx.MODULE},
{"id": "wy", "name": "网易音乐", "module": wy.MODULE},
{"id": "xm", "name": "喜马拉雅", "module": xm.MODULE, "no_aggregate": True},
{"id": "me", "name": "猫耳FM", "module": me.MODULE, "no_aggregate": True},
{"id": "qt", "name": "蜻蜓FM", "module": qt.MODULE, "no_aggregate": True},
{"id": "lr", "name": "懒人听书", "module": lr.MODULE, "no_aggregate": True},
{"id": "mg", "name": "咪咕音乐", "module": mg.MODULE},
{"id": "bd", "name": "千千音乐", "module": bd.MODULE},
{"id": "joox", "name": "JOOX", "module": joox.MODULE},
{"id": "bili", "name": "哔哩哔哩", "module": bili.MODULE},
{"id": "5sing", "name": "5sing", "module": fivesing.MODULE},
# Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
{"id": "apple", "name": "Apple Music", "module": apple.MODULE, "no_aggregate": True},
]
SUPPORTED_SOURCES: list[dict[str, Any]] = _load_platforms()
SOURCE_MODULES: dict[str, Any] = {item["id"]: item["module"] for item in SUPPORTED_SOURCES}
sources: list[dict[str, Any]] = [
{"id": item["id"], "name": item["name"], **({"noAggregate": True} if item.get("no_aggregate") else {})}
for item in SUPPORTED_SOURCES
]
def _quality_table() -> dict[str, list[str]]:
from app.sdk.apis import apple as apple_api
from app.sdk.apis import bd as bd_api
from app.sdk.apis import bili as bili_api
from app.sdk.apis import fivesing as fivesing_api
from app.sdk.apis import joox as joox_api
from app.sdk.apis import kg as kg_api
from app.sdk.apis import kw as kw_api
from app.sdk.apis import lr as lr_api
from app.sdk.apis import me as me_api
from app.sdk.apis import mg as mg_api
from app.sdk.apis import qt as qt_api
from app.sdk.apis import tx as tx_api
from app.sdk.apis import wy as wy_api
from app.sdk.apis import xm as xm_api
return {
"kw": kw_api.SUPPORT_QUALITYS,
"kg": kg_api.SUPPORT_QUALITYS,
"tx": tx_api.SUPPORT_QUALITYS,
"wy": wy_api.SUPPORT_QUALITYS,
"xm": xm_api.SUPPORT_QUALITYS,
"me": me_api.SUPPORT_QUALITYS,
"qt": qt_api.SUPPORT_QUALITYS,
"lr": lr_api.SUPPORT_QUALITYS,
"mg": mg_api.SUPPORT_QUALITYS,
"bd": bd_api.SUPPORT_QUALITYS,
"joox": joox_api.SUPPORT_QUALITYS,
"bili": bili_api.SUPPORT_QUALITYS,
"5sing": fivesing_api.SUPPORT_QUALITYS,
"apple": apple_api.SUPPORT_QUALITYS,
}
support_quality: dict[str, list[str]] = _quality_table()
def _trim_str(value: Any) -> Any:
return value.strip() if isinstance(value, str) else value
def _filter_str(value: Any) -> str:
if isinstance(value, str):
return re.sub(r"""\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||/|\]|\[|!|!""", "", value)
return "" if value is None else str(value)
def _get_interval_seconds(interval: Any) -> int:
if not interval:
return 0
parts = str(interval).split(":")
seconds = 0
unit = 1
while parts:
try:
seconds += int(parts.pop()) * unit
except ValueError:
pass
unit *= 60
return seconds
def _sort_single(singer: Any) -> str:
if not singer:
return ""
text = str(singer)
if _SINGERS_SEPARATOR.search(text):
return "、".join(sorted(_SINGERS_SEPARATOR.split(text)))
return text
async def search_music(
*,
name: str,
singer: str | None = None,
source: str | None = None,
only: str | None = None,
limit: int = 25,
) -> list[dict[str, Any]]:
"""多平台聚合搜索。
name 歌名;singer 歌手;source 指定平台时聚合结果跳过该平台;
only 只搜该平台(与 source 互斥,优先生效);limit 单平台结果条数。
"""
import asyncio
music_name = _trim_str(name)
tasks = []
for item in SUPPORTED_SOURCES:
module = item["module"]
if getattr(module, "music_search", None) is None:
continue
# 有声平台(no_aggregate)仅在显式 only 指定时参与搜索
if item.get("no_aggregate") and item["id"] != only:
continue
if only:
if item["id"] != only:
continue
elif item["id"] == source:
continue
keyword = f"{music_name} {singer or ''}".strip()
tasks.append(_search_one(module, keyword, limit))
results = await asyncio.gather(*tasks)
return [item for item in results if item]
async def _search_one(module: Any, keyword: str, limit: int) -> dict[str, Any] | None:
try:
return await module.music_search.search(keyword, 1, limit)
except Exception: # noqa: BLE001
return None
async def find_music(
*,
name: str,
singer: str | None = None,
album_name: str | None = None,
interval: str | None = None,
source: str | None = None,
only: str | None = None,
) -> list[dict[str, Any]]:
"""跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)。"""
lists = await search_music(name=name, singer=singer, source=source, only=only, limit=25)
f_music_name = _filter_str(name).lower()
f_singer = _filter_str(_sort_single(singer)).lower()
f_album_name = _filter_str(album_name).lower()
f_interval = _get_interval_seconds(interval)
def is_equals_interval(value: int) -> bool:
return abs((f_interval or value) - (value or f_interval)) < 5
candidates: list[dict[str, Any]] = []
for result in lists:
for item in result.get("list") or []:
item["name"] = _trim_str(item.get("name"))
item["singer"] = _trim_str(item.get("singer"))
item["_f_singer"] = _filter_str(_sort_single(item.get("singer")).lower())
item["_f_music_name"] = _filter_str(str(item.get("name") or "").lower())
item["_f_album_name"] = _filter_str(str(item.get("albumName") or "").lower())
item["_f_interval"] = _get_interval_seconds(item.get("interval"))
if not is_equals_interval(item["_f_interval"]):
item["name"] = None
continue
candidates.append(item)
matched_order: list[dict[str, Any]] = []
def take(predicate) -> None:
nonlocal candidates
hit = [item for item in candidates if predicate(item)]
rest = [item for item in candidates if not predicate(item)]
matched_order.extend(hit)
candidates = rest
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name and i.get("interval") == interval)
take(
lambda i: (
i["_f_music_name"] == f_music_name and i["_f_singer"] == f_singer and i["_f_album_name"] == f_album_name
)
)
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name)
take(lambda i: i["_f_music_name"] == f_music_name and i.get("interval") == interval)
take(lambda i: i["_f_singer"] == f_singer and i.get("interval") == interval)
take(lambda i: i.get("interval") == interval)
take(lambda i: i["_f_music_name"] == f_music_name)
take(lambda i: i["_f_singer"] == f_singer)
take(lambda i: i["_f_album_name"] == f_album_name)
for item in [*candidates, *matched_order]:
for key in ("_f_singer", "_f_music_name", "_f_album_name", "_f_interval"):
item.pop(key, None)
matched_order.extend(candidates)
return matched_order
+5
View File
@@ -0,0 +1,5 @@
"""取流接口层(对应原 sdk/apis/*.ts)。
每个模块导出 `SUPPORT_QUALITYS: list[str]` 与 `async def get_music_url(song_info, quality, retry_num=0)`,
返回 `{"type": <实际命中音质>, "url": <播放地址>}`。降级链由各平台自行实现。
"""
+78
View File
@@ -0,0 +1,78 @@
"""Apple Music 取流(对应 sdk/apis/apple.ts,上游 apple.go GetDownloadURL)。
⚠️ Apple 只提供 **preview 试听片段**(audio-ssl.itunes.apple.com,AAC/m4a,约 30-90 秒),
完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密,
本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,不假装是无损完整曲。
歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用)。
统一返回:``{type:'preview', url, ext:'m4a', preview:true, songDuration, isrc}``
(songDuration 是歌曲完整时长,试听片段本身约 30-90 秒);
type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐
/sources 与 /url?quality 枚举,返回的实际档位以 type='preview' 为准。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
SUPPORT_QUALITYS: list[str] = ["128k"]
def _is_audio_url(value: Any) -> bool:
text = str(value if value is not None else "")
return text.startswith(("http://", "https://"))
def preview_from_attributes(attr: dict[str, Any]) -> dict[str, Any]:
"""详情 → preview 结果(内部/测试用)。"""
previews = as_list(attr.get("previews"))
url = str(as_dict(previews[0]).get("url") or "") if previews else ""
if not url:
raise RuntimeError("apple music: no preview URL available (完整曲目受 DRM 保护,SDK 仅提供 30-90 秒试听片段)")
try:
duration_ms = float(attr.get("durationInMillis") or 0)
except (TypeError, ValueError):
duration_ms = 0.0
return {
"type": "preview",
"url": url,
"ext": "m4a",
"preview": True,
# 注意:这是歌曲完整时长(catalog durationInMillis),不是试听片段长度
"songDuration": math.floor(duration_ms / 1000 + 0.5),
"isrc": attr.get("isrc") or "",
}
async def get_preview_url(song_id: str) -> dict[str, Any]:
"""按 song id 拉取 preview(上游 GetDownloadURL → Parse)。"""
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
if not data:
raise RuntimeError(f"apple song not found: {song_id}")
return preview_from_attributes(as_dict(as_dict(data[0]).get("attributes")))
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流:preview 直链优先复用,否则按 song id 拉取详情。"""
del quality, retry_num # 只有 preview 一档,请求档位不影响结果
# 上游 GetDownloadURL:Song.URL 已有直链时直接复用(搜索结果里的 previewUrl / 链接解析结果)
if _is_audio_url(song_info.get("previewUrl")):
return {"type": "preview", "url": str(song_info["previewUrl"]), "ext": "m4a", "preview": True}
if _is_audio_url(song_info.get("url")):
return {"type": "preview", "url": str(song_info["url"]), "ext": "m4a", "preview": True}
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效(需要数字 song id 或 Apple Music 歌曲链接)")
return await get_preview_url(song_id)
+84
View File
@@ -0,0 +1,84 @@
"""千千音乐 取流(对应 sdk/apis/bd.ts)。
通道:`/v1/song/tracklink`(签名 GET)。档位:3000=flac / 320 / 128 / 64(aac)。
实测(2026-10 匿名):
* 免费曲目(搜索 isVip=0)各档位均返回明文直链 `data.path`(audio0x.dmhmusic.com,CDN 签名 URL);
* VIP/版权曲目返回 200 / errno 22000 但 `data` 无 path、无 trail_audio_info,
需登录态会员 cookie 才可能有直链 —— 这类曲目沿降级链走到底后如实报错,不造假数据。
**签名串与实际 query 必须同源**:`sign_params` 一次产出签名与参数,query 由
`URLSearchParams` 等价编码拼好后整串交给 `http_fetch`,不会再被二次序列化。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bd.util import api_ok, error_text, first_non_empty, num_or_zero, track_link
# 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
QUALITY_RATES: dict[str, list[str]] = {
"64k": ["64"],
"128k": ["128", "64"],
"320k": ["320", "128", "64"],
"flac": ["3000", "320", "128", "64"],
"flac24bit": ["3000", "320", "128", "64"],
}
DEFAULT_RATES = ["3000", "320", "128", "64"]
# 千千没有 flac24bit 对外档位(上游只到 3000 = flac)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac"]
def type_of_hit(request_rate: str, data: Any) -> str:
"""命中档位判定:以响应 format/rate/bits 为准(可能低于请求档位)。"""
source = data if isinstance(data, dict) else {}
fmt = str(source.get("format") or "").lower()
rate = num_or_zero(source.get("rate"))
bits = num_or_zero(source.get("bits"))
if fmt == "flac" or rate >= 1000:
return "flac24bit" if bits == 24 else "flac"
if rate >= 320 or request_rate == "320":
return "320k"
if rate >= 128 or request_rate == "128":
return "128k"
return "64k"
async def get_download_url(tsid: str, type: str | None = None) -> dict[str, Any]:
"""逐档请求 tracklink,取首个带 path 的档位。"""
id = str(tsid if tsid is not None else "").strip()
if not id:
raise RuntimeError("千千歌曲 id(TSID)为空")
rates = QUALITY_RATES.get(str(type if type is not None else ""), DEFAULT_RATES)
last_err: Exception | None = None
for rate in rates:
try:
body = await track_link(id, rate)
if not api_ok(body):
last_err = RuntimeError(error_text(body, "千千取流失败"))
continue
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
trail = data.get("trail_audio_info")
url = first_non_empty(data.get("path"), trail.get("path") if isinstance(trail, dict) else None)
if url:
return {"type": type_of_hit(rate, data), "url": url}
last_err = RuntimeError("千千该歌曲无可用直链(VIP/版权曲目需登录态,免费曲目可直接取流)")
except Exception as err: # noqa: BLE001 - 单档网络/上游失败沿降级链继续,与原实现一致
last_err = err
if last_err is not None:
raise last_err
raise RuntimeError("千千未找到可用音频直链")
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
# 上游 GetDownloadURL:Song.URL 已有直链时直接复用(Parse 场景)
if song_info.get("url"):
return {"type": quality or "128k", "url": str(song_info["url"])}
tsid = first_non_empty(song_info.get("songmid"), song_info.get("TSID"))
if not tsid:
raise RuntimeError("千千歌曲 id(TSID)为空")
return await get_download_url(tsid, quality)
+257
View File
@@ -0,0 +1,257 @@
"""Bilibili 取流(对应 sdk/apis/bili.ts,上游 bilibili/bilibili.go 的 fetchAudioURL + account.go 的 IsVipAccount)。
接口: 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 域名)。
"""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urljoin, urlsplit
from app.sdk.common.request import http_fetch, http_probe
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
bili_headers,
encode_uri_component,
fetch_view,
get_cookie,
parse_song_id,
)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
QUALITY_LABEL: dict[int, str] = {
30216: "64k",
30232: "132k",
30280: "192k",
30250: "flac",
30251: "flac24bit",
}
#: CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
PROBE_HOP_TIMEOUT = 8.0
PROBE_TOTAL_BUDGET = 20.0
PROBE_MAX_CANDIDATES = 3
_vip_cache: dict[str, Any] = {"value": False, "at": 0.0}
def reset_vip_cache() -> None:
"""登录态清空/更新后调用,丢弃大会员判断缓存。"""
_vip_cache["value"] = False
_vip_cache["at"] = 0.0
async def is_vip_account(cookie: str) -> bool:
"""上游 IsVipAccount:x/web-interface/nav 的 isLogin + vipStatus + vipType(进程内缓存 5 分钟)。"""
if not cookie:
return False
if _vip_cache["at"] and time.time() - _vip_cache["at"] < 5 * 60:
return bool(_vip_cache["value"])
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav", headers=bili_headers(cookie), timeout=15_000
)
body = resp.body if isinstance(resp.body, dict) else {}
data = as_dict(body.get("data"))
vip = (
body.get("code") == 0
and bool(data.get("isLogin"))
and int(data.get("vipStatus") or 0) == 1
and int(data.get("vipType") or 0) > 0
)
_vip_cache["value"] = vip
_vip_cache["at"] = time.time()
return vip
except Exception: # noqa: BLE001 - 判断失败按非会员处理
return False
def host_rank(url: str) -> int:
"""CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / *.akamaized.net,多地节点)
1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方)"""
try:
host = (urlsplit(str(url or "")).hostname or "").lower()
except ValueError:
return 2
if not host:
return 2
if host.endswith(".akamaized.net"):
return 0
if host.endswith(".bilivideo.com"):
return 0 if host.startswith("upos-") else 1
return 2
def node_urls(node: Any) -> list[str]:
"""节点地址收集:baseUrl + backupUrl(去重前的原始序)。"""
if not isinstance(node, dict):
return []
backups: list[Any] = []
if isinstance(node.get("backupUrl"), list):
backups.extend(node["backupUrl"])
if isinstance(node.get("backup_url"), list):
backups.extend(node["backup_url"])
urls = [node.get("baseUrl"), *backups]
return [str(url) for url in urls if url]
def rank_candidates(urls: list[str]) -> list[str]:
"""候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先)。"""
seen: set[str] = set()
uniq: list[str] = []
for url in urls:
text = str(url or "")
if not text or text in seen:
continue
seen.add(text)
uniq.append(text)
ranked = sorted(enumerate(uniq), key=lambda pair: (host_rank(pair[1]), pair[0]))
return [url for _, url in ranked]
def _pick_audio(data: Any, quality: str) -> dict[str, Any] | None:
data = as_dict(data)
dash = as_dict(data.get("dash"))
flac_info = as_dict(dash.get("flac"))
flac = as_dict(flac_info.get("audio"))
hires = flac if int(flac.get("id") or 0) == 30251 and flac.get("baseUrl") else None
dolby_list = as_list(as_dict(dash.get("dolby")).get("audio"))
dolby = None
for item in dolby_list:
if isinstance(item, dict) and int(item.get("id") or 0) == 30250 and item.get("baseUrl"):
dolby = item
break
audio_list = as_list(dash.get("audio"))
standards = sorted(
[item for item in audio_list if isinstance(item, dict) and item.get("baseUrl")],
key=lambda item: -int(item.get("id") or 0),
)
node: dict[str, Any] | None = None
label = ""
if quality == "64k":
low = next((item for item in standards if int(item.get("id") or 0) == 30216), None)
if low:
node = low
label = QUALITY_LABEL[30216]
# 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if node is None and hires:
node = hires
label = QUALITY_LABEL[30251]
if node is None and dolby:
node = dolby
label = QUALITY_LABEL[30250]
if node is None and standards:
node = standards[0]
label = QUALITY_LABEL.get(int(node.get("id") or 0), f"audio-{int(node.get('id') or 0)}")
if node is not None:
return {"id": int(node.get("id") or 0), "label": label, "urls": rank_candidates(node_urls(node))}
durl = as_list(data.get("durl"))
durl_node = durl[0] if durl and isinstance(durl[0], dict) else None
if durl_node and durl_node.get("url"):
return {"id": 0, "label": "durl", "urls": rank_candidates(node_urls(durl_node))}
return None
async def get_audio_url(bvid: str, cid: str | int, quality: str = "128k") -> dict[str, Any]:
"""播放地址 API(上游 fetchAudioURL 的等价实现,返回 ``{"type", "urls"}``;urls 已分级排序,[0] 为最优)。"""
cookie = await get_cookie()
need_flac = quality in ("flac", "flac24bit")
vip = await is_vip_account(cookie)
fnval = 4048 if (need_flac or vip) else 80
url = (
f"https://api.bilibili.com/x/player/playurl?fnval={fnval}&qn=127"
f"&bvid={encode_uri_component(bvid)}&cid={encode_uri_component(cid)}"
)
resp = await http_fetch(url, headers=bili_headers(cookie), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站取流失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站取流失败({body.get('code')}{f': {message}' if message else ''})")
picked = _pick_audio(body.get("data"), quality)
if not picked or not picked["urls"]:
raise RuntimeError("B站未返回可用音频流")
return {"type": picked["label"], "urls": picked["urls"]}
async def _resolve_url(url: str, deadline: float) -> str:
"""探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开。
HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。命中 2xx 返回该地址;
4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
"""
current = str(url)
for _hop in range(3):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("CDN 探测超时(总预算耗尽)")
timeout_ms = int(min(PROBE_HOP_TIMEOUT, remaining) * 1000)
status, location = await http_probe(
current, headers={**bili_headers(), "Range": "bytes=0-0"}, timeout_ms=timeout_ms
)
if 300 <= status < 400 and location:
try:
current = urljoin(current, location)
continue
except ValueError:
return current
if 200 <= status < 300:
return current
if status >= 400:
raise RuntimeError(f"CDN 返回 HTTP {status}")
return current
return current
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流接口:songmid 约定 ``<bvid>|<cid>``;仅给 bvid 时回源 view 取首P cid。
CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
"""
del retry_num # 契约参数,B 站按候选链自行兜底
ids = parse_song_id(song_info.get("songmid") or song_info.get("id"))
bvid = ids["bvid"]
if not bvid:
raise RuntimeError("bili songmid 需为 <bvid>|<cid> 格式")
real_cid = ids["cid"]
if not real_cid:
view = await fetch_view(bvid)
pages = as_list(view.get("pages"))
first = pages[0] if pages and isinstance(pages[0], dict) else {}
real_cid = str(first.get("cid") if first.get("cid") is not None else "")
if not real_cid:
raise RuntimeError("B站未找到视频 cid(可能已失效)")
info = await get_audio_url(bvid, real_cid, quality)
deadline = time.monotonic() + PROBE_TOTAL_BUDGET
for candidate in info["urls"][:PROBE_MAX_CANDIDATES]:
try:
final_url = await _resolve_url(candidate, deadline)
return {"type": info["type"], "url": final_url}
except Exception: # noqa: BLE001 - 换下一候选
if time.monotonic() >= deadline:
break
# 探测全败(或预算耗尽):回退分级排序后的最优候选
return {"type": info["type"], "url": info["urls"][0]}
+43
View File
@@ -0,0 +1,43 @@
"""5sing 取流(对应 sdk/apis/5sing.ts,上游 fivesing/download.go 的 GetDownloadURL)。
链路: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, ... } }
直链为 CDN 明文地址(wsaudiobssdlbig.kugou.com,https,无需 Referer/防盗链签名;实测 Range 206 可播)。
档位:上游固定 sq(最高)→ hq → lq,返回首个非空(含 _backup);只有显式请求 64k(或 lq)时
才倒序优先 lq,返回实际命中档位。songmid 约定 ``<songId>|<songType>``;
Parse 出来的歌曲带 url 时直接复用(上游 GetDownloadURL: if s.URL != "" return)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_audio_link
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
#: 上游只有 sq/hq/lq 三档,实际命中档位以返回 type 为准(sq 常缺,多命中 hq)
SUPPORT_QUALITYS: list[str] = ["sq", "hq", "lq"]
async def get_download_url(song_info: dict[str, Any], quality: str | None = None) -> dict[str, str]:
"""上游 GetDownloadURL 的等价实现。"""
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
# 上游逻辑:Song.URL 已带直链(Parse 场景)直接复用
if song_info.get("url"):
return {"type": str(song_info.get("type") or quality or "sq"), "url": str(song_info["url"])}
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"] or not ids["songType"]:
raise RuntimeError('invalid id structure: 5sing songmid 需为 "<songId>|<songType>"(songType 为 yc/fc/bz)')
prefer_low = quality in ("64k", "lq")
return await fetch_audio_link(ids["songID"], ids["songType"], prefer_low)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流:返回 ``{type: 'sq'|'hq'|'lq', url}``(type 为实际命中档位,可能低于请求档位)。"""
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
hit = await get_download_url(song_info, quality)
return {"type": hit["type"], "url": hit["url"]}
+65
View File
@@ -0,0 +1,65 @@
"""JOOX 取流(对应 sdk/apis/joox.ts)。
链路:GET cache.api.joox.com/openjoox2/v1/track/<songid>?...&secret=<md5>
→ ``play_url_list`` 首个 http 地址(无则 ``refrain_url``)→ 带 vkey/hdnts 的 CDN 直链。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 实测),
故取流改用官网当前使用的 openjoox2 通道(签名细节见 ``platforms/joox/music_info.py``)。
地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0;登录 cookie 的
wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
实测(游客态):免费曲目拿到 128k MP3 完整流;VIP 曲目只给 preview/refrain 片段
(``node_is_preview: true``、``error_code: 9009002``),与上游行为一致。
音质档位:游客通道实测只有 128k MP3。JOOX 的 has_hq/has_hifi 标志不代表游客可取,
登录态能否拿到更高档位未验证,故 ``SUPPORT_QUALITYS`` 保守地只声明 128k,不虚报。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, joox_headers, normalize_joox_id
SUPPORT_QUALITYS: list[str] = ["128k"]
#: 本平台实际只能返回这一档;返回值里的 type 用它而不是请求的 quality(与 TS 返回 type: '128k' 一致)
_ACTUAL_QUALITY = "128k"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流:openjoox2 详情 → play_url_list / refrain_url。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(song_id, full_song=True)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 取流失败:{msg or f'HTTP {res.status_code}'}")
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = body.get("play_url_list")
play_url = ""
for item in urls if isinstance(urls, list) else []:
candidate = clean(item)
if candidate.startswith("http"):
play_url = candidate
break
if not play_url:
refrain = clean(body.get("refrain_url"))
if refrain.startswith("http"):
play_url = refrain
if not play_url:
raise RuntimeError("无可用播放地址(VIP/版权限制或地区不可用)")
return {"type": _ACTUAL_QUALITY, "url": play_url}
+122
View File
@@ -0,0 +1,122 @@
"""酷狗音乐 取流(对应 sdk/apis/kg.ts)。
通道:`https://gateway.kugou.com/v5/url`(x-router: trackercdn.kugou.com,android 签名)。
登录后自动带 token/userid(见 /login/kg);匿名仅能取部分免费资源,VIP/受限资源返回 status:2。
音质映射:128k→128、320k→320、flac→flac、flac24bit→high。
成功响应播放地址在顶层 url: string[] 与 backupUrl: string[];旧 data:[{url}] 仅作兼容兜底。
album_audio_id 传 NaN 会被网关拒答,必须归零。
注意:TS 原实现在这里 `getDevice()` + `androidSign`,但那两个函数在 `sdk/auth/kg.ts`,
Python 侧 `app/sdk/auth/kg.py` 仍是 stub(不在本次写入范围),因此设备指纹与签名
改为 `app/sdk/platforms/kg/util.py` 的实现 —— 算法逐字一致,指纹同样落在
`auth.json` 的 `_kg_device`,与登录态共用。
"""
from __future__ import annotations
import hashlib
import math
import time
from typing import Any
from app.sdk.auth_store import get_kg_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.util import android_sign, encode_uri_component, get_device
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
SALT_KEY = "57ae12eb6890223e355ccfcb74edf70d"
APPID = 1005
CLIENTVER = 11430
QUALITY_MAP = {"128k": "128", "320k": "320", "flac": "flac", "flac24bit": "high"}
URL_API = "https://gateway.kugou.com/v5/url"
def _num(value: Any) -> int:
"""JS 的 Number() 语义:不可解析或非有限值一律归零(网关拒答 NaN)。"""
try:
number = float(value)
except (TypeError, ValueError):
return 0
return int(number) if math.isfinite(number) else 0
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
types = song_info.get("_types") or {}
quality_info = types.get(quality) if isinstance(types, dict) else None
hash_value = str((quality_info or {}).get("hash") or song_info.get("hash") or "").lower()
if not hash_value:
raise RuntimeError("缺少 hash")
dev = get_device()
auth = get_kg_auth()
userid = _num((auth or {}).get("userid") or 0)
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"album_id": _num(song_info.get("albumId")),
"area_code": 1,
"hash": hash_value,
"ssa_flag": "is_fromtrack",
"version": CLIENTVER,
"page_id": 151369488,
"quality": QUALITY_MAP.get(quality, "128"),
"album_audio_id": _num(song_info.get("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"] = hashlib.md5(f"{hash_value}{SALT_KEY}{APPID}{dev['mid']}{userid}".encode()).hexdigest()
params["signature"] = android_sign(params)
qs = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"{URL_API}?{qs}",
method="get",
headers={
"dfid": dev["dfid"],
"mid": dev["mid"],
"clienttime": str(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,
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
raise RuntimeError(str(body.get("error") or "获取失败(该资源需要酷狗登录)"))
candidates: list[Any] = []
if isinstance(body.get("url"), list):
candidates.extend(body["url"])
if isinstance(body.get("backupUrl"), list):
candidates.extend(body["backupUrl"])
url = next((str(item) for item in candidates if item), "")
if not url and isinstance(body.get("data"), list):
for entry in body["data"]:
if isinstance(entry, dict) and (entry.get("url") or entry.get("fallback")):
url = str(entry.get("url") or entry.get("fallback"))
break
if not url:
raise RuntimeError("获取失败(网关未返回播放地址)")
return {"type": quality, "url": url}
+224
View File
@@ -0,0 +1,224 @@
"""酷我音乐 取流(对应 sdk/apis/kw.ts)。
双通道,按优先级:
1) mobi.s convert_url_with_sign(主通道,匿名可用)
`source=kwplayer_ar_8.5.5.0_apk_keluze.apk` + `user=0` 即可匿名拿到真音频,
但请求档位缺失时会**静默换档**(实测 rid=440613 请求 4000kflac 回的是
bitrate=20501 / format=mflac 的加密流,不可播),因此必须严格校验
「返回 bitrate 精确等于请求档位」且「format 在明文白名单内」,
任一不满足视为未命中、沿降级链换档重试。
2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
签名所需 cookie(`Hm_Iuvt_*`,名字与值均随机下发)必须现场访问首页动态获取;
写死 token 一律返回 `The request is illegal!`(已实测确认)。
匿名该接口恒回 128k MP3,是 mobi 通道网络故障时的兜底。
CDN 返回的 URL query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`),
非标准分隔符播放器不认,统一还原成 `=` 再对外返回。
"""
from __future__ import annotations
import asyncio
import re
import uuid
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kw.util import (
HOME_URL,
MOBI_UA,
UA,
fetch_home_set_cookie,
random_digits,
)
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
# 音质 → mobi.s 请求档位(bitrate 是响应必须精确命中的数值)
QUALITY_TIERS: dict[str, dict[str, Any]] = {
"128k": {"br": "128kmp3", "bitrate": 128},
"320k": {"br": "320kmp3", "bitrate": 320},
"flac": {"br": "2000kflac", "bitrate": 2000},
"flac24bit": {"br": "4000kflac", "bitrate": 4000},
}
# 档位缺失时的降级链(请求档位 → 逐级尝试)
FALLBACK_CHAIN: dict[str, list[str]] = {
"flac24bit": ["flac", "320k", "128k"],
"flac": ["320k", "128k"],
"320k": ["128k"],
"128k": [],
}
# 明文音频格式白名单;mgg/mflac/mmp4/zp 是加密格式,拿到也不能播
PLAIN_FORMATS = frozenset({"mp3", "flac", "ogg", "aac", "m4a"})
MOBI_API = "http://mobi.kuwo.cn/mobi.s"
PLAY_URL_API = "https://www.kuwo.cn/api/v1/www/music/playUrl"
_TOKEN_NAME_RE = re.compile(r"^Hm_Iuvt_\w+$")
_ILLEGAL_RE = re.compile(r"illegal", re.IGNORECASE)
def normalize_url(url: str) -> str:
return url.replace("$", "=")
async def get_url_by_mobi(songmid: str, tier: dict[str, Any]) -> str | None:
"""mobi.s 取流:未命中(无 url / bitrate 不符 / 加密格式 / 网络异常)返回 None,由调用方换档。"""
url = (
f"{MOBI_API}?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk"
f"&type=convert_url_with_sign&br={tier['br']}&rid={songmid}"
)
try:
resp = await http_fetch(
url,
method="get",
headers={"User-Agent": MOBI_UA, "Accept-Language": "zh-CN,zh;q=0.9"},
timeout=15_000,
)
except Exception: # noqa: BLE001 — 网络失败视为本档未命中,继续降级
return None
body = resp.body if isinstance(resp.body, dict) else {}
data = body.get("data") or {}
if resp.status_code != 200 or not isinstance(data, dict) or not data.get("url"):
return None
raw_bitrate = data.get("bitrate")
if not isinstance(raw_bitrate, (int, float, str)):
return None
try:
bitrate = int(float(raw_bitrate))
except (TypeError, ValueError):
return None
if bitrate != tier["bitrate"] or str(data.get("format")) not in PLAIN_FORMATS:
return None
return normalize_url(str(data["url"]))
def kw_secret(message: str, key: str) -> str:
"""酷我 web 端 Secret 头哈希:key 为 cookie 名,message 为 cookie 值。
逐字移植自 web 端 secret.js:先把 key 的每个字符码拼成数字串,
取 5 个固定位置的数字组成乘子,再用随机种子一起做 LCG 式的异或掩码。
"""
digits = "".join(str(ord(ch)) for ch in key)
step = len(digits) // 5
multiplier = int(digits[step] + digits[2 * step] + digits[3 * step] + digits[4 * step] + digits[5 * step])
addend = -(-len(key) // 2) # Math.ceil(e.length / 2)
modulus = 2**31 - 1
if multiplier < 2:
raise RuntimeError("酷我 Secret 哈希失败")
seed = random_digits() % 10**8
digits += str(seed)
while len(digits) > 10:
digits = str(int(digits[:10]) + int(digits[10:]))
state = (multiplier * int(digits) + addend) % modulus
out: list[str] = []
for ch in message:
mask = int(state / modulus * 255) # 对应 Math.floor(n / c * 255)
value = ord(ch) ^ mask
out.append(f"{value:02x}" if value < 16 else format(value, "x"))
state = (multiplier * state + addend) % modulus
return "".join(out) + f"{seed:08x}"
class KwToken:
__slots__ = ("name", "secret", "value")
def __init__(self, name: str, value: str) -> None:
self.name = name
self.value = value
self.secret = kw_secret(value, name)
_state: dict[str, Any] = {"token": None, "lock": None}
def _lock() -> asyncio.Lock:
lock = _state["lock"]
if lock is None:
lock = asyncio.Lock()
_state["lock"] = lock
return lock
async def get_token(retry_num: int = 0) -> KwToken:
"""访问首页解析动态下发的 `Hm_Iuvt_*` cookie,生成 Secret。会话级缓存。"""
cached: KwToken | None = _state["token"]
if cached is not None:
return cached
async with _lock():
cached = _state["token"]
if cached is not None:
return cached
# 重试必须在锁内用循环完成:asyncio.Lock 不可重入,锁内递归会永久死锁。
last_error: Exception | None = None
for _attempt in range(max(0, retry_num), 3):
try:
cookies = await fetch_home_set_cookie()
except Exception as err: # noqa: BLE001 - 网络抖动重试,最终统一抛出
last_error = err
continue
found: list[tuple[str, str]] = []
for raw in cookies:
name, _, rest = raw.partition("=")
name = name.strip()
value = rest.split(";")[0] if rest else ""
if _TOKEN_NAME_RE.match(name) and value:
found.append((name, value))
if not found:
last_error = RuntimeError("响应中没有 Hm_Iuvt_* cookie")
continue
token = KwToken(found[0][0], found[0][1])
_state["token"] = token
return token
detail = f": {last_error}" if last_error else ""
raise RuntimeError(f"获取酷我token失败{detail}") from last_error
async def get_url_by_playurl(songmid: str, token: KwToken, retry_num: int = 0) -> dict[str, Any]:
"""官方 playUrl 接口(需 Secret 签名)。签名/token 失效时刷新后重试一次。"""
url = f"{PLAY_URL_API}?mid={songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId={uuid.uuid4()}"
resp = await http_fetch(
url,
method="get",
headers={
"Cookie": f"{token.name}={token.value}",
"Secret": token.secret,
"Referer": HOME_URL,
"User-Agent": UA,
},
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
msg = str(body.get("msg") or body.get("message") or "")
data = body.get("data") or {}
if resp.status_code != 200 or not body.get("success") or not isinstance(data, dict) or not data.get("url"):
if retry_num == 0 and _ILLEGAL_RE.search(msg):
_state["token"] = None
fresh = await get_token()
return await get_url_by_playurl(songmid, fresh, retry_num + 1)
raise RuntimeError(msg or "获取失败")
# 服务器无视 br 参数,匿名实际只回 128k,如实上报
return {"type": "128k", "url": normalize_url(str(data["url"]))}
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "").strip()
if not songmid:
raise RuntimeError("缺少 songmid")
start = quality if quality in QUALITY_TIERS else "128k"
# 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
for tier_type in [start, *FALLBACK_CHAIN[start]]:
hit = await get_url_by_mobi(songmid, QUALITY_TIERS[tier_type])
if hit:
return {"type": tier_type, "url": hit}
token = await get_token()
return await get_url_by_playurl(songmid, token)
+57
View File
@@ -0,0 +1,57 @@
"""懒人听书 URL 获取(对应 sdk/apis/lr.ts,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 视为第一章。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.auth.lr import get_lr_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
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"
)
PLAY_URL = "https://m.lrts.me/ajax/getPlayPath"
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,无重试语义
songmid = str(song_info.get("songmid") or "")
parts = songmid.split(":")
book_id = parts[0] if parts else ""
section = parts[1] if len(parts) > 1 and parts[1] else "1"
if not book_id or not re.fullmatch(r"\d+", book_id):
raise RuntimeError("懒人 id 需为 <bookId>[:<section>](见 /album/lr 章节列表)")
cookie = get_lr_cookie()
if not cookie:
raise RuntimeError("懒人听书取流需要登录(POST /login/lr 绑定账号密码)")
resp = await http_fetch(
f"{PLAY_URL}?entityId={book_id}&entityType=3&opType=1&sections=[{section}]&type=0",
headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/", "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200:
raise RuntimeError(f"取流失败(HTTP {resp.status_code})")
items = as_list(body.get("list"))
path = as_dict(items[0]).get("path") if items else None
if not path:
if body.get("status") == 2:
raise RuntimeError(str(body.get("msg") or "收费章节未购买(需账号已购/会员)"))
if "登录" in str(body.get("msg") or ""):
raise RuntimeError("登录态已失效,请重新 POST /login/lr")
raise RuntimeError(str(body.get("msg") or "取流失败"))
return {"type": "128k" if quality == "128k" else "64k", "url": str(path)}
+78
View File
@@ -0,0 +1,78 @@
"""猫耳FM URL 获取(对应 sdk/apis/me.ts,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>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.me.music_info import get_sound_info
#: 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
QUALITY_FIELDS: dict[str, list[str]] = {
"128k": ["soundurl_128", "soundurl", "soundurl_64", "soundurl_32"],
"64k": ["soundurl_64", "soundurl", "soundurl_128", "soundurl_32"],
}
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def pick_url(info: dict[str, Any], quality: str) -> dict[str, str]:
fields = QUALITY_FIELDS.get(quality, QUALITY_FIELDS["128k"])
url = ""
for field in fields:
value = info.get(field)
if value and str(value).startswith("http"):
url = str(value)
break
if not url:
raise RuntimeError("无可用音频资源(付费内容需 POST /login/me 绑定 token)")
hit_128 = "-128k" in url or quality == "128k"
return {"type": "128k" if hit_128 else "64k", "url": url}
async def _resolve_drama_first_free(drama_id: str) -> str:
"""剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[])。"""
resp = await http_fetch(
f"https://www.missevan.com/dramaapi/getdrama?drama_id={drama_id}",
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": f"https://www.missevan.com/mdrama/{drama_id}",
},
timeout=15_000,
)
info = as_dict(resp.body).get("info") if resp.status_code == 200 else None
episodes = as_list(as_dict(as_dict(info).get("episodes")).get("episode"))
if not episodes:
raise RuntimeError("剧集列表为空或已下线")
first = next((item for item in episodes if int(as_dict(item).get("pay_type") or 0) == 0), episodes[0])
return str(as_dict(first).get("sound_id"))
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,无重试语义
songmid = str(song_info.get("songmid") or "")
sound_id = songmid
if songmid.startswith("d:"):
if not re.fullmatch(r"\d+", songmid[2:]):
raise RuntimeError("剧 id 无效")
sound_id = await _resolve_drama_first_free(songmid[2:])
elif not re.fullmatch(r"\d+", sound_id):
raise RuntimeError("me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)")
info = await get_sound_info(sound_id)
return pick_url(info, quality)
+390
View File
@@ -0,0 +1,390 @@
"""咪咕音乐 取流(对应 sdk/apis/mg.ts)。
候选链:listen-url 多档位请求 → 按档位优先级产出「内嵌加密 / 明文直链 / 档位路径重写」
三类候选 → 逐条 Range 0-63 探测(校验音频魔数,加密流先解密再校验)→ 返回实际命中的最高档位。
匿名态仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie(接口回「登录后免费试听」)。
加密流:返回 `{"encrypted": True, "fileKey": "..."}`,路由据此改写成 `/stream/mg/<票据>`,
真正的逐字节解密在 `app/http/mg_stream.py`(本模块只负责产出字段与探测阶段的自校验)。
上游对应 music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import quote, unquote, urlencode, urlsplit, urlunsplit
from app.sdk.common.request import http_fetch
from app.sdk.platforms.mg.util import (
ANDROID_CHANNEL,
ANDROID_UA,
ANDROID_VERSION,
TONE_EXT,
TONE_QUALITY,
decrypt_z3d,
first_non_empty,
get_migu_cookie,
is_migu_audio_magic,
migu_format_ext,
normalize_tone,
parse_int,
)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
LISTEN_URL = "https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url"
#: 平台档位 → 咪咕 toneFlag(取流请求用)
QUALITY_TONE: dict[str, str] = {
"64k": "LQ",
"128k": "PQ",
"320k": "HQ",
"flac": "SQ",
"flac24bit": "ZQ24",
"wav": "ZQ32",
}
#: 可重写的 tone 路径(上游 miguTonePaths)
TONE_PATHS: dict[str, str] = {
"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",
}
TONE_PREFERENCE: list[str] = ["ZQ32", "ZQ24", "ZQ", "SQ", "HQ", "Z3D", "I3D", "3D60", "PQ", "LQ"]
#: 档位路径探测顺序(长的在前,避免 `/flac` 抢匹配 `/flac_24bit`)
_TONE_PATH_ORDER = ("ZQ24", "ZQ", "ZQ32", "SQ", "HQ", "LQ", "PQ")
MAX_PROBES = 10
def migu_song_parts(song_info: dict[str, Any]) -> dict[str, str]:
"""songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c`。"""
extra = song_info.get("extra") or {}
extra = extra if isinstance(extra, dict) else {}
content_id = str(extra.get("content_id") or "").strip()
resource_type = str(extra.get("resource_type") or "").strip()
format_type = str(extra.get("format_type") or "").strip()
if content_id and resource_type and format_type:
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
raw_id = str(song_info.get("songmid") or song_info.get("id") or "")
parts = raw_id.split("|")
if len(parts) == 3:
return {"contentId": parts[0].strip(), "resourceType": parts[1].strip(), "formatType": parts[2].strip()}
if not content_id:
content_id = raw_id.strip()
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
async def _fetch_listen_info(
content_id: str, copyright_id: str, song_id: str, album_id: str, resource_type: str, tone_flag: str
) -> dict[str, Any]:
"""listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误。"""
params: list[tuple[str, str]] = [
("netType", "01"),
("resourceType", first_non_empty(resource_type, "2")),
("contentId", content_id.strip()),
("toneFlag", first_non_empty(tone_flag, "PQ")),
]
if copyright_id.strip():
params.append(("copyrightId", copyright_id.strip()))
if song_id.strip():
params.append(("songId", song_id.strip()))
if album_id.strip():
params.append(("albumId", album_id.strip()))
headers: dict[str, str] = {
"User-Agent": ANDROID_UA,
"channel": ANDROID_CHANNEL,
"version": ANDROID_VERSION,
"Referer": "https://music.migu.cn/",
}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(f"{LISTEN_URL}?{urlencode(params)}", headers=headers, timeout=30_000)
if resp.status_code < 200 or resp.status_code >= 300:
raise RuntimeError(f"migu listen endpoint returned status {resp.status_code}")
result = resp.body if isinstance(resp.body, dict) else {}
if result.get("code") and result["code"] != "000000":
raise RuntimeError(f"migu api error: {result.get('info')} (code {result.get('code')})")
return result
def _normalize_download_url(raw_url: Any) -> str:
url = str(raw_url if raw_url is not None else "").strip()
if not url:
return ""
if url.startswith("//"):
url = f"https:{url}"
prefix = "ftp://218.200.160.122:21/"
if url.startswith(prefix):
url = f"https://freetyst.nf.migu.cn/{url[len(prefix) :]}"
return url
def _direct_download_info(raw_url: str, fmt: str, expected_size: int) -> dict[str, Any] | None:
url = _normalize_download_url(raw_url)
if not url:
return None
ext = ""
try:
path = urlsplit(url).path
ext = (path.split(".")[-1] or "").lower()
if not ext or "/" in ext:
ext = ""
except ValueError:
ext = ""
tone = normalize_tone(fmt)
if not ext:
ext = TONE_EXT.get(tone, "")
if not ext:
ext = "mp3"
if not tone:
# 按扩展名回填档位(上游 formatFromMiguExt)
tone = {"flac": "SQ", "wav": "ZQ32", "m4a": "I3D"}.get(ext, "PQ")
return {"url": url, "format": tone, "ext": ext, "encrypted": False, "fileKey": "", "size": expected_size}
def _embedded_download_info(resp: dict[str, Any], target_tone: str, expected_size: int) -> dict[str, Any] | None:
"""z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse。"""
data = resp.get("data") if isinstance(resp, dict) else None
song_item = (data or {}).get("songItem") if isinstance(data, dict) else None
code = (song_item or {}).get("z3dCode") if isinstance(song_item, dict) else None
if not isinstance(code, dict):
return None
def parse_size(value: Any) -> int:
size = parse_int(value)
return size if size > 0 else 0
if target_tone == "Z3D":
url = _normalize_download_url(first_non_empty(code.get("androidUrl"), code.get("url")))
file_key = str(code.get("androidFileKey") or "").strip()
if not url or not file_key:
return None
ext = first_non_empty(str(code.get("androidFileType") or "").removeprefix("."), "wav")
return {
"url": url,
"format": "Z3D",
"ext": ext,
"encrypted": True,
"fileKey": file_key,
"size": parse_size(code.get("androidSize")) or expected_size,
}
if target_tone == "I3D":
url = _normalize_download_url(first_non_empty(code.get("iosUrl"), code.get("url")))
file_key = str(code.get("iosFileKey") or "").strip()
if not url or not file_key:
return None
ext = first_non_empty(str(code.get("iosFileType") or "").removeprefix("."), "m4a")
return {
"url": url,
"format": "I3D",
"ext": ext,
"encrypted": True,
"fileKey": file_key,
"size": parse_size(code.get("iosSize")) or expected_size,
}
if target_tone == "3D60":
url = _normalize_download_url(first_non_empty(code.get("url"), code.get("h5Url")))
if not url:
return None
ext = first_non_empty(str(code.get("fileType") or "").removeprefix("."), "wav")
return {
"url": url,
"format": "3D60",
"ext": ext,
"encrypted": False,
"fileKey": "",
"size": parse_size(code.get("size")) or expected_size,
}
return None
def _detect_tone_path(decoded_path: str) -> str:
for tone in _TONE_PATH_ORDER:
tone_path = TONE_PATHS.get(tone, "")
if tone_path and tone_path in decoded_path:
return tone_path
return ""
def _rewrite_tone_url(raw_url: str, target_tone: str) -> str | None:
"""把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL)。"""
tone = normalize_tone(target_tone)
target_path = TONE_PATHS.get(tone)
target_ext = TONE_EXT.get(tone)
if not target_path or not target_ext or tone in ("Z3D", "I3D", "3D60"):
return None
try:
parts = urlsplit(_normalize_download_url(raw_url))
except ValueError:
return None
decoded_path = unquote(parts.path)
source_path = _detect_tone_path(decoded_path)
if not source_path:
return None
new_path = decoded_path.replace(source_path, target_path)
if new_path == decoded_path:
return None
dot_index = new_path.rfind(".")
if dot_index > new_path.rfind("/"):
new_path = new_path[:dot_index]
new_path += f".{target_ext}"
# JS 的 `parsed.pathname = ...` 会把中文等非 ASCII 重新百分号编码
return urlunsplit((parts.scheme, parts.netloc, quote(new_path, safe="/"), parts.query, parts.fragment))
def _build_candidates(responses: list[dict[str, Any]], expected_size: int) -> list[dict[str, Any]]:
"""候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选。"""
sources: list[dict[str, str]] = []
seen_sources: set[str] = set()
for resp in responses:
data = resp.get("data") if isinstance(resp, dict) else None
candidate_url = _normalize_download_url((data or {}).get("url") if isinstance(data, dict) else None)
if not candidate_url:
continue
actual_format = normalize_tone((data or {}).get("formatType")) or "PQ"
key = f"{actual_format}\u0000{candidate_url}"
if key in seen_sources:
continue
seen_sources.add(key)
sources.append({"url": candidate_url, "format": actual_format})
candidates: list[dict[str, Any]] = []
seen: set[str] = set()
def add(info: dict[str, Any] | None) -> None:
if not info or not info["url"]:
return
key = f"{info['format']}\u0000{info['url']}"
if key in seen:
return
seen.add(key)
candidates.append(info)
for tone in TONE_PREFERENCE:
for resp in responses:
add(_embedded_download_info(resp, tone, expected_size))
for source in sources:
if tone == source["format"]:
add(_direct_download_info(source["url"], source["format"], expected_size))
continue
rewritten = _rewrite_tone_url(source["url"], tone)
if rewritten:
add(_direct_download_info(rewritten, tone, 0))
return candidates
async def _download_info_valid(info: dict[str, Any]) -> bool:
"""探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积。"""
try:
headers: dict[str, str] = {
"User-Agent": ANDROID_UA,
"Referer": "https://music.migu.cn/",
"Range": "bytes=0-63",
}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(info["url"], headers=headers, timeout=10_000)
if resp.status_code < 200 or resp.status_code >= 300:
return False
prefix = resp.raw[:64]
if not prefix:
return False
if info["encrypted"]:
try:
decrypt_z3d(prefix, info["fileKey"])
except Exception: # noqa: BLE001 - 探测失败即视为该候选不可用
return False
elif not is_migu_audio_magic(prefix):
return False
content_range = resp.header("content-range")
slash = content_range.rfind("/")
if slash >= 0:
size = parse_int(content_range[slash + 1 :])
if size > 0:
info["size"] = size
return True
except Exception: # noqa: BLE001 - 任一候选探测异常都只代表该候选不可用
return False
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口:候选链 + 逐档探测;返回实际命中的最高档位。"""
parts = migu_song_parts(song_info)
content_id = parts["contentId"]
if not content_id:
raise RuntimeError("migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)")
resource_type = parts["resourceType"] or "2"
target_tone = (
normalize_tone(QUALITY_TONE.get(quality, ""))
or normalize_tone(parts["formatType"])
or normalize_tone(song_info.get("ext"))
or "PQ"
)
extra = song_info.get("extra") or {}
extra = extra if isinstance(extra, dict) else {}
copyright_id = str(extra.get("copyright_id") or "")
song_id = str(extra.get("song_id") or "")
album_id = str(extra.get("album_id") or song_info.get("albumId") or "")
request_tones: list[str] = []
for tone in ("ZQ32", target_tone, "PQ"):
if tone not in request_tones:
request_tones.append(tone)
responses: list[dict[str, Any]] = []
fetch_err: Exception | None = None
for tone in request_tones:
try:
responses.append(await _fetch_listen_info(content_id, copyright_id, song_id, album_id, resource_type, tone))
except Exception as err: # noqa: BLE001 - 多档位请求,单档失败不影响其它档
if fetch_err is None:
fetch_err = err
if not responses:
raise fetch_err or RuntimeError("migu listen-url 全部失败")
expected_size = parse_int(song_info.get("size"))
candidates = _build_candidates(responses, expected_size)
for probed, candidate in enumerate(candidates):
if probed >= MAX_PROBES:
break
if await _download_info_valid(candidate):
result: dict[str, Any] = {
"type": TONE_QUALITY.get(candidate["format"], quality),
"url": candidate["url"],
}
if candidate["encrypted"]:
result["encrypted"] = True
result["fileKey"] = candidate["fileKey"]
result["ext"] = candidate["ext"] or migu_format_ext(candidate["format"])
result["size"] = candidate["size"]
return result
# 全部候选不可用:优先回传接口文案(如「登录后免费试听」)
dialog = next(
(
str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
for resp in responses
if str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
),
"",
)
if dialog:
raise RuntimeError(f"migu 无可用音频:{dialog}(登录后可解锁更高档位)")
if fetch_err is not None:
raise fetch_err
raise RuntimeError("migu 未返回可访问的音频档位")
+42
View File
@@ -0,0 +1,42 @@
"""蜻蜓FM URL 获取(对应 sdk/apis/qt.ts,点播 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 列表产出)。
"""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Any
SIGN_KEY = b"7l8CZ)SgZgM_bkrw"
AUDIO_HOST = "https://audio.qtfm.cn"
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def build_program_url(channel_id: str, program_id: str) -> str:
path = (
f"/audiostream/redirect/{channel_id}/{program_id}"
f"?access_token=&device_id=MOBILESITE&qingting_id=&t={int(time.time() * 1000)}"
)
sign = hmac.new(SIGN_KEY, path.encode(), hashlib.md5).hexdigest()
return f"{AUDIO_HOST}{path}&sign={sign}"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数;直链由签名即时生成,无需请求上游
songmid = str(song_info.get("songmid") or "")
parts = songmid.split(":")
channel_id = parts[0] if parts else ""
program_id = parts[1] if len(parts) > 1 else ""
if not channel_id or not program_id:
raise RuntimeError("蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)")
return {"type": "128k" if quality == "128k" else "64k", "url": build_program_url(channel_id, program_id)}
+145
View File
@@ -0,0 +1,145 @@
"""QQ音乐取流(移植自 sdk/apis/tx.ts)。
接口:musicu.fcg 的 vkey.GetVkeyServer/CgiGetVkey。
取流方案(与原文一致):
* 一次请求带上整条音质候选链的 filename,命中第一个有 purl 的档位即「最高可用音质」;
* 始终附带 `C400{songmid}.m4a` 保底 —— 匿名账户唯一能拿到的 128k AAC;
* 档位前缀:RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC > F000 无损 FLAC >
M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
* VIP 档 filename 用 media_mid(songmid ≠ media_mid 时用媒体 id),保底档用 songmid;
* 登录 cookie(uin + qm_keyst,见 /login/tx)才可能解锁 320k/无损/Hi-Res,
未登录时高档位 purl 为空,只能落到 C400 或 testfile2g 试听地址。
参数映射:路由的 song_info 只有 {"songmid", "albumId", ...},没有 media_mid。
因此先查 songmid → media_mid(musicu 的 pf_song_detail_svr),查不到就退回用 songmid
(与原文 `songInfo.strMediaMid || songInfo.songmid` 同义)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils import MUSICU_URL
# 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
_CANDIDATES: dict[str, list[tuple[str, str, str]]] = {
"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"),
],
}
SUPPORT_QUALITYS: list[str] = list(_CANDIDATES)
_UA = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
_mid_cache: dict[str, str] = {}
async def _resolve_media_mid(songmid: str) -> str:
"""songmid → media_mid。失败/缺失时返回空串,由调用方退回 songmid。"""
cached = _mid_cache.get(songmid)
if cached is not None:
return cached
media_mid = ""
try:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"module": "music.pf_song_detail_svr",
"method": "get_song_detail_yqq",
"param": {"song_type": 0, "song_mid": songmid},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
track = ((body.get("req") or {}).get("data") or {}).get("track_info") or {}
media_mid = str((track.get("file") or {}).get("media_mid") or "")
except (RuntimeError, OSError, TimeoutError):
# 查不到不阻塞取流:退回 songmid(无名曲目/接口抖动时的降级行为)
return ""
_mid_cache[songmid] = media_mid
return media_mid
def _build_entries(song_info: dict[str, Any], quality: str, media_mid: str) -> list[dict[str, str]]:
songmid = str(song_info.get("songmid") or "")
entries: list[dict[str, str]] = []
for prefix, ext, hit_type in _CANDIDATES.get(quality) or _CANDIDATES["128k"]:
entries.append({"filename": f"{prefix}{media_mid}.{ext}", "type": hit_type})
# 保底:匿名 128k AAC 用 songmid(不是 media_mid)
entries.append({"filename": f"C400{songmid}.m4a", "type": "128k"})
return entries
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。返回 {"type": 实际命中音质, "url": 播放地址}(type 可能低于请求值)。
失败文案与原文一致:`获取失败`。
"""
if retry_num > 2:
raise RuntimeError("获取失败")
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取失败")
media_mid = str(song_info.get("strMediaMid") or "") or await _resolve_media_mid(songmid) or songmid
entries = _build_entries(song_info, quality, media_mid)
auth = get_tx_auth()
uin = str(auth.get("uin") or "0") if auth else "0"
headers = {"User-Agent": _UA, "Referer": "https://y.qq.com/"}
if auth and auth.get("cookie"):
headers["cookie"] = str(auth["cookie"])
data = {
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {
"module": "vkey.GetVkeyServer",
"method": "CgiGetVkey",
"param": {
"guid": "7342827269",
"songmid": [songmid],
"filename": [entry["filename"] for entry in entries],
"songtype": [0],
"uin": uin,
"loginflag": 1,
"platform": "20",
"needNewCode": 1,
},
},
}
resp = await http_fetch(MUSICU_URL, method="post", headers=headers, body=data, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
return await get_music_url(song_info, quality, retry_num + 1)
payload = req.get("data") or {}
sip = (payload.get("sip") or ["http://aqqmusic.tc.qq.com/"])[0]
infos = payload.get("midurlinfo") or []
for index, info in enumerate(infos):
if info.get("purl"):
hit_type = entries[index]["type"] if index < len(entries) else "128k"
return {"type": hit_type, "url": f"{sip}{info['purl']}"}
# 全部受限(VIP 歌匿名)时回退服务器给的 128k m4a 试听文件
if payload.get("testfile2g"):
return {"type": "128k", "url": f"{sip}{payload['testfile2g']}"}
return await get_music_url(song_info, quality, retry_num + 1)
+56
View File
@@ -0,0 +1,56 @@
"""网易云音乐 取流接口(移植自 sdk/apis/wy.ts)。
接口: https://interface3.music.163.com/eapi/song/enhance/player/url/v1(eapi 加密请求,响应为明文 JSON)
音质: 128k → standard, 320k → exhigh, flac → lossless, flac24bit → hires
未登录时不带 cookie 走匿名通道(音质上限由服务端决定),与原实现一致。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_wy_cookie
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.utils import UA_WIN_CHROME
from app.sdk.platforms.wy.utils.crypto import eapi
LEVEL_MAP = {
"128k": "standard",
"320k": "exhigh",
"flac": "lossless",
"flac24bit": "hires",
}
# 官方直连接口支持的音质(sdk/__init__.py 汇总为 support_quality)
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
URL_HOST = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。失败抛 RuntimeError(文案与原文一致:获取失败)。"""
data = {
"ids": f"[{song_info.get('songmid')}]",
"level": LEVEL_MAP.get(quality, "standard"),
"encodeType": "flac",
}
headers = {"User-Agent": UA_WIN_CHROME, "origin": "https://music.163.com"}
cookie = get_wy_cookie()
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
URL_HOST,
method="post",
headers=headers,
form=eapi("/api/song/enhance/player/url/v1", data),
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取失败")
items = body.get("data") or []
item = items[0] if items else None
if not isinstance(item, dict) or not item.get("url"):
raise RuntimeError("获取失败")
return {"type": quality, "url": item["url"]}
+63
View File
@@ -0,0 +1,63 @@
"""喜马拉雅 URL 获取(对应 sdk/apis/xm.ts,PC 桌面端 baseInfo 通道)。
链路:make_xm_sign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList
→ decode_win_url(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' 桶。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.xm.decoder import decode_win_url
from app.sdk.platforms.xm.music_info import get_track_info, play_url_list
#: 优先 M4A(同码率体积更小),128 档优先 MP3_128
QUALITY_PREFERENCE: dict[str, list[str]] = {
"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"],
}
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def _bitrate_of(type_name: str) -> int:
match = re.search(r"(\d+)", str(type_name or ""))
return int(match.group(1)) if match else 0
def _bucket_of(bitrate: int) -> str:
return "128k" if bitrate >= 128 else "64k"
def _resolve_url(raw: str) -> str:
return raw if raw.startswith("http") else decode_win_url(raw)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流:按档位偏好找 playUrlList,未知命名时取码率最高的一条。"""
del retry_num # 契约参数,无重试语义
track_id = str(song_info.get("songmid") or "")
track_info = await get_track_info(track_id)
play_list = play_url_list(track_info)
if not play_list:
raise RuntimeError("无可用播放资源(付费/版权内容需登录)")
preference = QUALITY_PREFERENCE.get(quality, QUALITY_PREFERENCE["128k"])
picked: dict[str, str] | None = None
for want in preference:
hit = next((item for item in play_list if item.get("type") == want and item.get("url")), None)
if hit:
picked = {"type": str(hit["type"]), "url": _resolve_url(str(hit["url"]))}
break
if picked is None:
# 兜底:未知档位命名时取码率最高的一条
candidates = [item for item in play_list if item.get("url")]
if not candidates:
raise RuntimeError("无可用播放资源")
best = sorted(candidates, key=lambda item: _bitrate_of(str(item.get("type") or "")), reverse=True)[0]
picked = {"type": str(best["type"]), "url": _resolve_url(str(best["url"]))}
return {"type": _bucket_of(_bitrate_of(picked["type"])), "url": picked["url"]}
+21
View File
@@ -0,0 +1,21 @@
"""账号登录模块(对应原 sdk/auth/ 与各平台 login/auth 文件)。
每个模块导出一组 async 函数,路由层只依赖这些签名:
wy:qr_key / qr_check / cellphone_login / login_by_cookie / status
kg:login_by_token / qr_key / qr_check / send_captcha / cellphone_login /
password_login / verify_user_info / status / refresh_login
tx:qr_show / qr_check / check_vip / refresh_login
lr:login / status
bili:create_qr_login / check_qr_login / status
约定:
* 成功直接返回 dict(由路由包装成 { code: 0, data })
* 可预期的业务失败抛 ApiError(401/422/502, msg)(路由原样转发状态码)
"""
from __future__ import annotations
__all__ = ["bili", "kg", "lr", "tx", "wy"]
from app.sdk.auth import bili, kg, lr, tx, wy
+223
View File
@@ -0,0 +1,223 @@
"""哔哩哔哩扫码登录(对应 sdk/bili/login.ts,上游 bilibili/login.go + account.go 的 IsVipAccount)。
接口:
* GET passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
* GET passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
凭据落盘到 auth_store 的 ``bili`` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
上游 ``New(cookie)`` 的实例化语义由 ``get_credentials('bili')`` 承担(见 platforms/bili/util.py)。
状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
"""
from __future__ import annotations
import random
import time
from typing import Any
from urllib.parse import parse_qs, urlsplit
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bili.util import REFERER, UA, as_dict
QR_GENERATE_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
QR_POLL_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
#: 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制
_CHINA_IP_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,
]
_CROSS_DOMAIN_KEYS = ("DedeUserID", "DedeUserID__ckMd5", "SESSDATA", "bili_jct", "sid")
def _random_china_ip() -> str:
first = random.choice(_CHINA_IP_FIRST)
return f"{first}.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}"
def _qr_headers() -> dict[str, str]:
ip = _random_china_ip()
return {"User-Agent": UA, "Referer": REFERER, "X-Forwarded-For": ip, "X-Real-IP": ip}
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
def _cookie_map_from_cross_domain(cross_url: str) -> dict[str, str]:
"""crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备)。"""
jar: dict[str, str] = {}
try:
query = parse_qs(urlsplit(str(cross_url or "")).query)
except ValueError:
return jar
for name in _CROSS_DOMAIN_KEYS:
values = query.get(name)
if values:
jar[name] = values[0]
return jar
def _cookie_string(jar: dict[str, str]) -> str:
return "; ".join(f"{key}={value}" for key, value in jar.items())
async def create_qr_login() -> dict[str, Any]:
"""创建扫码登录会话:返回 ``{key, qrurl, qrimg, expiresAt}``(qrimg 可直接展示)。"""
resp = await http_fetch(QR_GENERATE_API, headers=_qr_headers(), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"获取B站登录二维码失败(HTTP {resp.status_code})")
data = as_dict(body.get("data"))
key = str(data.get("qrcode_key") or "").strip()
if body.get("code") != 0 or not key:
message = body.get("message")
raise RuntimeError(f"获取B站登录二维码失败({body.get('code')}{f': {message}' if message else ''})")
url = str(data.get("url") or "")
qrimg = ""
try:
qrimg = to_data_url(url)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {
"key": key,
"qrurl": url,
"qrimg": qrimg,
# 上游 QRLoginSession.ExpiresAt:3 分钟有效期
"expiresAt": int(time.time()) + 180,
}
async def _fetch_profile(cookie: str) -> dict[str, Any]:
"""拉取账号资料(nav;失败不阻断登录)。"""
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav",
headers={"User-Agent": UA, "Referer": REFERER, "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
data = as_dict(body.get("data"))
return {
"mid": data.get("mid"),
"nickname": data.get("uname"),
"vipType": int(data.get("vipType") or 0),
"vipStatus": int(data.get("vipStatus") or 0),
}
except Exception: # noqa: BLE001 - 资料失败不阻断登录
return {"mid": None, "nickname": None, "vipType": 0, "vipStatus": 0}
async def _poll(key: str) -> tuple[int, list[str], Any]:
"""轮询请求(http_fetch 的 set_cookies 保留全部 Set-Cookie,登录凭据靠它取)。"""
url = f"{QR_POLL_API}?qrcode_key={key}"
resp = await http_fetch(url, headers={"User-Agent": UA, "Referer": REFERER}, timeout=15_000)
return resp.status_code, resp.set_cookies, resp.body
async def check_qr_login(key: str) -> dict[str, Any]:
"""轮询扫码状态。
返回 code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)。
"""
qrcode_key = str(key or "").strip()
if not qrcode_key:
raise RuntimeError("B站扫码 key 为空")
status, cookie_lines, body = await _poll(qrcode_key)
if status != 200:
raise ApiError(502, f"轮询B站扫码状态失败(HTTP {status})")
data = as_dict(body.get("data")) if isinstance(body, dict) else {}
code = int(data.get("code") or 0)
if code == 0:
jar = {**_cookie_map_from_cross_domain(data.get("url") or ""), **_cookie_map(cookie_lines)}
cookie = _cookie_string(jar)
if not cookie:
raise RuntimeError("B站登录成功但未获取到 Cookie,请重试")
profile = await _fetch_profile(cookie)
set_credentials(
"bili",
{
"cookie": cookie,
"refreshToken": str(data.get("refresh_token") or ""),
"mid": profile["mid"],
"nickname": profile["nickname"],
"vipType": profile["vipType"],
"vipStatus": profile["vipStatus"],
},
)
from app.sdk.apis.bili import reset_vip_cache
reset_vip_cache()
return {"code": 803, "message": "登录成功", "status": "success", "profile": profile}
status_map: dict[int, tuple[int, str, str]] = {
86038: (800, "二维码已过期", "expired"),
86090: (802, "已扫码,待确认", "scanned"),
86101: (801, "等待扫码", "waiting"),
}
if code in status_map:
mapped, message, state = status_map[code]
return {"code": mapped, "message": message, "status": state}
message = data.get("message") or f"未知状态 {code}"
return {"code": 800, "message": str(message), "status": "failed"}
def status() -> dict[str, Any]:
"""当前登录态(凭据存在性 + 落盘时保存的账号资料)。"""
cred = get_credentials("bili") or {}
if cred.get("cookie"):
return {
"logged": True,
"nickname": cred.get("nickname"),
"mid": cred.get("mid"),
"vipType": cred.get("vipType") or 0,
"updatedAt": cred.get("updatedAt"),
}
return {"logged": False}
+686
View File
@@ -0,0 +1,686 @@
"""酷狗登录(对应 sdk/auth/kg.ts)—— token 绑定 / 扫码 / 短信验证码 / 账号密码。
与 MakcRe/KuGouMusicApi 协议对齐:
* 扫码走 login-user.kugou.com 的 web 签名通道;
* 短信走 android 签名 + AES/RSA;
* 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
sid/edt 行为指纹,指纹由 kg_simulate.py 服务端生成,无需浏览器 WASM);
* token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定。
设备指纹(guid/mid/dfid)与取流共用 platforms/kg/util.py 的实现(落盘 _kg_device)。
"""
from __future__ import annotations
import base64
import json
import time
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.http.api_error import ApiError
from app.sdk.auth.kg_simulate import generate_simulate
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, to_md5
from app.sdk.platforms.kg.util import (
ANDROID_SALT,
WEB_SALT,
android_sign,
encode_uri_component,
get_device,
pkcs7_pad,
pkcs7_unpad,
random_string,
)
APPID = 1005
CLIENTVER = 11430
#: 登录专用域按参考实现固定 clientver 20489(qr/短信接口实测通过的版本)
LOGIN_CLIENTVER = 20489
SRC_APPID = 2919
QR_URL_PREFIX = "https://h5.kugou.com/apps/loginQRCode/html/index.html"
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----"""
#: 固定串派生 token 续期密钥(AES-256-CBC key / iv)
AES_KEY_SEED = "90b8382a1bb4ccdcf063102053fd75b8"
AES_IV = "f063102053fd75b8"
# login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
PWD_T1 = (
"562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c0"
"3a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5ad"
"cdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169"
"995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac"
)
PWD_T2 = (
"31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294a"
"eebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db14"
"00fdf49b7cf7e26be2e5af4da7830cc3be4"
)
PWD_T3 = "MCwwLDAsMCwwLDAsMCwwLDA="
PWD_CLIENTVER = 11440
VERIFY_VTYPES = {
16: "QQ 绑定验证",
18: "微信绑定验证",
22: "活体认证",
23: "滑块/图形验证码",
32: "手机短信验证码",
34: "人脸核身",
}
VERIFY_URL_TYPES = {
"KGCodeGT": "极验",
"KGCodeTX": "腾讯",
"KGCodeKG": "酷狗旋转",
"KGCodeKG2": "酷狗滑块",
"KGCodeSM": "数美",
"KGCodeYD": "易盾",
}
_ANDROID_UA = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi"
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(PUBLIC_KEY.encode())
return _public_key
def _js_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _rsa_pkcs1_hex(data: dict[str, Any]) -> str:
"""RSA PKCS#1 v1.5 加密(register_dev 的 p / 登录的 pk 之外的默认形态)。"""
return _load_public_key().encrypt(_js_json(data).encode(), padding.PKCS1v15()).hex()
def _rsa_no_padding_hex(data: dict[str, Any], *, pad_left: bool) -> str:
"""RSA 裸填充加密(128 字节;verify 左补零,登录/续期右补零,与参考实现一致)。"""
payload = _js_json(data).encode()
if len(payload) > 128:
raise RuntimeError("RSA 负载超长")
filler = b"\x00" * (128 - len(payload))
padded = filler + payload if pad_left else payload + filler
numbers = _load_public_key().public_numbers()
cipher = pow(int.from_bytes(padded, "big"), numbers.e, numbers.n).to_bytes(128, "big")
return cipher.hex()
def _aes_cbc_encrypt_hex(data: str, key: str, iv: str) -> str:
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv.encode()))
encryptor = cipher.encryptor()
return (encryptor.update(pkcs7_pad(data.encode())) + encryptor.finalize()).hex()
def _aes_encrypt_random(data: str | dict[str, Any]) -> dict[str, str]:
"""随机密钥 AES-256-CBC:key=md5(rand16),iv=key[16:32]。"""
temp_key = random_string(16)
key_hex = to_md5(temp_key)
text = data if isinstance(data, str) else _js_json(data)
return {"str": _aes_cbc_encrypt_hex(text, key_hex, key_hex[16:32]), "key": temp_key}
def _aes_decrypt_hex(hex_text: str, temp_key: str) -> Any:
key_hex = to_md5(temp_key)
decryptor = Cipher(algorithms.AES(key_hex.encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(bytes.fromhex(hex_text)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _playlist_aes_encrypt(data: dict[str, Any]) -> dict[str, str]:
key = random_string(6)
key_hex = to_md5(key)
cipher = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode()))
encryptor = cipher.encryptor()
payload = encryptor.update(pkcs7_pad(_js_json(data).encode())) + encryptor.finalize()
return {"key": key, "str": base64.b64encode(payload).decode()}
def _playlist_aes_decrypt(raw_base64: str, key: str) -> Any:
key_hex = to_md5(key)
decryptor = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(base64.b64decode(raw_base64)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _int_or_zero(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _gateway_headers(params: dict[str, Any], router: str | None, extra: dict[str, str] | None) -> dict[str, str]:
headers = {
"dfid": str(params["dfid"]),
"mid": str(params["mid"]),
"clienttime": str(params["clienttime"]),
"kg-rc": "1",
"kg-thash": "5d816a0",
"kg-rec": "1",
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
"User-Agent": _ANDROID_UA,
}
if router:
headers["x-router"] = router
if extra:
headers.update(extra)
return headers
async def gateway_request(
url: str,
*,
params: dict[str, Any] | None = None,
data: Any = None,
router: str | None = None,
host: str = "https://gateway.kugou.com",
headers: dict[str, str] | None = None,
with_headers: bool = False,
) -> Any:
"""网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域)。"""
dev = get_device()
all_params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
}
if params:
all_params.update(params)
signature = android_sign(all_params, _js_json(data) if data is not None else "")
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in all_params.items())
kwargs: dict[str, Any] = {
"method": "POST",
"headers": _gateway_headers(all_params, router, headers),
"timeout": 15_000,
}
if data is not None:
kwargs["body"] = data
resp = await http_fetch(f"{host}{url}?{query}&signature={signature}", **kwargs)
body = as_dict(resp.body)
if with_headers:
return body, resp.headers
return body
def _web_sign(params: dict[str, Any]) -> str:
items = sorted(f"{key}={value}" for key, value in params.items())
return to_md5(f"{WEB_SALT}{''.join(items)}{WEB_SALT}")
async def _login_user_get(url: str, extra_params: dict[str, Any] | None = None) -> dict[str, Any]:
"""login-user.kugou.com GET 请求(web 签名,与取流共用同一设备指纹)。"""
dev = get_device()
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": LOGIN_CLIENTVER,
"clienttime": int(time.time()),
}
if extra_params:
params.update(extra_params)
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://login-user.kugou.com{url}?{query}&signature={_web_sign(params)}",
headers=_gateway_headers(params, None, None),
timeout=15_000,
)
body = as_dict(resp.body)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or "酷狗登录服务请求失败"))
return body
async def verify_token(token: str, userid: str | int) -> dict[str, Any]:
"""验证 token 并返回账号资料(usercenter /v3/get_my_info,p = RSA 裸填充左补零)。"""
unix_sec = int(time.time())
p = _rsa_no_padding_hex({"token": token, "clienttime": unix_sec}, pad_left=True).upper()
body = await gateway_request(
"/v3/get_my_info",
router="usercenter.kugou.com",
params={"plat": 1},
data={"visit_time": unix_sec, "usertype": 1, "p": p, "userid": _int_or_zero(userid)},
)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data:
raise ApiError(401, str(body.get("error") or "token 无效或已过期"))
return {
"userid": data.get("userid") or _int_or_zero(userid),
"nickname": data.get("nickname") or data.get("username") or None,
"vip": bool(data["vip"]) if data.get("vip") else None,
}
async def login_by_token(token: str, userid: str | int) -> dict[str, Any]:
"""绑定并验证 kg token。"""
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
async def _apply_kg_login(token: str, userid: Any) -> dict[str, Any]:
"""保存登录凭据并拉取账号资料(扫码/短信登录共用;资料校验失败不阻断登录)。"""
try:
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
except Exception: # noqa: BLE001 - 资料校验失败不影响登录本身
uid = _int_or_zero(userid)
set_credentials("kg", {"token": token, "userid": uid})
return {"userid": uid, "nickname": None, "vip": None}
# ---- 扫码登录 ----
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码(qrimg 为可直接展示的 data URL)。"""
body = await _login_user_get(
"/v2/qrcode",
{
"appid": 1001,
"type": 1,
"plat": 4,
"qrcode_txt": f"{QR_URL_PREFIX}?appid={APPID}&",
"srcappid": SRC_APPID,
},
)
data = as_dict(body.get("data"))
key = data.get("qrcode")
if not key:
raise RuntimeError("获取二维码 key 失败")
qrurl = f"{QR_URL_PREFIX}?qrcode={key}"
qrimg = str(data.get("qrcode_img") or "")
if not qrimg:
from app.sdk.common.qrcode import to_data_url
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态(酷狗原生 0/1/2/4 → 800/801/802/803)。"""
body = await _login_user_get(
"/v2/get_userinfo_qrcode",
{"plat": 4, "appid": APPID, "srcappid": SRC_APPID, "qrcode": key},
)
data = as_dict(body.get("data"))
qr_status = data.get("status")
if qr_status == 4:
token = data.get("token")
if not token:
raise RuntimeError("登录响应中缺少 token")
return {"code": 803, "message": "登录成功", "profile": await _apply_kg_login(token, data.get("userid"))}
mapping: dict[Any, tuple[int, str]] = {0: (800, "二维码已过期"), 1: (801, "等待扫码"), 2: (802, "已扫码,待确认")}
code, message = mapping.get(qr_status, (qr_status, f"未知状态 {qr_status}"))
return {"code": code, "message": message}
# ---- 手机验证码登录 ----
def _sign_login_key(clienttime_ms: int) -> str:
return to_md5(f"{APPID}{ANDROID_SALT}{LOGIN_CLIENTVER}{clienttime_ms}")
async def send_captcha(mobile: str) -> dict[str, Any]:
"""发送短信验证码(businessid 5 = 登录场景)。"""
body = await gateway_request(
"/v7/send_mobile_code",
router="login.user.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={"businessid": 5, "mobile": str(mobile), "plat": 3},
)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or body.get("msg") or "验证码发送失败"))
return {"sent": True}
async def cellphone_login(mobile: str, code: str) -> dict[str, Any]:
"""手机验证码登录(mobile + code,成功后自动保存凭据并返回账号资料)。"""
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"mobile": str(mobile), "code": str(code)})
body = await gateway_request(
"/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": clienttime_ms,
"mobile": f"{str(mobile)[:2]}*****{str(mobile)[10:11]}",
"key": _sign_login_key(clienttime_ms),
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
"params": enc["str"],
"t3": "MCwwLDAsMCwwLDAsMCwwLDA=",
},
headers={"User-Agent": "Android16-1070-11440-130-0-LOGIN-wifi", "support-calm": "1"},
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
data = as_dict(body.get("data"))
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"]) if data.get("secu_params") else None
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
# ---- token 自动续期 ----
async def refresh_login() -> dict[str, Any]:
"""刷新 kg token(login_by_token),成功后写回凭据存储。"""
cred = get_credentials("kg") or {}
if not cred.get("token") or str(cred.get("userid") or "") in ("", "0"):
return {"skipped": True}
clienttime = int(time.time())
p3 = _aes_cbc_encrypt_hex(
_js_json({"clienttime": clienttime, "token": cred["token"]}), to_md5(AES_KEY_SEED), AES_IV
)
enc = _aes_encrypt_random({})
pk = _rsa_no_padding_hex({"clienttime_ms": clienttime * 1000, "key": enc["key"]}, pad_left=False)
body = await gateway_request(
"/v5/login_by_token",
router="login.user.kugou.com",
params={"clientver": 20349},
data={
"p3": p3,
"params": enc["str"],
"userid": _int_or_zero(cred.get("userid")),
"need_toneinfo": 1,
"clienttime_ms": clienttime * 1000,
"dfid": "-",
"dev": "SDY-AN00",
"plat": 1,
"pk": 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",
},
)
data = as_dict(body.get("data"))
if body.get("error_code") != 0 or not data.get("secu_params"):
error = body.get("error")
raise RuntimeError(f"刷新失败(code {body.get('error_code')}{f': {error}' if error else ''})")
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
if not isinstance(decrypted, dict) or not decrypted.get("token"):
raise RuntimeError("刷新响应解密失败")
set_credentials("kg", {"token": decrypted["token"]})
return {"refreshed": True}
# ---- 设备 dfid 正式注册 ----
async def register_dfid() -> dict[str, Any]:
"""注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍)。
随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
协议:设备画像 → AES-128-CBC(随机 6 位 key) → base64 作 body;
p = RSA-PKCS1({aes:key, uid, token});响应为二进制密文,按 base64 解码后同密钥解密。
"""
dev = get_device()
if dev.get("dfidRegistered"):
return dev
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
device_map = {
"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": "",
}
enc = _playlist_aes_encrypt(device_map)
p = _rsa_pkcs1_hex({"aes": enc["key"], "uid": userid, "token": cred.get("token") or ""})
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"part": 1,
"platid": 1,
"p": p,
}
params["signature"] = android_sign(params, enc["str"])
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://userservice.kugou.com/risk/v2/r_register_dev?{query}",
method="POST",
headers={
"dfid": str(dev["dfid"]),
"mid": str(dev["mid"]),
"clienttime": str(params["clienttime"]),
"User-Agent": _ANDROID_UA,
},
body=enc["str"],
timeout=15_000,
)
raw_b64 = (
base64.b64encode(resp.raw).decode()
if resp.raw
else base64.b64encode(_js_json(resp.body or "").encode()).decode()
)
body = _playlist_aes_decrypt(raw_b64, enc["key"])
body = as_dict(body)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data.get("dfid"):
raise RuntimeError(f"dfid 注册失败(status {body.get('status', '未知')} {body.get('error', '')})".strip())
updated = {**dev, "dfid": str(data["dfid"]), "dfidRegistered": True}
set_credentials("_kg_device", updated)
return updated
# ---- 账号密码登录 + 20028 验证码挑战 ----
async def get_verify_info(eventid: str) -> dict[str, Any]:
"""查询验证事件详情(/verifyservice/v3/get_verify_info)。"""
cred = get_credentials("kg") or {}
body = await gateway_request(
"/verifyservice/v3/get_verify_info",
data={
"eventid": eventid,
"userid": _int_or_zero(cred.get("userid")),
"platid": 2,
"rtype": 1,
"wasm": 1,
"i": "",
"sid": "",
"edt": "",
},
)
if body.get("status") != 1 and body.get("error_code") != 0:
raise RuntimeError(str(body.get("error") or f"查询验证信息失败(code {body.get('error_code', '未知')})"))
data = body.get("data")
return data if isinstance(data, dict) else {}
async def verify_user_info(
eventid: str,
v_type: int,
verifycode: str,
sid: str | None = None,
edt: str | None = None,
) -> dict[str, Any]:
"""提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码)。"""
dev = get_device()
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
fingerprint = {"sid": sid, "edt": edt} if sid and edt else generate_simulate(dev["mid"], userid, dev["dfid"])
data_map: dict[str, Any] = {
"eventid": eventid,
"userid": userid,
"platid": 2,
"v_type": _int_or_zero(v_type) or 23,
"wasm": 1,
"i": "",
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
enc = _aes_encrypt_random({"code": verifycode} if data_map["v_type"] == 32 else {})
if data_map["v_type"] == 32:
# 手机短信验证码:明文 code + 密文 params 均携带
data_map.update({"code": verifycode, "verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
else:
# 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
data_map.update({"verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
data_map["params"] = enc["str"]
body = await gateway_request(
"/v4/verify_user_info",
host="https://verifyservice.kugou.com",
params={"clientver": 11510},
data=data_map,
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"验证提交失败(code {body.get('error_code', '未知')})"))
return {"verified": True}
async def password_login(username: str, password: str, code: str | None = None) -> dict[str, Any]:
"""账号密码登录(/v9/login_by_pwd);命中 20028 风控返回验证码挑战。"""
# 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
try:
await register_dfid()
except Exception: # noqa: BLE001 - 注册失败不阻断登录
pass
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"pwd": str(password), "code": str(code or ""), "clienttime_ms": clienttime_ms})
body, headers = await gateway_request(
"/v9/login_by_pwd",
router="login.user.kugou.com",
params={"clientver": PWD_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"clienttime_ms": clienttime_ms,
"t1": PWD_T1,
"t2": PWD_T2,
"t3": PWD_T3,
"dev": "SDY-AN00",
"username": str(username),
"params": enc["str"],
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
},
headers={"User-Agent": f"Android16-1070-{PWD_CLIENTVER}-130-0-LOGIN-wifi", "support-calm": "1"},
with_headers=True,
)
data = as_dict(body.get("data"))
if body.get("status") == 1 and data.get("secu_params"):
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
if body.get("error_code") == 20028:
eventid = str(headers.get("ssa-code") or body.get("ssaCode") or "")
if not eventid:
raise RuntimeError("触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录")
dev = get_device()
cred = get_credentials("kg") or {}
fingerprint = generate_simulate(dev["mid"], _int_or_zero(cred.get("userid")), dev["dfid"])
try:
info = await get_verify_info(eventid)
except Exception: # noqa: BLE001 - 挑战详情失败时按默认类型返回
info = {}
v_type = _int_or_zero(info.get("v_type")) or 23
url = str(info.get("url") or "")
provider = next((key for key in VERIFY_URL_TYPES if key in url), "")
challenge: dict[str, Any] = {
"eventid": eventid,
"v_type": v_type,
"vTypeLabel": VERIFY_VTYPES.get(v_type, f"未知类型 {v_type}"),
"url": url or None,
"provider": VERIFY_URL_TYPES[provider] if provider else None,
"txappid": info.get("txappid"),
"sessionid": info.get("sessionid"),
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
return challenge
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
async def status() -> dict[str, Any] | None:
"""当前 kg 登录资料(未登录返回 None)。"""
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"userid": cred.get("userid"), "nickname": cred.get("nickname")}
+161
View File
@@ -0,0 +1,161 @@
"""酷狗行为指纹模拟(对应 sdk/auth/kgSimulate.ts)—— 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 的随机值)。
"""
from __future__ import annotations
import base64
import math
import random
import time
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.utils import to_md5
from app.sdk.platforms.kg.util import pkcs7_pad, random_string
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-----"""
SIM_IV = b"kugousecurity123"
def _ri(low: int, high: int) -> int:
"""JS ``Math.floor(Math.random() * (max - min + 1)) + min``。"""
return math.floor(random.random() * (high - low + 1)) + low
def _round(value: float) -> int:
"""JS Math.round(.5 向上取整,不是 Python 的银行家舍入)。"""
return math.floor(value + 0.5)
_sentinel = 0xFFFFFFFF - _ri(0, 20)
def _f3(t: int | str, i: int, x: int, y: int) -> str:
return f"3,{t},{i},{x},{y}"
def _f5(t: int | str, i: int) -> str:
return f"5,{t},{i}"
def _f6(t: int | str, i: int, x: int, y: int) -> str:
return f"6,{t},{i},{x},{y}"
def _bezier_path(sx: float, sy: float, ex: float, ey: float, n: int) -> list[dict[str, float]]:
"""三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛。"""
c1x = sx + (ex - sx) * 0.3 + _ri(-80, 80)
c1y = sy + (ey - sy) * 0.2 + _ri(-60, 60)
c2x = sx + (ex - sx) * 0.7 + _ri(-60, 60)
c2y = sy + (ey - sy) * 0.8 + _ri(-40, 40)
points: list[dict[str, float]] = []
for i in range(n + 1):
t = i / n
u = 1 - t
x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
jitter = max(0.5, 3 - t * 2.5)
points.append(
{
"x": x + (random.random() - 0.5) * jitter,
"y": y + (random.random() - 0.5) * jitter,
}
)
return points
def _generate_edt_data(start_x: int, start_y: int, end_x: int, end_y: int, mouse_points: int) -> str:
entries: list[str] = []
ts = 0
ei = 0
entries.extend([_f5(0, 0), _f5(_sentinel, 0), _f5(0, 0), _f5(_sentinel, 0)])
ts += _ri(5, 20)
entries.extend([_f6(ts, ei, 750, 500), _f6(_sentinel, ei, 750, 500)])
ei += 1
for _ in range(3):
ts += _ri(80, 600)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
path = _bezier_path(start_x, start_y, end_x, end_y, mouse_points)
si = 0
for i, point in enumerate(path):
ts += _ri(8, 50)
rx = _round(point["x"])
ry = _round(point["y"])
entries.extend([_f3(ts, si, rx, ry), _f3(_sentinel, si, rx, ry)])
if i > 0 and i % 12 == 0:
ts += _ri(20, 60)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
si = (si + 1) % 2
ts += _ri(5, 30)
entries.extend(
[
_f3(ts, 1, _round(end_x + _ri(-5, 5)), _round(end_y + _ri(-5, 5))),
_f3(_sentinel, 1, _round(end_x), _round(end_y)),
]
)
return ":".join(entries)
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(SIM_PUBLIC_KEY.encode())
return _public_key
def generate_simulate(mid: str | int, userid: str | int, dfid: str, webgl_hash: str = "") -> dict[str, str]:
"""生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送。"""
global _sentinel
_sentinel = 0xFFFFFFFF - _ri(0, 20)
key = to_md5(random_string(16))[:16]
points = _ri(30, 60)
start_x = _ri(200, 600)
start_y = _ri(200, 500)
end_x = _ri(500, 700)
end_y = _ri(80, 150)
webgl = webgl_hash or to_md5(random_string(32))
edt_data = _generate_edt_data(start_x, start_y, end_x, end_y, points)
plaintext = (
f"mid={mid or 0};userid={userid or 0};dfid={dfid or 0};webgl={webgl};"
f"webdriver=0;ts={int(time.time() * 1000)};data={edt_data}"
)
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(SIM_IV))
encryptor = cipher.encryptor()
edt = base64.b64encode(encryptor.update(pkcs7_pad(plaintext.encode())) + encryptor.finalize()).decode()
sid = base64.b64encode(
_load_public_key().encrypt(
key.encode(),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
)
).decode()
return {"edt": edt, "sid": sid}
+59
View File
@@ -0,0 +1,59 @@
"""懒人听书登录(对应 sdk/lr/auth.ts,web 通道 m.lrts.me/ajax/logon)。
参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
(免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
"""
from __future__ import annotations
import base64
from typing import Any
from urllib.parse import quote
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
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"
)
LOGON_URL = "https://m.lrts.me/ajax/logon"
async def login(account: str, password: str) -> dict[str, Any]:
"""账号密码登录(成功保存会话 Cookie)。"""
form = f"account={quote(str(account), safe='')}&pwd={base64.b64encode(str(password).encode()).decode()}"
resp = await http_fetch(
LOGON_URL,
method="POST",
headers={
"User-Agent": UA_MI,
"Referer": "https://m.lrts.me/",
"Content-Type": "application/x-www-form-urlencoded",
},
body=form,
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not body.get("phone"):
raise ApiError(401, str(body.get("msg") or "登录失败(账号或密码错误)"))
cookie = "; ".join(part for part in (item.split(";")[0].strip() for item in resp.set_cookies) if part)
if not cookie:
raise RuntimeError("登录成功但未返回会话 Cookie")
set_credentials("lr", {"cookie": cookie, "phone": str(body["phone"])})
return {"logged": True, "phone": str(body["phone"])}
def get_lr_cookie() -> str:
"""登录会话 Cookie(未登录返回空串)。"""
return str((get_credentials("lr") or {}).get("cookie") or "")
def status() -> dict[str, Any]:
"""登录状态。"""
cred = get_credentials("lr") or {}
if cred.get("cookie"):
return {"logged": True, "phone": cred.get("phone"), "updatedAt": cred.get("updatedAt")}
return {"logged": False}
+382
View File
@@ -0,0 +1,382 @@
"""QQ 音乐凭据(对应 sdk/auth/tx.ts)—— cookie 解析、扫码登录、VIP 检测与登录态续期。
续期走 musicu.fcg 未签名通道(与 apis/tx.py 一致);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 等完整字段,可走自动续期。
"""
from __future__ import annotations
import base64
import random
import re
import time
from typing import Any
from urllib.parse import urlencode
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)",
"Referer": "https://y.qq.com/",
}
def parse_cookie(cookie: str) -> dict[str, str]:
"""解析 cookie 中的关键字段(uin / qm_keyst 即 musickey / tmeLoginType)。"""
text = str(cookie or "")
uin = re.search(r"uin=(\d+)", text)
musickey = re.search(r"qm_keyst=([^;]+)", text)
login_type = re.search(r"tmeLoginType=(\d+)", text)
return {
"uin": uin.group(1) if uin else "0",
"musickey": musickey.group(1) if musickey else "",
"tmeLoginType": login_type.group(1) if login_type else "",
}
async def _musicu_request(data: dict[str, Any], cred: dict[str, Any] | None) -> dict[str, Any]:
headers = dict(HEADERS)
cookie = str((cred or {}).get("cookie") or "")
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers=headers,
body=data,
timeout=15_000,
)
return resp.body if isinstance(resp.body, dict) else {}
async def check_vip() -> str | None:
"""检测当前账号 VIP 等级(vip_login_base),结果写入凭据存储。
成功返回 'svip' / 'vip' / 'normal';请求失败返回 None(不改动已存值)。
"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return None
uin = parse_cookie(str(cred["cookie"]))["uin"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": {}},
},
cred,
)
req = as_dict(body.get("req"))
if req.get("code") != 0:
return None
data = as_dict(req.get("data"))
identity = as_dict(data.get("identity"))
if identity.get("HugeVip"):
vip_type = "svip"
elif identity.get("LMFlag"):
vip_type = "vip"
else:
vip_type = "normal"
set_credentials("tx", {"vipType": vip_type})
return vip_type
async def refresh_login() -> dict[str, Any]:
"""续期 tx 登录态(music.login.LoginServer),成功后写回新 musickey 并顺带检测 VIP。"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return {"skipped": True}
parsed = parse_cookie(str(cred["cookie"]))
uin, musickey = parsed["uin"], parsed["musickey"]
if not musickey:
raise RuntimeError("cookie 缺少 qm_keyst 字段")
param: dict[str, Any] = {
"musicid": int(uin) if uin.isdigit() else 0,
"musickey": musickey,
"loginMode": 2,
"expired_in": 0,
}
# App 抓包的完整凭据(存在才携带)
if cred.get("openId"):
param["openid"] = cred["openId"]
if cred.get("accessToken"):
param["access_token"] = cred["accessToken"]
if cred.get("refreshToken"):
param["refresh_token"] = cred["refreshToken"]
if cred.get("refreshKey"):
param["refresh_key"] = cred["refreshKey"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "music.login.LoginServer", "method": "Login", "param": param},
},
cred,
)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
if req.get("code") != 0 or not data.get("musickey"):
msg = f": {req['msg']}" if req.get("msg") else ""
raise RuntimeError(f"刷新失败(code {req.get('code')}{msg})")
# musickey 可能含 $ 等字符:用函数替换,避免字符串替换里的分组转义被展开
cookie = re.sub(r"qm_keyst=[^;]*", lambda _match: f"qm_keyst={data['musickey']}", str(cred["cookie"]))
updated: dict[str, Any] = {"cookie": cookie, "uin": str(data.get("musicid") or uin)}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响续期结果
vip_type = None
return {"refreshed": True, **({"vipType": vip_type} if vip_type else {})}
# ---------------- 扫码登录 ----------------
def _int32(value: int) -> int:
"""JS 位运算的 ToInt32 语义。"""
value &= 0xFFFFFFFF
return value - 0x100000000 if value >= 0x80000000 else value
def _hash33(text: str) -> int:
"""ptqrtoken:hash33(qrsig)。"""
hashed = 0
for char in text:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _get_gtk(skey: str) -> int:
"""g_tk:p_skey 的 5381 哈希。"""
hashed = 5381
for char in skey:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _random_guid() -> str:
def repl(match: re.Match[str]) -> str:
value = int(random.random() * 16) | 0
if match.group(0) == "y":
value = (value & 0x3) | 0x8
return format(value, "X")
return re.sub(r"[xy]", repl, "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx")
class _CookieJar:
"""登录链路共享的 cookie 罐:同名后到优先,存「name=value」对。"""
def __init__(self) -> None:
self._jar: dict[str, str] = {}
def merge(self, set_cookies: list[str] | None) -> None:
for item in set_cookies or []:
pair = str(item).split(";")[0]
name = pair.split("=")[0].strip() if "=" in pair else ""
if name:
self._jar[name] = pair
def get(self, name: str) -> str:
pair = self._jar.get(name)
return "=".join(pair.split("=")[1:]) if pair else ""
def entries(self) -> list[tuple[str, str]]:
return list(self._jar.items())
def header(self) -> str:
return "; ".join(self._jar.values())
class _PtloginError(RuntimeError):
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
async def _ptlogin_request(path: str, query: dict[str, str], cookie: str) -> Any:
url = f"https://ssl.ptlogin2.qq.com/{path}?{urlencode(query)}"
kwargs: dict[str, Any] = {"timeout": 15_000}
if cookie:
kwargs["headers"] = {"cookie": cookie}
resp = await http_fetch(url, **kwargs)
if resp.status_code != 200:
raise _PtloginError(resp.status_code, f"ptlogin {path} 失败(HTTP {resp.status_code})")
return resp
async def qr_show() -> dict[str, Any]:
"""创建扫码登录二维码(客户端扫码后轮询 qr_check),qrsig 需原样回传。"""
resp = await http_fetch(
"https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4"
f"&t={random.random()}&daid=383&pt_3rd_aid=100497308"
"&u1=https%3A%2F%2Fgraph.qq.com%2Foauth2.0%2Flogin_jump",
timeout=15_000,
)
qrsig = ""
for item in resp.set_cookies:
match = re.search(r"qrsig=([^;]+)", item)
if match:
qrsig = match.group(1)
break
if not qrsig:
raise RuntimeError("获取二维码失败(响应缺少 qrsig)")
return {"qrimg": f"data:image/png;base64,{base64.b64encode(resp.raw).decode()}", "qrsig": qrsig}
async def qr_check(qrsig: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存)。"""
if not qrsig:
raise ApiError(422, "缺少 qrsig")
jar = _CookieJar()
jar.merge([f"qrsig={qrsig}"])
# 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中
# qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理
try:
poll = await _ptlogin_request(
"ptqrlogin",
{
"u1": "https://graph.qq.com/oauth2.0/login_jump",
"ptqrtoken": str(_hash33(qrsig)),
"ptredirect": "0",
"h": "1",
"t": "1",
"g": "1",
"from_ui": "1",
"ptlang": "2052",
"action": f"0-0-{int(time.time() * 1000)}",
"js_ver": "23111510",
"js_type": "1",
"pt_uistyle": "40",
"aid": "716027609",
"daid": "383",
"pt_3rd_aid": "100497308",
"o1vId": _random_guid(),
},
jar.header(),
)
except _PtloginError as err:
if err.status_code == 403:
return {"code": 800, "message": "二维码已失效"}
raise
jar.merge(poll.set_cookies)
text = poll.body if isinstance(poll.body, str) else ""
match = re.search(r"ptuiCB\('(\d+)','[^']*','([^']*)'", text)
state = match.group(1) if match else ""
if state != "0":
states = {
"65": (800, "二维码已失效"),
"66": (801, "等待扫码"),
"67": (802, "已扫码,待确认"),
"68": (802, "授权中"),
}
code, message = states.get(state, (801, "等待扫码"))
return {"code": code, "message": message}
# 2. check_sig 换 p_skey(g_tk 需要)
check_sig_url = match.group(2) if match else ""
if not check_sig_url.startswith("http"):
raise RuntimeError("登录检查失败(未返回 check_sig 地址)")
check_sig = await http_fetch(check_sig_url, headers={"cookie": jar.header()}, timeout=15_000)
jar.merge(check_sig.set_cookies)
p_skey = jar.get("p_skey")
if not p_skey:
raise RuntimeError("登录检查失败(响应缺少 p_skey)")
# 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台)
authorize = await http_fetch(
"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": str(_get_gtk(p_skey)),
"auth_time": str(int(time.time() * 1000)),
"ui": _random_guid(),
},
timeout=15_000,
)
jar.merge(authorize.set_cookies)
location = authorize.headers.get("location", "")
code_match = re.search(r"[?&]code=([^&]+)", location)
if not code_match:
raise RuntimeError("OAuth 授权失败(未取得 code)")
code = code_match.group(1)
# 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段)
login = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers={**HEADERS, "cookie": jar.header()},
body={
"comm": {"g_tk": _get_gtk(p_skey), "platform": "yqq", "ct": 24, "cv": 0},
"req": {"module": "QQConnectLogin.LoginServer", "method": "QQLogin", "param": {"code": code}},
},
timeout=15_000,
)
jar.merge(login.set_cookies)
body = as_dict(login.body)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
musickey = str(data.get("musickey") or jar.get("qm_keyst") or "")
if not musickey:
raise RuntimeError("登录失败(未取得 musickey)")
# ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉
uin = re.sub(r"^o0*", "", str(data.get("musicid") or jar.get("uin") or "")) or "0"
# 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声)
pairs = [f"uin={uin}", f"qm_keyst={musickey}"]
if data.get("encryptUin"):
pairs.append(f"euin={data['encryptUin']}")
skip = {"qrsig", "ptui_loginuin", "uin", "qm_keyst", "euin"}
for name, pair in jar.entries():
if name not in skip:
pairs.append(pair)
updated: dict[str, Any] = {"cookie": "; ".join(pairs), "uin": uin}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_token"):
updated["refreshToken"] = str(data["refresh_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响登录结果
vip_type = None
return {"code": 803, "message": "登录成功", "uin": uin, **({"vipType": vip_type} if vip_type else {})}
+169
View File
@@ -0,0 +1,169 @@
"""网易云登录(对应 sdk/auth/wy.ts)—— 扫码 / 手机号 / cookie 绑定,weapi 加密请求。
参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)。
二维码接口只提供明文 api 形态(weapi 形态已 404);其余登录接口走 weapi。
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
from app.http.api_error import ApiError
from app.sdk.auth_store import get_wy_cookie, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
from app.sdk.platforms.wy.utils.crypto import weapi
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
async def _request_weapi(path: str, data: Any, cookie: str | None = None) -> tuple[Any, list[str]]:
ck = get_wy_cookie() if cookie is None else cookie
headers = {
"User-Agent": UA,
"origin": "https://music.163.com",
"Referer": "https://music.163.com/",
}
if ck:
headers["cookie"] = ck
resp = await http_fetch(
f"https://music.163.com/weapi{path}",
method="POST",
headers=headers,
form=weapi(data),
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
async def _request_api(path: str, data: Any) -> tuple[Any, list[str]]:
"""明文 api 请求(二维码接口只提供该形态)。"""
resp = await http_fetch(
f"https://music.163.com{path}",
method="POST",
headers={"User-Agent": UA, "origin": "https://music.163.com", "Referer": "https://music.163.com/"},
form=data,
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
"""从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U。"""
joined = "; ".join(part for part in (item.split(";")[0].strip() for item in set_cookie) if part)
match = re.search(r"MUSIC_U=([^;]+)", joined)
return joined, match.group(1) if match else ""
async def _fetch_profile(cookie: str | None = None) -> dict[str, Any]:
"""登录成功后拉取账号资料(cookie 显式传入新登录会话,避免首次登录读到旧 cookie)。"""
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
body = as_dict(body)
account = as_dict(body.get("account"))
profile = as_dict(body.get("profile"))
return {
"userid": account.get("id"),
"nickname": profile.get("nickname"),
"avatar": profile.get("avatarUrl"),
"vipType": profile.get("vipType"),
}
async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
"""应用登录 cookie(成功后调用)。"""
cookie, music_u = _extract_login_cookie(set_cookie)
if not music_u:
raise RuntimeError("登录响应中缺少 MUSIC_U")
try:
profile = await _fetch_profile(cookie)
except Exception: # noqa: BLE001 - 资料拉取失败不影响已成功的登录,返回空资料
profile = {"userid": None, "nickname": None, "avatar": None, "vipType": None}
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
return profile
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码内容。"""
body, _ = await _request_api("/api/login/qrcode/unikey", {"type": 3})
body = as_dict(body)
if body.get("code") != 200 or not body.get("unikey"):
raise RuntimeError("获取二维码 key 失败")
key = str(body["unikey"])
qrurl = f"https://music.163.com/login?codekey={key}"
qrimg = ""
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)。"""
body, set_cookie = await _request_api("/api/login/qrcode/client/login", {"key": key, "type": 3})
body = as_dict(body)
code = body.get("code")
if code == 803:
profile = await _apply_login(set_cookie)
return {"code": 803, "message": "登录成功", "profile": profile}
messages: dict[Any, str] = {800: "二维码已过期", 801: "等待扫码", 802: "已扫码,待确认"}
message = messages.get(code) or body.get("message") or "未知状态"
return {"code": code, "message": message}
async def cellphone_login(
phone: str, password: str | None = None, captcha: str | None = None, countrycode: str = "86"
) -> dict[str, Any]:
"""手机号登录(密码明文传入,本地 MD5 后提交;或直接传验证码 captcha)。"""
data: dict[str, Any] = {
"type": "1",
"https": "true",
"phone": phone,
"countrycode": countrycode,
"remember": "true",
}
if captcha:
data["captcha"] = captcha
else:
data["password"] = hashlib.md5(str(password or "").encode()).hexdigest()
body, set_cookie = await _request_weapi("/api/w/login/cellphone", data)
body = as_dict(body)
if body.get("code") != 200:
raise ApiError(401, str(body.get("message") or body.get("msg") or "登录失败"))
return await _apply_login(set_cookie)
async def login_by_cookie(cookie_input: str) -> dict[str, Any]:
"""手动绑定 cookie(须含 MUSIC_U);绑定前用 account/get 实测校验。"""
cookie = str(cookie_input or "").strip()
match = re.search(r"MUSIC_U=([^;\s]+)", cookie)
if not match:
raise ApiError(422, "cookie 需包含 MUSIC_U 字段")
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
body = as_dict(body)
account = as_dict(body.get("account"))
if not account.get("id"):
raise ApiError(401, "cookie 无效或已过期(MUSIC_U 校验未通过)")
profile_body = as_dict(body.get("profile"))
profile = {
"userid": account.get("id"),
"nickname": profile_body.get("nickname"),
"avatar": profile_body.get("avatarUrl"),
"vipType": profile_body.get("vipType"),
}
set_credentials("wy", {"cookie": cookie, "musicU": match.group(1), **profile})
return profile
async def status() -> dict[str, Any] | None:
"""当前账号资料(未登录返回 None)。"""
if not get_wy_cookie():
return None
return await _fetch_profile()
+152
View File
@@ -0,0 +1,152 @@
"""登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)。
结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + 原子替换」,
避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
"""
from __future__ import annotations
import json
import os
import threading
import time
from datetime import UTC
from pathlib import Path
from typing import Any
from app.config import data_dir
_lock = threading.Lock()
_cache: dict[str, Any] = {}
_loaded = False
_cache_path: str | None = None
_cache_mtime: int | None = None
def _data_file() -> Path:
return data_dir() / "auth.json"
def _mtime(path: Path) -> int | None:
try:
return path.stat().st_mtime_ns
except OSError:
return None
def _backup_corrupt(path: Path, err: Exception) -> None:
"""损坏文件先备份再重置,避免后续 set_credentials 用空表覆盖可抢救的数据。"""
backup = path.with_name(f"{path.name}.corrupt-{int(time.time() * 1000)}")
try:
path.rename(backup)
print(f"[auth/store] {path} 解析失败,已备份为 {backup},本次以空凭据启动: {err}")
except OSError:
print(f"[auth/store] {path} 解析失败且备份未成功,本次以空凭据启动: {err}")
def _load() -> dict[str, Any]:
"""读取凭据缓存;文件被外部修改(mtime 变化)时自动重载。"""
global _cache, _loaded, _cache_path, _cache_mtime
path = _data_file()
mtime = _mtime(path)
if _loaded and _cache_path == str(path) and _cache_mtime == mtime:
return _cache
try:
if mtime is None:
_cache = {}
else:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("auth.json 顶层必须是 JSON 对象")
_cache = data
except (ValueError, OSError) as err:
_backup_corrupt(path, err)
_cache = {}
_loaded = True
_cache_path = str(path)
_cache_mtime = _mtime(path)
return _cache
def _save(data: dict[str, Any]) -> None:
global _cache, _loaded, _cache_path, _cache_mtime
path = _data_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=2))
os.replace(tmp, path)
except OSError:
try:
tmp.unlink()
except OSError:
pass
raise
_cache = data
_loaded = True
_cache_path = str(path)
_cache_mtime = _mtime(path)
def get_credentials(platform: str) -> dict[str, Any] | None:
with _lock:
return _load().get(platform)
def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
with _lock:
data = dict(_load())
merged = {**data.get(platform, {}), **credentials, "updatedAt": _iso_now()}
data[platform] = merged
_save(data)
def clear_credentials(platform: str) -> None:
with _lock:
data = dict(_load())
data.pop(platform, None)
_save(data)
def _iso_now() -> str:
from datetime import datetime
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# ---- 各平台便捷读法(未登录返回空值,与原文一致) ----
def get_wy_cookie() -> str:
cred = get_credentials("wy") or {}
return str(cred.get("cookie") or "")
def get_kg_auth() -> dict[str, Any] | None:
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"token": cred["token"], "userid": cred.get("userid") or 0}
def get_tx_auth() -> dict[str, Any] | None:
import re
cred = get_credentials("tx") or {}
cookie = str(cred.get("cookie") or "")
if not cookie:
return None
match = re.search(r"uin=(\d+)", cookie)
return {"cookie": cookie, "uin": match.group(1) if match else "0"}
def get_cookie(platform: str) -> str:
"""通用 cookie 读取(xm/me/lr/mg/bd/joox/apple/5sing 等)。"""
cred = get_credentials(platform) or {}
return str(cred.get("cookie") or "").strip()
+1
View File
@@ -0,0 +1 @@
"""共享基础设施(请求层、工具函数)。"""
+36
View File
@@ -0,0 +1,36 @@
"""二维码生成 —— 把登录 URL 渲染成 data URL,供 /login/* 的 qrimg 字段直接展示。
对应原 TS 里 `import qrcode from 'qrcode'` 的 `toDataURL` 用法(bili 扫码登录)。
用 Pillow 后端输出 PNG,再 base64 成 data URL,前端 <img src> 可直接用。
"""
from __future__ import annotations
import base64
import io
import qrcode as _qrcode
from qrcode.constants import ERROR_CORRECT_M
def to_data_url(text: str, *, box_size: int = 6, border: int = 2) -> str:
"""生成 `data:image/png;base64,...`。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def to_png_bytes(text: str, *, box_size: int = 6, border: int = 2) -> bytes:
"""生成 PNG 原始字节(需要直接返回图片响应时用)。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
return buffer.getvalue()
+224
View File
@@ -0,0 +1,224 @@
"""网络请求层。
对应原 sdk/common/request.ts,但把 Node 的 `{ promise, cancelHttp }` 句柄收敛为
原生 `async def` —— 取消语义由调用方用 `asyncio.wait_for` / task 取消表达。
保持与原文一致的行为细节:
* 不跟随重定向(Node http 默认行为;需要跟随的调用方自行处理 Location)
* 不保留 cookie(每次请求独立;各平台自己管理 Cookie 头,签名依赖这一点)
* body:dict/list 自动 JSON 序列化,str/bytes 原样发送;form 走 urlencoded
* 未显式给 content-type 时按 body 形式补 application/json / x-www-form-urlencoded
* 响应体:无论 content-type 都先尝试 JSON 解析,失败返回文本
* 64MB 响应上限
* 错误文案与原文一致(超时 / 无法连接到服务器 / 请求失败)
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import aiohttp
MAX_BUFFER_SIZE = 64 * 1024 * 1024
DEFAULT_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"
),
}
MSG_CANCEL = "请求已取消"
MSG_FAIL = "请求失败"
MSG_TIMEOUT = "请求超时"
MSG_NOT_CONNECT = "无法连接到服务器"
@dataclass
class HttpResponse:
status_code: int
headers: dict[str, str] = field(default_factory=dict)
body: Any = None
raw: bytes = b""
#: 原始多值 Set-Cookie(headers 折叠同名头后只留最后一个,登录链路必须按数组读)
set_cookies: list[str] = field(default_factory=list)
@property
def statusCode(self) -> int: # 兼容原文命名
return self.status_code
def header(self, name: str, default: str = "") -> str:
return self.headers.get(name.lower(), default)
def json(self) -> Any:
"""把 body 当 JSON 取;body 已是结构时原样返回。"""
return self.body
_session: aiohttp.ClientSession | None = None
_session_loop: asyncio.AbstractEventLoop | None = None
def _get_session() -> aiohttp.ClientSession:
"""进程级连接池。禁用 cookie jar 与自动重定向,语义对齐 Node http。
trust_env=False:Node 的 http/https 不读系统代理,而 aiohttp 在 Windows 上会通过
注册表拿到「Internet 选项」里的代理(本机是 127.0.0.1:7890)。走该代理会让部分域名
(如 xm 的 hdaa.shuzilm.cn)连不上,且所有请求的出口 IP/风控画像与 TS 版不一致,
因此这里显式直连,与 Node 行为保持一致。
"""
global _session, _session_loop
loop = asyncio.get_running_loop()
if _session is None or _session.closed or _session_loop is not loop:
_session = aiohttp.ClientSession(
cookie_jar=aiohttp.DummyCookieJar(),
auto_decompress=True,
trust_env=False,
connector=aiohttp.TCPConnector(limit=200, ttl_dns_cache=300),
)
_session_loop = loop
return _session
async def close_session() -> None:
global _session
if _session is not None and not _session.closed:
await _session.close()
_session = None
def _parse_body(raw: bytes) -> Any:
if not raw:
return None
text = raw.decode("utf-8", errors="replace")
try:
return json.loads(text)
except (ValueError, TypeError):
return text
def _build_body(options: Mapping[str, Any]) -> tuple[bytes | None, bool]:
form = options.get("form")
if form:
from urllib.parse import urlencode
pairs: list[tuple[str, str]] = []
for key, value in form.items():
if value is None:
continue
# 对象值对齐 URLSearchParams 的 JSON.stringify(紧凑、无空格)
pairs.append(
(
key,
json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, (dict, list))
else str(value),
)
)
return urlencode(pairs).encode(), True
body = options.get("body")
if body is None:
return None, False
if isinstance(body, bytes):
return body, False
if isinstance(body, str):
return body.encode(), False
# 必须与 JS 的 JSON.stringify 逐字节一致(紧凑分隔符):
# 部分平台(kg 网关)的 signature 就是按紧凑 JSON 计算的,空格会导致验签失败。
return json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode(), False
def _flatten_headers(headers: Mapping[str, Any]) -> dict[str, str]:
return {str(k): str(v) for k, v in headers.items()}
async def http_fetch(url: str, **options: Any) -> HttpResponse:
"""发起 HTTP 请求并返回完整响应。
options: method / headers / body / form / timeout(毫秒) / lookup / family
"""
method = str(options.get("method") or "GET").upper()
payload, is_form = _build_body(options)
headers = {**DEFAULT_HEADERS, **_flatten_headers(options.get("headers") or {})}
lowered = {k.lower() for k in headers}
if payload is not None:
if "content-type" not in lowered:
headers["content-type"] = "application/x-www-form-urlencoded" if is_form else "application/json"
if "content-length" not in lowered:
headers["content-length"] = str(len(payload))
timeout_ms = options.get("timeout")
timeout = aiohttp.ClientTimeout(total=(timeout_ms / 1000) if timeout_ms else 60)
session = _get_session()
try:
async with session.request(
method,
url,
headers=headers,
data=payload,
timeout=timeout,
allow_redirects=False,
) as resp:
chunks: list[bytes] = []
size = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
size += len(chunk)
if size > MAX_BUFFER_SIZE:
raise RuntimeError("Response too large")
chunks.append(chunk)
raw = b"".join(chunks)
return HttpResponse(
status_code=resp.status,
headers={k.lower(): v for k, v in resp.headers.items()},
body=_parse_body(raw),
raw=raw,
set_cookies=list(resp.headers.getall("set-cookie", [])),
)
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorDNSError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientConnectionError as err:
if isinstance(err, aiohttp.ServerDisconnectedError):
raise ConnectionError(MSG_FAIL) from err
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_probe(url: str, *, headers: Mapping[str, Any] | None = None, timeout_ms: int = 8000) -> tuple[int, str]:
"""只读响应头的探测请求:返回 ``(status, location)``,**不消费 body**。
取流 CDN 探测专用(bili):CDN 忽略 Range 时会尝试把整段音频推下来,
等 http_fetch 读完 body 就等于把文件下载完(点歌卡死)。拿到响应头即退出上下文,
aiohttp 随即中止并关闭连接。不跟随重定向,调用方用 location 自行跟跳(对应 Node 的 manual)。
"""
timeout_obj = aiohttp.ClientTimeout(total=timeout_ms / 1000)
session = _get_session()
try:
async with session.request(
"GET",
url,
headers={**DEFAULT_HEADERS, **_flatten_headers(headers or {})},
timeout=timeout_obj,
allow_redirects=False,
) as resp:
return resp.status, resp.headers.get("location", "")
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_get_json(url: str, **options: Any) -> Any:
"""GET 并返回解析后的 body。"""
options.setdefault("method", "get")
resp = await http_fetch(url, **options)
return resp.body
+158
View File
@@ -0,0 +1,158 @@
"""通用工具函数(对应原 sdk/common/utils.ts 与 sdk/utils.ts)。"""
from __future__ import annotations
import hashlib
import math
import re
import time
from collections.abc import Iterable, Mapping
from datetime import datetime
from typing import Any
def to_md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def as_dict(value: Any) -> dict[str, Any]:
"""上游 ``x?.field ?? {}`` 的安全取法:非 dict 一律当空 dict。"""
return value if isinstance(value, dict) else {}
def as_list(value: Any) -> list[Any]:
"""上游 ``x ?? []`` 的安全取法:非 list 一律当空列表。"""
return value if isinstance(value, list) else []
def size_formate(size: float | int | None) -> str:
"""将字节数格式化为可读字符串。"""
if not size:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
number = math.floor(math.log(size) / math.log(1024))
number = min(number, len(units) - 1)
return f"{size / math.pow(1024, number):.2f} {units[number]}"
def format_play_time(seconds: float | int | str | None) -> str:
"""秒数 → mm:ss;0 返回 --/--。"""
try:
value = float(seconds) # type: ignore[arg-type]
except (TypeError, ValueError):
return "--/--"
if math.isnan(value) or value < 0:
value = 0
minutes = int(value // 60)
secs = int(value % 60)
if minutes == 0 and secs == 0:
return "--/--"
return f"{minutes:02d}:{secs:02d}"
def date_format(value: Any, fmt: str = "Y-M-D h:m:s") -> str:
"""时间对象/时间戳/字符串 → 日期字符串(Y/M/D/h/m/s 占位)。"""
if not value:
return ""
if isinstance(value, datetime):
date = value
elif isinstance(value, (int, float)):
date = datetime.fromtimestamp(value / 1000 if value > 1e11 else value)
elif isinstance(value, str):
raw = value
if "T" not in raw:
raw = raw.split(".")[0].replace("-", "/")
parsed: datetime | None = None
for pattern in ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
try:
parsed = datetime.strptime(raw[: len(pattern) + 10], pattern)
break
except ValueError:
continue
if parsed is None:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return ""
date = parsed
else:
return ""
return (
fmt.replace("Y", str(date.year))
.replace("M", f"{date.month:02d}")
.replace("D", f"{date.day:02d}")
.replace("h", f"{date.hour:02d}")
.replace("m", f"{date.minute:02d}")
.replace("s", f"{date.second:02d}")
)
def date_format2(timestamp_ms: float | int) -> str:
"""相对时间格式化(用于评论时间)。"""
differ = int((time.time() * 1000 - float(timestamp_ms)) / 1000)
if differ < 60:
return f"{differ}秒前"
if differ < 3600:
return f"{differ // 60}分钟前"
if differ < 86400:
return f"{differ // 3600}小时前"
if differ < 2592000:
return f"{differ // 86400}天前"
return date_format(timestamp_ms)
def format_play_count(num: float | int | None) -> str:
if num is None:
return "0"
if num > 100000000:
return f"{int(num / 10000000) / 10}亿"
if num > 10000:
return f"{int(num / 1000) / 10}万"
return str(num)
_ENTITY_MAP = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
"&nbsp;": " ",
}
def decode_name(text: Any = "") -> str:
"""解码 HTML 实体。"""
if not text:
return ""
if not isinstance(text, str):
text = str(text)
for entity, char in _ENTITY_MAP.items():
text = text.replace(entity, char)
return re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text)
def format_singer_name(
singers: Iterable[Mapping[str, Any]] | str | None,
name_key: str = "name",
join: str = "、",
) -> str:
"""歌手数组或字符串 → 展示名(数组按 name_key 提取后 join)。"""
if isinstance(singers, (list, tuple)):
names = [str(item[name_key]) for item in singers if isinstance(item, Mapping) and item.get(name_key)]
return decode_name(join.join(names))
return decode_name(str(singers or ""))
def pick(data: Any, *keys: str, default: Any = None) -> Any:
"""沿键路径取值(dict 缺失返回 default),迁移时统一空值访问。"""
cur = data
for key in keys:
if isinstance(cur, Mapping):
cur = cur.get(key)
else:
return default
if cur is None:
return default
return cur
+4
View File
@@ -0,0 +1,4 @@
"""平台包集合。每个子包导出 `MODULE: Platform`。
目录名与平台 id 的差异:5sing → fivesing(Python 标识符不能以数字开头)。
"""
+110
View File
@@ -0,0 +1,110 @@
"""平台模块契约 —— 各平台包导出 `MODULE = Platform(...)`。
对应原 TS 的 `sdk/<platform>/index.ts` default export 对象:
const kw = { musicSearch, leaderboard, songList, hotSearch, comment, album, playlist, parse,
getMusicUrl, getLyric, getPic, handleMusicInfo, getMusicInfo, ... }
Python 侧用 `Platform` 容器承载子模块(属性名 snake_case),能力位缺省为 None 表示不支持,
路由层据此返回 404(与 /sources 的能力位同源)。
子模块协议:
music_search.search(keyword, page=1, limit=20, retry_num=0) -> {"list": [...], ...}
leaderboard.get_boards() -> {"list": [{"id", "name", "bangid"}]}
leaderboard.get_list(bangid, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.get_list(sort_id, tag_id, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.sort_list -> [{"id", "name"}](可选,路由取首项作默认排序)
hot_search.get_list() -> {"list": [str], "source"}
singer.get_info(id) / get_song_list(id, page, limit) / get_album_list(id, page, limit)
album.get_album_detail(id, page, limit) | get_album_list_detail(id, page)
album.search_album(keyword, page, limit)
playlist.get_playlist_detail(id, page, limit) | get_playlist_songs(id)
playlist.search_playlist(keyword, page, limit) | get_categories() | get_category_playlists(id, page, limit)
parse.parse_song(link) / parse_album(link) / parse_playlist(link)
user_playlist.get_list(page, limit)
顶层方法(由 Platform 直接持有):
get_music_url(song_info, quality, retry_num=0) -> {"type", "url"}
get_lyric(song_info) -> {"lyric", "tlyric", "rlyric"?, "yrc"?}
get_pic(song_info) -> str
get_music_info(song_info) -> dict(可选,酷我兜底等场景需要)
handle_music_info(song_info) -> dict(可选,补全歌名/歌手/封面)
get_music_detail_page_url(song_info) -> str(可选)
"""
from __future__ import annotations
from typing import Any
# 能力位名称(/sources 的 capabilities 键 → Platform 属性)
CAPABILITY_ATTRS = {
"search": "music_search",
"url": "get_music_url",
"lyric": "get_lyric",
"pic": "get_pic",
"boards": "leaderboard",
"hotSearch": "hot_search",
"songList": "song_list",
"singer": "singer",
"playlist": "playlist",
"parse": "parse",
"userPlaylist": "user_playlist",
"comment": "comment",
}
class Platform:
"""平台模块容器。只声明属性,不做校验 —— 缺省即不支持。"""
# 平台短码与中文名(/sources 用)
id: str = ""
name: str = ""
music_search: Any = None
leaderboard: Any = None
song_list: Any = None
hot_search: Any = None
comment: Any = None
singer: Any = None
album: Any = None
playlist: Any = None
parse: Any = None
user_playlist: Any = None
get_music_url: Any = None
get_lyric: Any = None
get_pic: Any = None
get_music_info: Any = None
handle_music_info: Any = None
get_music_urls: Any = None
get_music_detail_page_url: Any = None
init: Any = None
def __init__(self, **kwargs: Any) -> None:
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self) -> str:
caps = [key for key in CAPABILITY_ATTRS.values() if getattr(self, key, None) is not None]
return f"<Platform {self.id} caps={','.join(caps)}>"
def capability_map(platform: Platform) -> dict[str, bool]:
"""生成 /sources 的 capabilities 字段(键名沿用原 API 的驼峰写法)。"""
caps: dict[str, bool] = {name: getattr(platform, attr, None) is not None for name, attr in CAPABILITY_ATTRS.items()}
album = platform.album
caps["album"] = album is not None and (
getattr(album, "get_album_detail", None) is not None
or getattr(album, "get_album_list_detail", None) is not None
)
caps["albumSearch"] = album is not None and getattr(album, "search_album", None) is not None
playlist = platform.playlist
caps["playlist"] = playlist is not None and getattr(playlist, "get_playlist_detail", None) is not None
caps["playlistSearch"] = playlist is not None and getattr(playlist, "search_playlist", None) is not None
caps["playlistCategories"] = playlist is not None and getattr(playlist, "get_categories", None) is not None
parse = platform.parse
caps["parse"] = parse is not None and any(
getattr(parse, f"parse_{kind}", None) is not None for kind in ("song", "album", "playlist")
)
caps.pop("comment", None)
return caps
+123
View File
@@ -0,0 +1,123 @@
"""Apple Music 平台模块(对应 sdk/apple/index.ts,上游 music-lib/apple/apple.go 的 Node/TS 移植)。
能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到)/ 封面 /
专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 apis/apple.py)。
凭据:auth_store 的 apple 记录,cookie 形如
``"media-user-token=xxx;storefront=cn;token=xxx"``,也支持 storefront/mediaUserToken/token 直填字段。
未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.apple import album, lyric, music_search, parse, playlist
from app.sdk.platforms.apple.util import (
apple_artwork_url,
apple_song_from_catalog,
apple_web_url,
extract_apple_id,
fetch_apple_song_resource,
get_apple_config,
)
def _song_id_of(song_info: dict[str, Any]) -> str:
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.apple import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort)。"""
if song_info.get("img"):
return str(song_info["img"])
song_id = _song_id_of(song_info)
if not song_id:
return None
try:
res = await fetch_apple_song_resource(song_id)
if not res:
return None
return apple_artwork_url(res.get("attributes", {}).get("artwork"), 600) or None
except Exception: # noqa: BLE001 - 封面取不到返回 None
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""歌曲详情(best-effort;等价上游 Parse(id))。"""
song_id = _song_id_of(song_info)
if not song_id:
return None
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
return apple_song_from_catalog(res) if res else None
class _AppleAlbum:
"""契约 album 能力位(含 TS index.ts 里挂的 parseAlbumLink)。"""
get_album_detail = staticmethod(album.get_album_detail)
get_album_songs = staticmethod(album.get_album_songs)
search_album = staticmethod(album.search_album)
parse_album_link = staticmethod(album.parse_album_link)
class _AppleSongList:
"""契约别名 songList:Apple 的歌单分类即 genre curators,供 /song-list/apple 复用。"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "热门", "id": "1526756058"}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, limit: int = 20) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate, page, limit)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
song_id = _song_id_of(song_info)
if not song_id:
return "https://music.apple.com/"
return apple_web_url("song", song_id, get_apple_config()["storefront"])
def status() -> dict[str, Any]:
"""登录状态(media-user-token 存在性判断;无在线校验接口)。"""
cred = get_credentials("apple") or {}
cfg = get_apple_config()
if cfg["mediaUserToken"] or cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="apple",
name="Apple Music",
music_search=music_search,
album=_AppleAlbum,
playlist=playlist,
song_list=_AppleSongList,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+89
View File
@@ -0,0 +1,89 @@
"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。
详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
→ data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_album
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
async def get_album_detail(raw_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(page/limit 在完整曲目上切片,与其它平台 get_album_detail 契约一致)。"""
album_id = extract_apple_id(str(raw_id or ""), "album")
if not album_id:
raise RuntimeError(f"invalid apple music album id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/albums/{album_id}", {"extend": "extendedAssetUrls"})
)
data = as_list(body.get("data"))
album = as_dict(data[0]) if data else {}
if not album:
raise RuntimeError(f"apple album not found: {album_id}")
tracks = await fetch_all_track_resources(as_dict(album.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track, album_id) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 50))
summary = apple_album_to_playlist(album)
# 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": album_id,
"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[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只返回曲目数组。"""
detail = await get_album_detail(album_id, 1, 100000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页)。"""
detail = await get_album_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = ["get_album_detail", "get_album_songs", "parse_album_link", "search_album"]
+45
View File
@@ -0,0 +1,45 @@
"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 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: ''}``,不抛错。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"include": "lyrics", "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
first = as_dict(data[0]) if data else {}
relationships = as_dict(first.get("relationships"))
lyrics = as_list(as_dict(relationships.get("lyrics")).get("data"))
for rel in lyrics:
text = str(as_dict(as_dict(rel).get("attributes")).get("ttml") or "")
if text:
return {"lyric": text, "tlyric": ""}
return {"lyric": "", "tlyric": ""}
async def get_lyrics_text(song_id: str) -> str:
"""非取消句柄版:直接返回歌词文本(内部/测试用)。"""
result = await get_lyric({"songmid": song_id})
return result.get("lyric") or ""
+90
View File
@@ -0,0 +1,90 @@
"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。
通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.py)
单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
get_apple_config,
)
#: amp-api search 单页硬上限
SEARCH_PAGE_MAX = 25
async def _catalog_search(keyword: str, types: str, page: int, limit: int) -> dict[str, Any]:
word = str(keyword or "").strip()
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 100)
if not word:
return {"body": None, "page": p, "limit": size}
cfg = get_apple_config()
body = await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/search",
{
"term": word,
"types": types,
"limit": min(size, SEARCH_PAGE_MAX),
"offset": (p - 1) * size,
},
)
return {"body": body, "page": p, "limit": size}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,amp-api 无重试语义
result = await _catalog_search(keyword, "songs", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
bucket = as_dict(results.get("songs"))
songs = [apple_song_from_catalog(item) for item in as_list(bucket.get("data"))]
has_next = bool(bucket.get("next"))
return {
"list": songs,
"allPage": p + 1 if has_next else p,
"total": (p - 1) * size + len(songs),
"limit": size,
"source": "apple",
}
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchAlbum:搜索专辑(返回歌单摘要形状)。"""
result = await _catalog_search(keyword, "albums", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("albums")).get("data"))
songs = [apple_album_to_playlist(item) for item in items]
return {
"list": songs,
"total": (p - 1) * size + len(songs),
"page": p,
"limit": size,
"source": "apple",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist:搜索歌单。"""
result = await _catalog_search(keyword, "playlists", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("playlists")).get("data"))
playlists = [apple_playlist_to_playlist(item) for item in items]
return {
"list": playlists,
"total": (p - 1) * size + len(playlists),
"page": p,
"limit": size,
"source": "apple",
}
+34
View File
@@ -0,0 +1,34 @@
"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 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。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.apple.album import parse_album_link
from app.sdk.platforms.apple.playlist import parse_playlist_link
from app.sdk.platforms.apple.util import apple_song_from_catalog, extract_apple_id, fetch_apple_song_resource
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链)。"""
song_id = extract_apple_id(str(link or ""), "song")
if not song_id:
raise RuntimeError(f"invalid apple music song link: {link}")
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
if not res:
raise RuntimeError(f"apple song not found: {song_id}")
return apple_song_from_catalog(res)
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+177
View File
@@ -0,0 +1,177 @@
"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。
详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
曲目超过一页时沿 relationships.tracks.next 全量拉取(带 20 页上限防死循环)。
分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
链接:extract_apple_id(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_playlist
from app.sdk.platforms.apple.util import (
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
#: 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators)
APPLE_CURATOR_CATEGORIES: list[dict[str, str]] = [
{"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": "亲子"},
]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:静态返回 Apple Music genre curators。"""
return {
"source": "apple",
"list": [
{"id": item["id"], "name": item["name"], "group": "Apple Music 分类", "source": "apple"}
for item in APPLE_CURATOR_CATEGORIES
],
}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐)。"""
cate = str(category_id or "").strip()
if not cate:
raise RuntimeError("apple 分类 id 不能为空")
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 300)
api_max = 25
offset = (p - 1) * size
all_playlists: list[dict[str, Any]] = []
while len(all_playlists) < size:
batch_size = min(api_max, size - len(all_playlists))
try:
body = as_dict(
await apple_amp_get(
f"/v1/catalog/cn/apple-curators/{cate}/playlists",
{"limit": batch_size, "offset": offset, "l": "zh-Hans-CN"},
)
)
except Exception: # 上游:已有数据时返回已取部分
if all_playlists:
break
raise
data = as_list(body.get("data"))
all_playlists.extend(apple_playlist_to_playlist(item) for item in data)
# 没有更多数据或没有下一页
if len(data) < batch_size or not body.get("next"):
break
offset += len(data)
return {"source": "apple", "list": all_playlists[:size], "page": p, "limit": size, "total": len(all_playlists)}
async def get_playlist_detail(raw_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(全量曲目后按 page/limit 切片)。"""
playlist_id = extract_apple_id(str(raw_id or ""), "playlist")
if not playlist_id:
raise RuntimeError(f"invalid apple music playlist id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/playlists/{playlist_id}",
{"limit[tracks]": 300, "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
item = as_dict(data[0]) if data else {}
if not item:
raise RuntimeError(f"apple playlist not found: {playlist_id}")
tracks = await fetch_all_track_resources(as_dict(item.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 100))
summary = apple_playlist_to_playlist(item)
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": playlist_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只返回曲目数组。"""
detail = await get_playlist_detail(playlist_id, 1, 100000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页)。"""
detail = await get_playlist_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = [
"APPLE_CURATOR_CATEGORIES",
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"parse_playlist_link",
"search_playlist",
]
+465
View File
@@ -0,0 +1,465 @@
"""Apple Music 公共工具(对应 sdk/apple/util.ts,上游 music-lib/apple/apple.go 的移植基础层)。
* ``New(cookie)`` → auth_store 的 apple 记录:cookie 支持
``"media-user-token=xxx;storefront=cn;token=xxx"`` 形态。
* ``fetch_apple_token``:从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)。
当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi 开头而非上游正则写的 eyJh,
故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取。
* amp 请求(httpFetch 不自动跟 302,首页跳转用 fetch_follow 手动跟)。
* 模型转换与 ID 提取(字段映射同上游,apple_test.go 的断言逐条保留)。
"""
from __future__ import annotations
import asyncio
import base64
import json
import re
import time
from typing import Any
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
APPLE_HOMEPAGE_URL = "https://music.apple.com"
APPLE_AMP_API_URL = "https://amp-api.music.apple.com"
DEFAULT_STOREFRONT = "us"
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"
)
#: JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
TOKEN_CACHE_TTL_MS = 12 * 60 * 60 * 1000
_cached_token = ""
_cached_at = 0.0
#: 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
_bypass_explicit_token = False
_token_lock = asyncio.Lock()
def parse_cookie_parts(cookie: str) -> dict[str, str]:
"""上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白。"""
result: dict[str, str] = {}
for part in str(cookie or "").split(";"):
trimmed = part.strip()
idx = trimmed.find("=")
if idx > 0:
result[trimmed[:idx].strip()] = trimmed[idx + 1 :].strip()
return result
def get_apple_config() -> dict[str, str]:
"""读取 apple 平台运行时配置(对应上游 New(cookie))。"""
cred = get_credentials("apple") or {}
parts = parse_cookie_parts(str(cred.get("cookie") or ""))
storefront = str(cred.get("storefront") or parts.get("storefront") or DEFAULT_STOREFRONT).strip()
return {
"storefront": storefront or DEFAULT_STOREFRONT,
"mediaUserToken": str(cred.get("mediaUserToken") or parts.get("media-user-token") or "").strip(),
"token": str(cred.get("token") or parts.get("token") or "").strip(),
}
def decode_apple_token_payload(token: str) -> dict[str, Any]:
"""解码 JWT payload(Apple token 为 ES256 JWT)。"""
try:
part = str(token or "").split(".")[1]
padded = part.replace("-", "+").replace("_", "/")
padded += "=" * (-len(padded) % 4)
data = json.loads(base64.b64decode(padded).decode("utf-8"))
return data if isinstance(data, dict) else {}
except (IndexError, ValueError, TypeError):
return {}
def is_apple_token_expired(token: str) -> bool:
try:
exp = float(decode_apple_token_payload(token).get("exp") or 0)
except (TypeError, ValueError):
exp = 0
return exp > 0 and exp * 1000 < time.time() * 1000 + 60 * 1000
def extract_apple_tokens(js: str) -> list[str]:
"""从 index JS 中提取所有 JWT 形态的候选 token(含引号前缀的主匹配 + 无前缀回退)。"""
pattern = r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
found: list[str] = []
found.extend(m.group(1) for m in re.finditer(rf"""(?:=["']){pattern}""", str(js or "")))
if not found:
found.extend(m.group(1) for m in re.finditer(pattern, str(js or "")))
# 去重且保持顺序
return list(dict.fromkeys(found))
def pick_apple_token(js: str) -> str:
"""选择可用 token:优先 issuer=AMPWebPlay,其次第一个未过期;全部过期视为抓取失败。"""
tokens = extract_apple_tokens(js)
if not tokens:
return ""
alive = [token for token in tokens if not is_apple_token_expired(token)]
if not alive:
return ""
preferred = next(
(token for token in alive if str(decode_apple_token_payload(token).get("iss") or "") == "AMPWebPlay"), None
)
return preferred or alive[0] or ""
async def fetch_follow(url: str, headers: dict[str, str] | None = None, max_hops: int = 5) -> HttpResponse:
"""手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳)。"""
current = str(url)
response = None
for _hop in range(max_hops):
response = await http_fetch(current, headers=headers or {}, timeout=20_000)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
current = urljoin(current, location)
continue
return response
raise RuntimeError("apple: too many redirects")
def _response_text(response: HttpResponse) -> str:
if isinstance(response.body, str):
return response.body
if response.raw:
return response.raw.decode("utf-8", errors="replace")
return str(response.body or "")
async def fetch_apple_token() -> str:
"""上游 fetchAppleToken:首页 → index-legacy JS → bearer token。"""
home = await fetch_follow(APPLE_HOMEPAGE_URL, {"User-Agent": APPLE_UA})
if home.status_code != 200:
raise RuntimeError(f"apple: fetch homepage error: HTTP {home.status_code}")
html = _response_text(home)
# 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
match = re.search(r"/(assets/index-legacy[~-][^/\"']+\.js)", html)
if not match:
match = re.search(r"/(assets/index[~-][^/\"']+\.js)", html)
if not match:
raise RuntimeError("apple: index.js URI not found")
js_res = await fetch_follow(f"{APPLE_HOMEPAGE_URL}/{match.group(1)}", {"User-Agent": APPLE_UA})
if js_res.status_code != 200:
raise RuntimeError(f"apple: fetch index.js error: HTTP {js_res.status_code}")
token = pick_apple_token(_response_text(js_res))
if not token:
raise RuntimeError("apple: token not found in index.js")
return token
async def get_apple_token(force: bool = False) -> str:
"""获取 developer token(缓存 + JWT exp 校验 + 并发去重)。"""
global _cached_token, _cached_at
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
async with _token_lock:
# 双检:并发调用者拿到锁后直接复用前一次抓取结果
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
token = await fetch_apple_token()
_cached_token = token
_cached_at = time.time() * 1000
return token
def invalidate_apple_token() -> None:
"""清除缓存 token(401/403 时调用,下次请求会回源重取)。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = True
def reset_apple_token_state() -> None:
"""仅测试/调试用:重置内存态。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = False
def apple_amp_headers(token: str) -> dict[str, str]:
cfg = get_apple_config()
headers = {
"Authorization": f"Bearer {token}",
"Origin": APPLE_HOMEPAGE_URL,
"Referer": f"{APPLE_HOMEPAGE_URL}/",
"User-Agent": APPLE_UA,
"Accept": "application/json",
}
# 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
if cfg["mediaUserToken"]:
headers["Cookie"] = f"media-user-token={cfg['mediaUserToken']}"
return headers
def build_apple_amp_url(uri: str, params: dict[str, Any] | None = None) -> str:
"""拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL。"""
raw = str(uri or "")
if re.match(r"^https?://", raw, re.IGNORECASE):
base = raw
elif raw.startswith("/"):
base = APPLE_AMP_API_URL + raw
else:
base = f"{APPLE_AMP_API_URL}/{raw}"
pairs = [(key, str(value)) for key, value in (params or {}).items() if value is not None and value != ""]
if not pairs:
return base
query = urlencode(pairs)
return base + ("&" if "?" in base else "?") + query
def _amp_error_detail(response: HttpResponse) -> str:
body = response.body if isinstance(response.body, dict) else {}
errors = as_list(body.get("errors"))
first = as_dict(errors[0]) if errors else {}
return str(first.get("detail") or first.get("title") or "")
async def apple_amp_get_tracked(uri: str, params: dict[str, Any] | None = None) -> Any:
"""amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)。"""
url = build_apple_amp_url(uri, params)
async def attempt() -> HttpResponse:
token = await get_apple_token()
return await http_fetch(url, headers=apple_amp_headers(token), timeout=20_000)
response = await attempt()
if response.status_code in (401, 403):
invalidate_apple_token()
response = await attempt()
if not (200 <= response.status_code < 300):
detail = _amp_error_detail(response)
raise RuntimeError(f"apple amp-api HTTP {response.status_code}{f': {detail}' if detail else ''}")
return response.body
async def apple_amp_get(uri: str, params: dict[str, Any] | None = None) -> Any:
return await apple_amp_get_tracked(uri, params)
#: 拉取 relationships.tracks 全部分页时的页数上限(防死循环)
MAX_TRACK_PAGES = 20
async def fetch_all_track_resources(tracks: Any) -> list[Any]:
"""拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources)。"""
tracks = as_dict(tracks)
all_tracks: list[Any] = list(as_list(tracks.get("data")))
next_uri = str(tracks.get("next") or "").strip()
page = 0
while next_uri and page < MAX_TRACK_PAGES:
body = as_dict(await apple_amp_get_tracked(next_uri))
all_tracks.extend(as_list(body.get("data")))
next_uri = str(body.get("next") or "").strip()
page += 1
return all_tracks
def apple_artwork_url(artwork: Any, size: int = 600) -> str:
"""上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位。"""
artwork = as_dict(artwork)
url = str(artwork.get("url") or "")
if not url:
return ""
return url.replace("{w}", str(size)).replace("{h}", str(size))
def _round_number(value: Any) -> int:
"""JS Math.round 的等价实现(.5 向上取整)。"""
import math
try:
number = float(value or 0)
except (TypeError, ValueError):
number = 0.0
return math.floor(number + 0.5)
def apple_song_from_catalog(res: Any, default_album_id: str = "") -> dict[str, Any]:
"""上游 appleSongFromCatalog → 契约 Song 形状。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
previews = as_list(attr.get("previews"))
preview_url = str(as_dict(previews[0]).get("url") or "") if previews else ""
duration_sec = _round_number(float(attr.get("durationInMillis") or 0) / 1000)
return {
"songmid": str(res.get("id") or ""),
"name": attr.get("name") or "",
"singer": attr.get("artistName") or "",
"albumId": str(attr.get("albumId") or default_album_id or ""),
"albumName": attr.get("albumName") or "",
"interval": format_play_time(duration_sec),
"img": apple_artwork_url(attr.get("artwork"), 600) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "apple",
"link": attr.get("url") or "", # music.apple.com 网页地址
"previewUrl": preview_url, # 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
"duration": duration_sec,
"isrc": attr.get("isrc") or "",
}
def apple_album_to_playlist(res: Any) -> dict[str, Any]:
"""上游 appleAlbumToPlaylist → 契约歌单/专辑摘要。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
notes = as_dict(attr.get("editorialNotes"))
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("artistName") or "",
"description": notes.get("short") or "",
"link": attr.get("url") or "",
"source": "apple",
"extra": {
"release_date": attr.get("releaseDate") or "",
"record_label": attr.get("recordLabel") or "",
},
}
def apple_playlist_to_playlist(res: Any) -> dict[str, Any]:
"""上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游)。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
description = as_dict(attr.get("description"))
notes = as_dict(attr.get("editorialNotes"))
desc = description.get("short") or description.get("standard") or notes.get("short") or notes.get("standard") or ""
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("curatorName") or "",
"description": desc,
"link": attr.get("url") or "",
"source": "apple",
}
def apple_web_url(kind: str, media_id: str, storefront: str = DEFAULT_STOREFRONT) -> str:
sf = str(storefront or DEFAULT_STOREFRONT)
return f"{APPLE_HOMEPAGE_URL}/{sf}/{kind}/{media_id}"
def is_apple_raw_id(value: str, media_type: str) -> bool:
"""上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字)。"""
if re.search(r"[/?#]", value) or "://" in value:
return False
if media_type == "playlist":
if value.startswith("pl."):
return re.fullmatch(r"[a-zA-Z0-9._-]+", value) is not None
return re.fullmatch(r"[a-zA-Z0-9_-]+", value) is not None
return re.fullmatch(r"\d+", value) is not None
def extract_apple_id_from_path(path: str, media_type: str) -> str:
"""上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id>。"""
trimmed = re.sub(r"^/+|/+$", "", str(path or ""))
if not trimmed:
return ""
segments = trimmed.split("/")
for index, segment in enumerate(segments):
if segment != media_type and segment != f"{media_type}s":
continue
if index + 2 < len(segments):
return segments[index + 2]
if index + 1 < len(segments):
return segments[index + 1]
return ""
def extract_apple_id(link: str, media_type: str) -> str:
"""上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底。"""
raw = str(link or "").strip()
if not raw:
return ""
if is_apple_raw_id(raw, media_type):
return raw
path = ""
query = ""
try:
parts = urlsplit(raw)
if parts.scheme or parts.netloc:
path = parts.path or ""
query = f"?{parts.query}" if parts.query else ""
except ValueError:
path, query = "", ""
if not path:
idx = raw.find("?")
path = raw[:idx] if idx >= 0 else raw
query = raw[idx:] if idx >= 0 else ""
if path:
if media_type == "song":
values = parse_qs(query.lstrip("?"), keep_blank_values=True).get("i")
song_id = values[0].strip() if values else ""
if song_id:
return song_id
from_path = extract_apple_id_from_path(path, media_type)
if from_path:
return from_path
patterns = [
rf"{media_type}/[^/]+/(\d+)",
rf"{media_type}s?/[^/]+/[^/]+/(\d+)",
r"/(\d+)$",
r"[?&]i=(\d+)",
]
if media_type == "playlist":
patterns.extend(
[
r"playlists?/[^/?#]+/([a-zA-Z0-9._-]+)",
r"playlists?/([a-zA-Z0-9._-]+)(?:[?#]|$)",
]
)
for pattern in patterns:
match = re.search(pattern, raw)
if match:
return match.group(1)
return ""
async def fetch_apple_song_resource(song_id: str, include: str = "") -> Any:
"""歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>)。"""
cfg = get_apple_config()
params: dict[str, Any] = {"extend": "extendedAssetUrls"}
if include:
params["include"] = include
body = as_dict(await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/songs/{song_id}", params))
data = as_list(body.get("data"))
return data[0] if data else None
def quote_path_segment(value: str) -> str:
"""路径段转义(ID 基本是字母数字,保留以免未来出现特殊字符)。"""
return quote(str(value), safe="._-")
+96
View File
@@ -0,0 +1,96 @@
"""千千音乐(music.91q.com)平台模块(对应 sdk/bd/index.ts)。
上游 music-lib/qianqian 的移植。匿名可用面(实测):
* 搜索(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),`search_playlist` 返回空列表;
* 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 `user_playlist`;
* 千千无独立封面接口:搜索结果/专辑/歌单自带 pic,缺失时回退 `song/info`;
* 千千无「榜单」概念:`song_list` 以歌单分类作为标签体系(tagId 为 `/v1/tracklist/category` 的 subCate id)。
凭据:cookie 从 `app/sdk/auth_store.py` 的 `get_credentials('bd')` 读取(可选,仅影响 VIP 等登录态资源)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bd import album, lyric, music_info, music_search, parse, playlist
from app.sdk.platforms.bd.util import get_qian_cookie
class _SongList:
"""MusicServer 的 songList 契约:`get_list(sort_id, tag_id, page)` / `get_tags()`。
千千把歌单分类当标签体系,`tagId` 为 subCate id(纯数字才下发,其它回落到「全部」)。
"""
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate if re.fullmatch(r"\d+", cate) else "", page, 30)
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
song_list = _SongList()
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""千千无独立封面接口:先用曲目自带的 img,缺失时回退 song/info。"""
img = song_info.get("img")
if img:
return str(img)
try:
info = await music_info.get_song_info(str(song_info.get("songmid") or ""))
return info.get("img") or None
except Exception: # noqa: BLE001 - 与原文一致:封面取不到就返回 null,不影响调用方
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
return await music_info.get_song_info(str(song_info.get("songmid") or ""))
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
tsid = str(song_info.get("songmid") or "").strip()
return f"https://music.91q.com/song/{tsid}" if tsid else "https://music.91q.com/"
def status() -> dict[str, Any]:
"""凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流)。"""
return {"logged": True} if get_qian_cookie() else {"logged": False}
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:`apis/bd.py` 反过来依赖本包的 util,顶层导入会形成环。"""
from app.sdk.apis.bd import get_music_url
return await get_music_url(song_info, quality, retry_num)
MODULE = Platform(
id="bd",
name="千千音乐",
music_search=music_search,
song_list=song_list,
album=album,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=lyric.get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+203
View File
@@ -0,0 +1,203 @@
"""千千音乐专辑(对应 sdk/bd/album.ts)。
搜索:`/v1/search?type=3`(特殊字符触发 errno 23001 时用清洗后的关键词重试)。
详情:`/v1/album/info?albumAssetCode=P...`(P 码可直接用;纯数字旧 id 走
`/v1/album/albumid2psid` 转换)。
专辑链接:`https://music.91q.com/album/<albumAssetCode>`。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.bd.util import (
APP_ID,
album_link,
api_get,
api_ok,
error_text,
join_artists,
map_track,
normalize_album_asset_code,
release_date10,
sanitize_album_keyword,
to_number,
)
class _RetryableAlbumError(RuntimeError):
"""专辑搜索失败;`retry_with_sanitized` 表示 23001(词非法)可换清洗后的词重试。"""
def __init__(self, message: str, retry_with_sanitized: bool) -> None:
super().__init__(message)
self.retry_with_sanitized = retry_with_sanitized
def _map_album_summary(item: Any) -> dict[str, Any]:
source = item if isinstance(item, dict) else {}
id = normalize_album_asset_code(source.get("albumAssetCode"))
track_list = source.get("trackList")
return {
"id": id,
"name": str(source.get("title") or "").strip(),
"cover": str(source.get("pic") or "").strip(),
"trackCount": len(track_list) if isinstance(track_list, list) else 0,
"creator": join_artists(source.get("artist")),
"description": str(source.get("introduce") or "").strip(),
"releaseDate": release_date10(source.get("releaseDate")),
"genre": str(source.get("genre") or "").strip(),
"lang": str(source.get("lang") or "").strip(),
"link": album_link(id) if id else "",
"source": "bd",
}
async def _search_album_once(word: str, page: int = 1, limit: int = 10) -> list[Any]:
body = await api_get(
"/v1/search",
{
"word": word,
"type": "3",
"pageNo": str(max(1, page)),
"pageSize": str(max(1, limit)),
"appid": APP_ID,
},
)
if not api_ok(body):
errno = body.get("errno") if isinstance(body, dict) else None
raise _RetryableAlbumError(error_text(body, "千千专辑搜索失败"), to_number(errno) == 23001)
data = (body.get("data") if isinstance(body, dict) else None) or {}
items = data.get("typeAlbum") if isinstance(data, dict) else None
return list(items) if isinstance(items, list) else []
async def search_album_items(keyword: str, page: int = 1, limit: int = 10) -> list[Any]:
"""上游 searchAlbumItems:原词失败(23001)时用清洗后的关键词重试一次。"""
words = [str(keyword if keyword is not None else "").strip()]
sanitized = sanitize_album_keyword(keyword)
if sanitized and sanitized != words[0]:
words.append(sanitized)
last_err: Exception | None = None
for word in words:
try:
return await _search_album_once(word, page, limit)
except _RetryableAlbumError as err:
last_err = err
# 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
if not err.retry_with_sanitized:
break
if last_err is not None:
raise last_err
return []
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""搜索专辑(返回歌单/专辑统一摘要形状)。"""
word = str(keyword if keyword is not None else "").strip()
if not word:
return {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
items = await search_album_items(word, page, limit)
list_out = [album for album in (_map_album_summary(item) for item in items) if album["id"]][:limit]
return {"source": "bd", "list": list_out, "total": len(items), "page": page, "limit": limit}
async def resolve_album_asset_code(id: str) -> str:
"""旧数字专辑 id → PSID(P 码);已是 P 码则原样返回。"""
raw = str(id if id is not None else "").strip()
if not raw:
raise RuntimeError("千千专辑 id 为空")
normalized = normalize_album_asset_code(raw)
if normalized:
return normalized
body = await api_get("/v1/album/albumid2psid", {"albumid": raw, "appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千专辑 id 转换失败"))
data = body.get("data") if isinstance(body, dict) else None
for item in data if isinstance(data, list) else []:
if isinstance(item, dict):
psid = normalize_album_asset_code(item.get("psid"))
if psid:
return psid
raise RuntimeError("千千专辑 asset code 未找到")
async def _fetch_album_info(id: str) -> tuple[str, dict[str, Any]]:
album_id = await resolve_album_asset_code(id)
body = await api_get("/v1/album/info", {"albumAssetCode": album_id, "appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千专辑详情获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
if not normalize_album_asset_code(data.get("albumAssetCode") if isinstance(data, dict) else None):
raise RuntimeError("千千专辑不存在或已下架")
return album_id, data
async def get_album_detail(id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""专辑详情(统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片)。"""
album_id, data = await _fetch_album_info(id)
title = str(data.get("title") or "").strip()
cover = str(data.get("pic") or "").strip()
creator = join_artists(data.get("artist"))
all_tracks = data.get("trackList")
tracks = list(all_tracks) if isinstance(all_tracks, list) else []
songs: list[dict[str, Any]] = []
for item in tracks:
song = map_track(item, album_id=album_id, album_name=title, img=cover, singer=creator)
if not song["songmid"]:
continue
# 专辑内曲目补上序号(上游 Extra.track / sort);为 0 时不写该键,与 TS 的 undefined 一致
sort = to_number(item.get("sort") if isinstance(item, dict) else None)
if sort > 0:
song["track"] = int(sort)
songs.append(song)
size = max(1, limit)
start = (max(1, page) - 1) * size
return {
"source": "bd",
"info": {
"id": album_id,
"name": title,
"img": cover,
"desc": str(data.get("introduce") or "").strip(),
"author": creator,
"releaseDate": release_date10(data.get("releaseDate")),
"genre": str(data.get("genre") or "").strip(),
"lang": str(data.get("lang") or "").strip(),
"total": len(songs),
},
"list": songs[start : start + size],
"page": page,
"limit": limit,
"total": len(songs),
"allPage": math.ceil(len(songs) / size) or 1,
}
async def get_album_songs(id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只取曲目列表。"""
detail = await get_album_detail(id, 1, 10000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态。"""
raw = str(link if link is not None else "").strip()
patterns = (
re.compile(r"music\.91q\.com/album/([A-Za-z0-9]+)"),
re.compile(r"albumAssetCode=([A-Za-z0-9]+)"),
re.compile(r"albumid=(\d+)"),
)
for pattern in patterns:
match = pattern.search(raw)
if match:
return await get_album_detail(match.group(1))
raise RuntimeError("无效的千千专辑链接")
+30
View File
@@ -0,0 +1,30 @@
"""千千音乐歌词(对应 sdk/bd/lyric.ts)。
链路:`/v1/song/info` → `data[0].lyric` 为歌词文本 URL(static-qianqian.taihe.com/*.txt)
→ 直接下载文本。返回 `{lyric, tlyric: ''}`(千千只有原文歌词,无翻译)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bd.music_info import get_song_info
from app.sdk.platforms.bd.util import request_headers
async def get_lyric(song_info: dict[str, Any]) -> dict[str, Any]:
tsid = str(song_info.get("songmid") or song_info.get("TSID") or "").strip()
if not tsid:
raise RuntimeError("千千歌词需要 songmid(TSID)")
info = await get_song_info(tsid)
lyric_url = str(info.get("lyricUrl") or "").strip()
if not lyric_url:
raise RuntimeError("千千该歌曲暂无歌词")
resp = await http_fetch(lyric_url, method="get", headers=request_headers(), timeout=15000)
if resp.status_code != 200:
raise RuntimeError(f"千千歌词下载失败(HTTP {resp.status_code})")
lyric = resp.body if isinstance(resp.body, str) else resp.raw.decode("utf-8", errors="replace")
return {"lyric": lyric, "tlyric": ""}
+34
View File
@@ -0,0 +1,34 @@
"""千千音乐单曲信息(对应 sdk/bd/musicInfo.ts)。
接口返回 `data[]`:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track
async def get_song_info(tsid: str) -> dict[str, Any]:
"""由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl)。"""
id = str(tsid if tsid is not None else "").strip()
if not id:
raise RuntimeError("千千歌曲 id(TSID)为空")
body = await api_get("/v1/song/info", {"TSID": id, "appid": APP_ID})
data = body.get("data") if isinstance(body, dict) else None
if not api_ok(body) and not isinstance(data, list):
raise RuntimeError(error_text(body, "千千歌曲信息获取失败"))
item = data[0] if isinstance(data, list) and data else None
if not isinstance(item, dict):
raise RuntimeError("千千歌曲不存在或已下架")
song = map_track(item)
return {
**song,
# 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词)
"songmid": song["songmid"] or id,
"lyricUrl": str(item.get("lyric") or "").strip(),
"raw": item,
}
+41
View File
@@ -0,0 +1,41 @@
"""千千音乐搜索(对应 sdk/bd/musicSearch.ts)。
`/v1/search?word=&type=1&pageNo=&pageSize=`,appid + 签名。
响应 `data.typeTrack[]`:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip。
注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay,
以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track, to_int, to_number
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
word = str(keyword if keyword is not None else "").strip()
if not word:
return {"list": [], "allPage": 1, "total": 0, "limit": limit, "source": "bd"}
body = await api_get(
"/v1/search",
{"word": word, "type": "1", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
)
# 23001 = 服务端不认该搜索类型/关键词,与原文一致:按空结果处理而不是报错
if not api_ok(body) and to_number(body.get("errno") if isinstance(body, dict) else None) != 23001:
raise RuntimeError(error_text(body, "千千搜索失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
tracks = data.get("typeTrack") if isinstance(data, dict) else None
raw = tracks if isinstance(tracks, list) else []
total = to_int(data.get("total") if isinstance(data, dict) else None, len(raw))
have_more = to_number(data.get("haveMore") if isinstance(data, dict) else None) == 1
songs = [song for song in (map_track(item) for item in raw) if song["songmid"]]
page_size = max(1, limit)
all_page = page + 1 if have_more else max(page, math.ceil(total / page_size) or 1)
return {"list": songs, "allPage": all_page, "total": total, "limit": limit, "source": "bd"}
+48
View File
@@ -0,0 +1,48 @@
"""千千音乐链接解析(对应 sdk/bd/parse.ts)。
歌曲:`music.91q.com/song/<TSID>`(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)。
专辑:`music.91q.com/album/<P码>` / `?albumAssetCode=` / `?albumid=`。
歌单:`music.91q.com/songlist|tracklist|playlist/<id>` / `?*id=` / 裸歌单 id。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bd import album as album_module
from app.sdk.platforms.bd import playlist as playlist_module
from app.sdk.platforms.bd.music_info import get_song_info
from app.sdk.platforms.bd.util import first_non_empty
_TSID_RE = re.compile(r"music\.91q\.com/song/(\w+)", re.ASCII)
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:歌曲链接 → Song(尽力附带直链 url/type)。"""
match = _TSID_RE.search(str(link if link is not None else ""))
if not match:
raise RuntimeError("无效的千千歌曲链接")
tsid = match.group(1)
song: dict[str, Any] = dict(await get_song_info(tsid))
try:
# 延迟导入:`apis/bd.py` 依赖本包的 util,顶层导入会让包初始化与 apis 互等
from app.sdk.apis.bd import get_download_url
hit = await get_download_url(tsid)
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
pass
song.pop("lyricUrl", None)
song.pop("raw", None)
return {**song, "songmid": first_non_empty(song.get("songmid"), tsid), "source": "bd"}
async def parse_album(link: str) -> dict[str, Any]:
return await album_module.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist_module.parse_playlist_link(link)
+201
View File
@@ -0,0 +1,201 @@
"""千千音乐歌单(对应 sdk/bd/playlist.ts)。
详情:`/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 解析以备恢复。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.bd.util import (
APP_ID,
api_get,
api_ok,
error_text,
first_non_empty,
map_track,
playlist_link,
to_int,
)
def _map_playlist_summary(item: Any) -> dict[str, Any]:
source = item if isinstance(item, dict) else {}
id = "" if source.get("id") is None else str(source.get("id"))
raw_desc = str(source.get("desc") or "").strip()
tag_list = source.get("tagList")
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
return {
"id": id,
"name": str(source.get("title") or "").strip(),
"cover": str(source.get("pic") or "").strip(),
"trackCount": to_int(source.get("trackCount"), 0),
"creator": first_non_empty(
source.get("creator"),
source.get("author"),
source.get("userName"),
source.get("nickName"),
source.get("ownerName"),
),
"description": raw_desc or "、".join(tags),
"link": playlist_link(id) if id else "",
"source": "bd",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表)。"""
word = str(keyword if keyword is not None else "").strip()
empty: dict[str, Any] = {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
if not word:
return empty
body = await api_get(
"/v1/search",
{"word": word, "type": "6", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
)
# 上游此处只看 body.state(不看 errno),与服务端下线后的行为一致
if not isinstance(body, dict) or not body.get("state"):
return empty
data = body.get("data") or {}
items = data.get("typeSonglist") if isinstance(data, dict) else None
summaries = [_map_playlist_summary(item) for item in items] if isinstance(items, list) else []
list_out = [item for item in summaries if item["id"]]
total = to_int(data.get("total") if isinstance(data, dict) else None, len(list_out))
return {"source": "bd", "list": list_out, "total": total, "page": page, "limit": limit}
async def get_playlist_detail(id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize)。"""
playlist_id = str(id if id is not None else "").strip()
if not playlist_id:
raise RuntimeError("千千歌单 id 为空")
body = await api_get(
"/v1/tracklist/info",
{
"id": playlist_id,
"type": "0",
"pageNo": str(max(1, page)),
"pageSize": str(max(1, limit)),
"appid": APP_ID,
},
)
if not api_ok(body):
raise RuntimeError(error_text(body, "千千歌单获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
raw_tracks = data.get("trackList")
tracks = [item for item in raw_tracks if isinstance(item, dict)] if isinstance(raw_tracks, list) else []
songs = [song for song in (map_track(item) for item in tracks) if song["songmid"]]
title = first_non_empty(data.get("title"), playlist_id)
tag_list = data.get("tagList")
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
cover = first_non_empty(data.get("pic"), songs[0]["img"] if songs else None)
creator = first_non_empty(
data.get("creator"),
data.get("author"),
data.get("userName"),
data.get("nickName"),
data.get("ownerName"),
)
description = first_non_empty(data.get("desc"), data.get("description")) or "、".join(tags)
total = to_int(data.get("trackCount"), len(songs))
return {
"source": "bd",
"info": {
"id": playlist_id,
"name": title,
"img": cover,
"desc": description,
"author": creator,
"link": playlist_link(playlist_id),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
"allPage": math.ceil(total / max(1, limit)) or 1,
}
async def get_playlist_songs(id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只取曲目列表。"""
detail = await get_playlist_detail(id, 1, 1000)
return detail["list"]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:`{source, list: [{id, name, group, count}]}`,首项为「全部」(id='')。"""
body = await api_get("/v1/tracklist/category", {"appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千歌单分类获取失败"))
list_out: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
groups = body.get("data") if isinstance(body, dict) else None
for group in groups if isinstance(groups, list) else []:
if not isinstance(group, dict):
continue
group_name = str(group.get("categoryName") or "").strip()
sub_cate = group.get("subCate")
for item in sub_cate if isinstance(sub_cate, list) else []:
if not isinstance(item, dict):
continue
id = str(item.get("id") or "").strip()
name = str(item.get("categoryName") or "").strip()
if not id or not name:
continue
list_out.append({"id": id, "name": name, "group": group_name, "count": to_int(item.get("count"), 0)})
return {"source": "bd", "list": list_out}
async def get_category_playlists(category_id: Any, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists(page >= 1,limit 1..100)。"""
cate_id = str(category_id if category_id is not None else "").strip()
page_no = max(1, to_int(page, 1))
page_size = min(100, max(1, to_int(limit, 30)))
params: dict[str, Any] = {"appid": APP_ID, "pageNo": str(page_no), "pageSize": str(page_size)}
if cate_id:
params["subCateId"] = cate_id
body = await api_get("/v1/tracklist/list", params)
if not api_ok(body):
raise RuntimeError(error_text(body, "千千分类歌单获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
result = data.get("result")
summaries = (
[{**_map_playlist_summary(item), "categoryId": cate_id} for item in result] if isinstance(result, list) else []
)
list_out = [item for item in summaries if item["id"]]
total = to_int(data.get("total"), len(list_out))
return {"source": "bd", "list": list_out, "total": total, "page": page_no, "limit": page_size}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id。"""
raw = str(link if link is not None else "").strip()
patterns = (
re.compile(r"music\.91q\.com/(?:songlist|tracklist|playlist)/([A-Za-z0-9]+)"),
re.compile(r"(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)"),
)
for pattern in patterns:
match = pattern.search(raw)
if match:
return await get_playlist_detail(match.group(1))
if raw and "/" not in raw:
return await get_playlist_detail(raw)
raise RuntimeError("无效的千千歌单链接")
+296
View File
@@ -0,0 +1,296 @@
"""千千音乐(music.91q.com / 91q.com)公共请求层(对应 sdk/bd/util.ts)。
上游全部接口都走 `/v1/*`,参数 = 业务参数 + appid + timestamp + sign:
sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 只影响 VIP、收藏等登录态资源。
**签名串与 query 必须分开编码**:sign 用参数原值拼接(服务端拿收到的参数值重算),
query 用 `URLSearchParams` 序列化。两者共用同一份 `sign_params` 结果,且 URL 拼好后
直接交给 `http_fetch`(不再二次序列化),这是历史上「签名串 ≠ 实际 body」踩过的坑。
"""
from __future__ import annotations
import hashlib
import math
import re
import time
from typing import Any
from app.sdk.auth_store import get_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import size_formate
APP_ID = "16073360"
SECRET = "0b50b02fd0d73a9c4c8c3a781c30845f"
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"
)
REFERER = "https://music.91q.com/player"
API_BASE = "https://music.91q.com"
# 上游音质档位顺序(3000 = flac,其余为 kbps)
RATE_ORDER = ["3000", "320", "128", "64"]
# URLSearchParams 不编码的字符集。与 Python quote 的默认安全字符不同:
# URLSearchParams 保留 `*` 却编码 `~`,差一个字符服务端就验签失败,故自行实现。
_FORM_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._")
# rateFileInfo 档位 → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
_RATE_LABEL = {"128": "128k", "320": "320k", "3000": "flac"}
# 上游 sanitizeQianqianAlbumKeyword 的特殊字符集
_ALBUM_KEYWORD_SPECIALS = re.compile(r"""[::"'“”‘’()()\[\]【】,,/\\\-.]""")
def to_number(value: Any) -> float:
"""JS `Number()` 语义:None(undefined) 与不可解析 → NaN,bool → 1/0,空串 → 0。"""
if value is None:
return math.nan
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return 0.0
try:
return float(text)
except ValueError:
return math.nan
def num_or_zero(value: Any) -> float:
"""`Number(value ?? 0)` 语义:缺失(null/undefined)视作 0,不可解析才是 NaN。"""
return 0.0 if value is None else to_number(value)
def to_int(value: Any, fallback: int = 0) -> int:
"""`Number(value) || fallback` 语义:0 / NaN / 不可解析都落到 fallback。"""
number = to_number(value)
if not number or math.isnan(number):
return fallback
return int(number)
def form_encode(value: Any) -> str:
"""`new URLSearchParams(...).toString()` 的逐字节等价实现(空格写 `+`,其余按 UTF-8 编码)。"""
out: list[str] = []
for byte in str(value).encode("utf-8"):
char = chr(byte)
if char in _FORM_SAFE:
out.append(char)
elif char == " ":
out.append("+")
else:
out.append(f"%{byte:02X}")
return "".join(out)
def get_qian_cookie() -> str:
"""千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致)。"""
return get_cookie("bd")
def request_headers() -> dict[str, str]:
headers = {
"User-Agent": USER_AGENT,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
cookie = get_qian_cookie()
if cookie:
headers["Cookie"] = cookie
return headers
def sign_params(params: dict[str, Any]) -> dict[str, Any]:
"""写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5。"""
signed: dict[str, Any] = {str(key): value for key, value in params.items()}
signed["timestamp"] = str(int(time.time()))
payload = "&".join(f"{key}={signed[key]}" for key in sorted(signed)) + SECRET
signed["sign"] = hashlib.md5(payload.encode("utf-8")).hexdigest()
return signed
def signed_url(path: str, params: dict[str, Any]) -> str:
"""签名后的完整 URL:签名用原值,query 用 URLSearchParams 等价编码。"""
query = "&".join(f"{key}={form_encode(value)}" for key, value in sign_params(params).items())
return f"{API_BASE}{path}?{query}"
async def api_get(path: str, params: dict[str, Any]) -> Any:
"""发起带签名的 GET,返回响应 JSON body(非 200 按原文案抛错)。"""
resp = await http_fetch(signed_url(path, params), method="get", headers=request_headers(), timeout=15000)
if resp.status_code != 200:
raise RuntimeError(f"千千音乐接口失败(HTTP {resp.status_code})")
return resp.body if resp.body is not None else {}
def api_ok(body: Any) -> bool:
"""上游状态判定:state=true 或 errno=22000 视为成功。"""
if not isinstance(body, dict):
return False
return body.get("state") is True or to_number(body.get("errno")) == 22000
def error_text(body: Any, prefix: str) -> str:
"""上游错误文案:errmsg 优先,缺失时拼 errno(两种文案都逐字沿用原实现)。"""
if not isinstance(body, dict):
return prefix
message = str(body.get("errmsg") or "").strip()
if message:
return message
return f"{prefix}(errno {body.get('errno')})"
async def track_link(tsid: str, rate: str) -> Any:
"""tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path)。"""
return await api_get("/v1/song/tracklink", {"TSID": tsid, "appid": APP_ID, "rate": rate})
def interval_of(duration: Any) -> str | int:
"""时间格式 mm:ss(duration 为秒;无效或 <=0 显示 0)。"""
seconds = to_number(duration)
if math.isnan(seconds) or seconds <= 0:
return 0
return f"{int(seconds // 60):02d}:{int(seconds % 60):02d}"
def join_artists(artists: Any) -> str:
"""千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接)。"""
if not isinstance(artists, list) or not artists:
return ""
names: list[str] = []
seen: set[str] = set()
def add(name: Any) -> None:
text = "" if name is None else str(name).strip()
if not text or text in seen:
return
seen.add(text)
names.append(text)
for item in artists:
if isinstance(item, dict) and to_number(item.get("artistType")) == 38:
add(item.get("name"))
if not names:
for item in artists:
if isinstance(item, dict):
add(item.get("name"))
return "、".join(names)
def normalize_album_asset_code(id: Any) -> str:
"""上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P。"""
text = ("" if id is None else str(id)).strip()
if len(text) < 2:
return ""
return f"P{text[1:]}" if text[0] in ("p", "P") else ""
def sanitize_album_keyword(keyword: Any) -> str:
"""上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(23001 时重试用)。"""
trimmed = ("" if keyword is None else str(keyword)).strip()
if not trimmed:
return ""
return " ".join(part for part in _ALBUM_KEYWORD_SPECIALS.sub(" ", trimmed).split() if part)
def first_non_empty(*values: Any) -> str:
for value in values:
text = "" if value is None else str(value).strip()
if text:
return text
return ""
def release_date10(raw: Any) -> str:
"""上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD)。"""
text = ("" if raw is None else str(raw)).strip()
return text[:10] if len(text) >= 10 else text
def rate_stats(rate_file_info: Any, duration: float) -> dict[str, int]:
"""上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps)。"""
for rate in RATE_ORDER:
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
if not isinstance(info, dict):
continue
size = to_number(info.get("size"))
if not size > 0:
continue
if duration > 0:
return {"size": int(size), "bitrate": int(size * 8 / 1000 / duration)}
return {"size": int(size), "bitrate": 800 if rate == "3000" else int(rate)}
return {"size": 0, "bitrate": 0}
def rate_types(rate_file_info: Any) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""rateFileInfo → 既有 Quality 桶的 types / _types。"""
types: list[dict[str, Any]] = []
type_map: dict[str, dict[str, Any]] = {}
for rate in ("128", "320", "3000"):
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
if not isinstance(info, dict):
continue
size = to_number(info.get("size"))
if not size > 0:
continue
label = _RATE_LABEL[rate]
size_text = size_formate(int(size))
types.append({"type": label, "size": size_text})
type_map[label] = {"size": size_text}
return types, type_map
def map_track(
item: Any,
*,
album_id: str = "",
album_name: str = "",
img: str | None = None,
singer: str = "",
) -> dict[str, Any]:
"""千千曲目字段 → MusicServer 统一 Song 形状。
搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
歌单详情用 TSID + albumTitle + pic。
注:上游 Search 会跳过 isVip != 0 的曲目;本实现保留它们并标记 payPlay,
取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
"""
source = item if isinstance(item, dict) else {}
duration = num_or_zero(source.get("duration"))
stats = rate_stats(source.get("rateFileInfo"), duration)
types, type_map = rate_types(source.get("rateFileInfo"))
return {
"songmid": first_non_empty(source.get("TSID"), source.get("assetId"), source.get("tsid")),
"name": str(source.get("title") or "").strip(),
"singer": first_non_empty(join_artists(source.get("artist")), singer),
"albumId": first_non_empty(normalize_album_asset_code(source.get("albumAssetCode")), album_id),
"albumName": first_non_empty(source.get("albumTitle"), album_name),
"interval": interval_of(duration),
"size": stats["size"] or 0,
"bitrate": stats["bitrate"] or 0,
"payPlay": num_or_zero(source.get("isVip")) != 0 or num_or_zero(source.get("isPaid")) != 0,
"img": first_non_empty(source.get("pic"), img) or None,
"lrc": None,
"lrcx": "",
"types": types,
"_types": type_map,
"source": "bd",
}
def song_link(tsid: str) -> str:
return f"https://music.91q.com/song/{tsid}"
def album_link(id: str) -> str:
return f"https://music.91q.com/album/{id}"
def playlist_link(id: str) -> str:
return f"https://music.91q.com/songlist/{id}"
+57
View File
@@ -0,0 +1,57 @@
"""哔哩哔哩(bili)平台模块(对应 sdk/bili/index.ts)。
上游 music-lib/bilibili 的移植,能力位:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单
(合集 ugc_season + 多分P稿件)/ 链接解析(BV/av、分P)/ 封面 / 扫码登录。
不实现(上游即未实现):专辑、歌词(空实现)、榜单、热搜、歌手、评论、个人歌单。
取流入口在 ``app/sdk/apis/bili.py``,songmid 约定 ``<bvid>|<cid>``。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bili import lyric, music_search, parse, pic, playlist
from app.sdk.platforms.bili.util import parse_song_id
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:apis 模块反向依赖本包的 util,顶层导入不必要地增加耦合。"""
from app.sdk.apis.bili import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str:
return await pic.get_pic(song_info)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return "https://www.bilibili.com/"
try:
page = int(song_info.get("page") or 0)
except (TypeError, ValueError):
page = 0
return f"https://www.bilibili.com/video/{bvid}{f'?p={page}' if page > 1 else ''}"
MODULE = Platform(
id="bili",
name="哔哩哔哩",
music_search=music_search,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+14
View File
@@ -0,0 +1,14 @@
"""Bilibili 歌词(对应 sdk/bili/lyric.ts)。
上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串。
B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
返回统一结构 ``{"lyric": "", "tlyric": ""}``,让 /lyric/bili 有响应而不是 404。
"""
from __future__ import annotations
from typing import Any
async def get_lyric(_song_info: dict[str, Any] | None = None) -> dict[str, str]:
return {"lyric": "", "tlyric": ""}
+83
View File
@@ -0,0 +1,83 @@
"""Bilibili 搜索(对应 sdk/bili/musicSearch.ts,上游 bilibili/song.go 的 ``Search``)。
接口: x/web-interface/search/type?search_type=video
上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),
本移植保持一致,只是把逐个串行改成 4 并发;单条 view 失败按上游语义跳过该条。
实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;
风控时返回 -412/-352,会抛错由路由兜底。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
clean_title,
encode_uri_component,
fetch_view,
make_song,
map_limit,
to_number,
)
SEARCH_URL = "https://api.bilibili.com/x/web-interface/search/type"
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,B 站搜索无重试语义
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站搜索失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站搜索失败({body.get('code')}{f': {message}' if message else ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
pages = as_list(view.get("pages"))
if not pages:
return None
first = pages[0] if isinstance(pages[0], dict) else {}
root_title = clean_title(item.get("title"))
display_title = str(first.get("part") or "")
if not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
return make_song(
bvid=str(item["bvid"]),
cid=first.get("cid"),
name=display_title,
artist=str(item.get("author") or owner.get("name") or ""),
album=str(item["bvid"]),
duration=first.get("duration"),
cover=str(item.get("pic") or view.get("pic") or ""),
page=1,
)
songs = await map_limit(items, 4, one)
result = [song for song in songs if song]
total = int(to_number(data.get("numResults"))) or len(result)
num_pages = int(to_number(data.get("numPages")))
return {
"list": result,
"allPage": num_pages or p + (1 if len(result) >= size else 0),
"total": total,
"limit": size,
"source": "bili",
}
+93
View File
@@ -0,0 +1,93 @@
"""Bilibili 链接解析(对应 sdk/bili/parse.ts,上游 bilibili/song.go 的 ``Parse``)。
支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parse_playlist_link。
上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.playlist import parse_playlist_link
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
extract_aid,
extract_bvid,
extract_page,
fetch_page_list,
fetch_view,
make_song,
normalize_cover,
)
async def parse_song(link: str) -> dict[str, Any]:
raw = str(link or "").strip()
bvid = extract_bvid(raw)
aid = extract_aid(raw)
if not bvid and not aid:
raise RuntimeError("无效的B站链接:未找到 BV/av 号")
view = await fetch_view(bvid or aid)
pages = as_list(view.get("pages"))
# 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if view.get("ugc_season") or len(pages) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
if len(pages) <= 1:
try:
page_list = await fetch_page_list(view.get("bvid") or bvid)
if len(page_list) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
except Exception as err:
if "歌单解析" in str(err):
raise
# 分P列表接口失败时按上游语义忽略
if not pages:
raise RuntimeError("未找到视频分P")
page = extract_page(raw)
if page > len(pages):
page = 1
target = pages[page - 1] if isinstance(pages[page - 1], dict) else {}
root_title = str(view.get("title") or "")
display_title = str(target.get("part") or "")
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
cid = str(target.get("cid") if target.get("cid") is not None else "")
song = make_song(
bvid=str(view.get("bvid") or bvid),
cid=cid,
name=display_title,
artist=str(owner.get("name") or ""),
album=str(view.get("bvid") or bvid),
duration=target.get("duration"),
cover=normalize_cover(view.get("pic") or ""),
page=page,
)
# 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if cid:
try:
from app.sdk.apis.bili import get_audio_url
audio = await get_audio_url(str(song["bvid"]), cid)
urls = audio.get("urls") or []
if urls:
song["url"] = urls[0]
song["type"] = audio.get("type")
except Exception: # noqa: BLE001 - 忽略:元数据仍可返回
pass
return song
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单/合集链接解析(上游 bilibili/playlist.go 的 ``ParsePlaylist``)。"""
return await parse_playlist_link(link)
+21
View File
@@ -0,0 +1,21 @@
"""Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import fetch_view, normalize_cover, parse_song_id
async def get_pic(song_info: dict[str, Any]) -> str:
direct = str(song_info.get("img") or "")
if direct.startswith(("http://", "https://")):
return direct
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return ""
try:
view = await fetch_view(bvid)
return normalize_cover(view.get("pic") or "")
except Exception: # noqa: BLE001 - 与原文一致:封面取不到返回空串,不影响调用方
return ""
+318
View File
@@ -0,0 +1,318 @@
"""Bilibili 歌单(对应 sdk/bili/playlist.ts,上游 bilibili/playlist.go)。
B 站的「歌单」= 合集 ugc_season / 多分P稿件:
* search_playlist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
* get_playlist_detail / parse_playlist_link: id 约定 ``season:<season_id>:<mid>:<bvid>`` 或 ``bvid:<bvid>``;
* get_categories / get_category_playlists: 上游显式返回 ErrPlaylistCategoriesUnsupported,保持不支持(抛错)。
注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bili.music_search import SEARCH_URL
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
build_songs_from_pages,
build_songs_from_season_sections,
clean_title,
encode_uri_component,
extract_aid,
extract_bvid,
fetch_page_list,
fetch_season_archive_index,
fetch_season_songs,
fetch_view,
map_limit,
normalize_cover,
to_number,
)
def _count_season_episodes(sections: list[Any]) -> int:
count = 0
for sec in sections or []:
if isinstance(sec, dict):
episodes = sec.get("episodes")
count += len(episodes) if isinstance(episodes, list) else 0
return count
def _season_key(season_id: Any, mid: Any, bvid: str) -> str:
return f"season:{season_id}:{mid}:{bvid}"
def _bvid_key(bvid: str) -> str:
return f"bvid:{bvid}"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力)。"""
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站歌单搜索失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站歌单搜索失败({body.get('code')}{f': {message}' if message else ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
seen: set[str] = set()
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
cover = normalize_cover(item.get("pic") or view.get("pic") or "")
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count and season.get("id") and owner.get("mid"):
try:
track_count = len(await fetch_season_songs(owner.get("mid"), season.get("id")))
except Exception: # noqa: BLE001 - 上游 fetchSeasonSongs 失败同样保持 trackCount 0
pass
stat = as_dict(season.get("stat"))
return {
"id": playlist_id,
"name": str(season.get("title") or clean_title(item.get("title"))),
"cover": normalize_cover(season.get("cover")) or cover,
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
"creator": str(owner.get("name") or item.get("author") or ""),
"description": str(season.get("intro") or ""),
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
pages = as_list(view.get("pages"))
if len(pages) > 1:
playlist_id = _bvid_key(str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
return {
"id": playlist_id,
"name": clean_title(item.get("title")),
"cover": cover,
"trackCount": len(pages),
"creator": str(owner.get("name") or item.get("author") or ""),
"description": "",
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
return None
result = await map_limit(items, 4, one)
result_list = [item for item in result if item]
return {"list": result_list, "page": p, "limit": size, "total": len(result_list), "source": "bili"}
async def _detail_from_bvid(bvid: str) -> dict[str, Any]:
"""由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据)。"""
view = await fetch_view(bvid)
real_bvid = str(view.get("bvid") or bvid)
pages = as_list(view.get("pages"))
if len(pages) <= 1:
try:
page_list = await fetch_page_list(real_bvid)
except Exception: # noqa: BLE001 - 分P列表接口失败按上游语义忽略
page_list = []
if page_list:
pages = page_list
if not pages:
raise RuntimeError("未找到视频分P")
owner = as_dict(view.get("owner"))
songs = build_songs_from_pages(
real_bvid, str(view.get("title") or ""), str(owner.get("name") or ""), str(view.get("pic") or ""), pages
)
return {
"info": {
"name": str(view.get("title") or real_bvid),
"img": normalize_cover(view.get("pic") or ""),
"desc": str(view.get("desc") or ""),
"author": str(owner.get("name") or ""),
},
"songs": songs,
}
async def _detail_from_season(season_id: str, mid: str, bvid: str) -> dict[str, Any]:
"""由 ``season:<season_id>:<mid>:<bvid>`` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支)。"""
info: dict[str, Any] = {"name": "", "img": "", "desc": "", "author": ""}
songs: list[dict[str, Any]] = []
if bvid:
view = await fetch_view(bvid)
season = as_dict(view.get("ugc_season")) or None
if season:
archive_index: dict[str, dict[str, Any]] = {}
season_title = str(season.get("title") or "")
season_cover = str(season.get("cover") or "")
# 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
archive_index = idx["index"]
season_title = season_title or idx["title"]
season_cover = season_cover or idx["cover"]
owner = as_dict(view.get("owner"))
songs = build_songs_from_season_sections(
as_list(season.get("sections")),
season_title,
season_cover,
str(owner.get("name") or ""),
archive_index,
)
info = {
"name": season_title or str(view.get("title") or ""),
"img": normalize_cover(season_cover or view.get("pic") or ""),
"desc": str(season.get("intro") or ""),
"author": str(owner.get("name") or ""),
}
if not songs:
songs = await fetch_season_songs(mid, season_id)
if not info["name"]:
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
info = {**info, "name": idx["title"] or "", "img": normalize_cover(idx["cover"] or "")}
if not songs:
raise RuntimeError("合集为空或合集接口不可用")
return {"info": info, "songs": songs}
async def _resolve_id(playlist_id: str) -> dict[str, Any]:
"""解析 id(兼容链接/裸 BV/av/bvid: 前缀)。"""
raw = str(playlist_id or "").strip()
if re.match(r"^https?:", raw, re.IGNORECASE):
return {"kind": "link", "value": raw}
if re.match(r"^season:", raw, re.IGNORECASE):
return {"kind": "season", "value": raw, "parts": raw.split(":")}
# 注意先去 `bvid:` 前缀再提 BV:大小写不敏感的 BV 匹配会把前缀 "bvid" 本身当成 BV 号
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if bvid:
return {"kind": "bvid", "value": bvid}
aid = extract_aid(raw)
if aid:
view = await fetch_view(aid)
real_bvid = str(view.get("bvid") or "")
if not real_bvid:
raise RuntimeError("未找到视频 BV 号")
return {"kind": "bvid", "value": real_bvid}
raise RuntimeError("无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)")
def _page_slice(detail: dict[str, Any], page: Any, limit: Any) -> dict[str, Any]:
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 100), 10000)
songs = detail["songs"]
return {
"source": "bili",
"info": detail["info"],
"list": songs[(p - 1) * size : p * size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(契约键 playlist.get_playlist_detail)。"""
target = await _resolve_id(playlist_id)
if target["kind"] == "link":
return await parse_playlist_link(target["value"])
if target["kind"] == "season":
parts = target.get("parts") or []
if len(parts) < 3:
raise RuntimeError("无效的合集 id")
bvid = parts[3] if len(parts) > 3 else ""
return _page_slice(await _detail_from_season(parts[1], parts[2], bvid), page, limit)
return _page_slice(await _detail_from_bvid(target["value"]), page, limit)
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs 的等价导出(只返回歌曲数组)。"""
detail = await get_playlist_detail(playlist_id, 1, 10000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量)。"""
raw = str(link or "").strip()
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if not bvid:
aid = extract_aid(raw)
if not aid:
raise RuntimeError("无效的B站歌单链接:未找到 BV/av 号")
view = await fetch_view(aid)
return await _parse_playlist_view(str(view.get("bvid") or ""), view)
view = await fetch_view(bvid)
return await _parse_playlist_view(bvid, view)
async def _parse_playlist_view(bvid: str, view: dict[str, Any]) -> dict[str, Any]:
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), bvid)
detail = await _detail_from_season(
str(season.get("id")), str(owner.get("mid") if owner.get("mid") is not None else ""), bvid
)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count:
track_count = len(detail["songs"])
stat = as_dict(season.get("stat"))
return {
"source": "bili",
"id": playlist_id,
"info": {
**detail["info"],
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
detail = await _detail_from_bvid(bvid)
return {
"source": "bili",
"id": _bvid_key(bvid),
"info": {**detail["info"], "trackCount": len(detail["songs"])},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
+425
View File
@@ -0,0 +1,425 @@
"""Bilibili 公共请求助手(对应 sdk/bili/util.ts,上游 music-lib/bilibili 的共享逻辑)。
与上游的差异(2026-10-02 实测):
1) 上游硬编码的默认 cookie(buvid3=...; SESSDATA=your_sessdata;)当前会被 B 站风控:
带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
2) 请求层不跟随 302,取流的手动跟跳在 ``app/sdk/apis/bili.py`` 内处理。
3) 登录凭据来自 ``app/sdk/auth_store.py`` 的 bili 记录(扫码登录写入)。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"
)
REFERER = "https://www.bilibili.com/"
HOME_URL = "https://www.bilibili.com/"
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def bili_headers(cookie: str = "") -> dict[str, str]:
"""上游 UserAgent/Referer 组合(所有接口统一携带)。"""
headers = {
"User-Agent": UA,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
if cookie:
headers["Cookie"] = cookie
return headers
def get_login_cookie() -> str:
"""已保存的登录 Cookie(未登录返回空串)。"""
cred = get_credentials("bili") or {}
return str(cred.get("cookie") or "")
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
async def _fetch_home_cookies() -> str:
"""首页下发的新鲜 Cookie(http_fetch 保留了原始多值 Set-Cookie,同名头不会互相覆盖)。"""
resp = await http_fetch(HOME_URL, headers=bili_headers(), timeout=15_000)
jar = _cookie_map(resp.set_cookies)
return "; ".join(f"{key}={value}" for key, value in jar.items())
_anon_cookie = ""
_anon_lock = asyncio.Lock()
async def get_cookie() -> str:
"""请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘)。"""
saved = get_login_cookie()
if saved:
return saved
global _anon_cookie
if _anon_cookie:
return _anon_cookie
async with _anon_lock:
if _anon_cookie:
return _anon_cookie
try:
_anon_cookie = await _fetch_home_cookies()
except Exception: # noqa: BLE001 - 拿不到就用无 Cookie 请求(实测同样可用)
_anon_cookie = ""
return _anon_cookie
async def api_get(url: str, cookie: str | None = None) -> HttpResponse:
"""带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名)。"""
ck = await get_cookie() if cookie is None else cookie
return await http_fetch(url, headers=bili_headers(ck), timeout=15_000)
def normalize_cover(cover: Any) -> str:
"""封面协议补全(上游 normalizeCover)。"""
text = str(cover or "")
if not text:
return ""
return f"https:{text}" if text.startswith("//") else text
def clean_title(title: Any) -> str:
"""搜索标题去高亮标签(上游 cleanTitle)。"""
if not title:
return ""
return re.sub(r'<em class="keyword">|</em>', "", str(title))
def parse_song_id(songmid: Any) -> dict[str, str]:
"""songmid 约定:``<bvid>|<cid>``(与上游 ID 格式一致)。"""
raw = str(songmid if songmid is not None else "")
idx = raw.find("|")
if idx < 0:
return {"bvid": raw, "cid": ""}
return {"bvid": raw[:idx], "cid": raw[idx + 1 :]}
def extract_bvid(link: str) -> str:
"""从链接/id 提取 BV 号(兼容小写 bv)。"""
match = re.search(r"BV[0-9A-Za-z]+", str(link or ""), re.IGNORECASE)
return match.group(0) if match else ""
def extract_page(link: str) -> int:
"""从链接提取分P(上游 ``[?&]p=(\\d+)``)。"""
match = re.search(r"[?&]p=(\d+)", str(link or ""))
page = int(match.group(1)) if match else 0
return page if page > 0 else 1
def extract_aid(link: str) -> str:
"""从链接提取 av 号(上游未支持,任务要求兼容)。"""
match = re.search(r"\bav(\d+)\b", str(link or ""), re.IGNORECASE)
return match.group(1) if match else ""
def to_number(value: Any) -> float:
"""JS 的 Number(value):空值/非数字一律 0。"""
try:
return float(value)
except (TypeError, ValueError):
return 0.0
async def map_limit(items: list[Any], limit: int, fn: Any) -> list[Any]:
"""并发映射(限制并发数,单条失败返回 None)。"""
source = list(items or [])
out: list[Any] = [None] * len(source)
if not source:
return out
size = max(1, min(int(limit or 1), len(source)))
cursor = 0
async def worker() -> None:
nonlocal cursor
while True:
index = cursor
cursor += 1
if index >= len(source):
return
try:
out[index] = await fn(source[index], index)
except Exception: # noqa: BLE001 - 单条失败按上游语义跳过
out[index] = None
await asyncio.gather(*(worker() for _ in range(size)))
return out
def make_song(
*,
bvid: str,
cid: Any,
name: str = "",
artist: str = "",
album: str = "",
duration: Any = 0,
cover: str = "",
page: int = 1,
) -> dict[str, Any]:
"""统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid)。"""
seconds = to_number(duration)
link = f"https://www.bilibili.com/video/{bvid}" + (f"?p={page}" if page > 1 else "")
return {
"songmid": f"{bvid}|{cid}",
"name": name or "",
"singer": artist or "",
"albumId": bvid or "",
"albumName": album or bvid or "",
"interval": format_play_time(seconds) if seconds else 0,
"img": normalize_cover(cover) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "bili",
"bvid": bvid,
"cid": str(cid if cid is not None else ""),
"page": page,
"link": link,
}
def assert_api_ok(what: str, status_code: int, body: Any) -> None:
"""B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误。"""
if status_code != 200 or not isinstance(body, dict) or body.get("code") != 0:
code = body.get("code") if isinstance(body, dict) else None
message = body.get("message") if isinstance(body, dict) else None
reason = code if code is not None else f"HTTP {status_code}"
raise RuntimeError(f"B站{what}获取失败({reason}{f': {message}' if message else ''})")
async def fetch_view(video_id: Any) -> dict[str, Any]:
"""视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点。"""
raw = str(video_id or "").strip()
if not raw:
raise RuntimeError("B站视频 id 为空")
if re.match(r"^BV", raw, re.IGNORECASE):
query = f"bvid={encode_uri_component(raw)}"
else:
query = f"aid={encode_uri_component(re.sub('^av', '', raw, flags=re.IGNORECASE))}"
resp = await api_get(f"https://api.bilibili.com/x/web-interface/view?{query}")
assert_api_ok("视频信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
if not isinstance(data, dict):
raise RuntimeError("B站视频信息获取失败(data 为空)")
return data
async def fetch_page_list(bvid: str) -> list[Any]:
"""分P列表(x/player/pagelist),返回 data 数组。"""
resp = await api_get(f"https://api.bilibili.com/x/player/pagelist?bvid={encode_uri_component(bvid)}")
assert_api_ok("分P列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
return data if isinstance(data, list) else []
def _season_page_url(mid: Any, season_id: Any, page_num: int, page_size: int) -> str:
return (
"https://api.bilibili.com/x/space/ugc/season?"
f"mid={mid}&season_id={season_id}&page_num={page_num}&page_size={page_size}"
)
async def fetch_season_archive_index(mid: Any, season_id: Any) -> dict[str, Any]:
"""合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex)。
该接口现已 404,调用方需容错;返回 ``{"index", "title", "cover"}``,
index 以 ``bvid:<bvid>`` / ``cid:<cid>`` 为键。
"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
index: dict[str, dict[str, Any]] = {}
season_title = ""
season_cover = ""
processed = 0
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict):
continue
meta = {
"title": str(arc.get("title") or ""),
"cover": normalize_cover(arc.get("cover") or ""),
"duration": to_number(arc.get("duration")),
}
if arc.get("bvid"):
index[f"bvid:{arc['bvid']}"] = meta
if arc.get("cid"):
index[f"cid:{arc['cid']}"] = meta
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return {"index": index, "title": season_title, "cover": season_cover}
def build_songs_from_pages(
bvid: str, root_title: str, author: str, cover: str, pages: list[Any]
) -> list[dict[str, Any]]:
"""由分P数组构造歌曲(上游 buildSongsFromPages)。"""
songs: list[dict[str, Any]] = []
for i, page in enumerate(pages or []):
part = str(page.get("part") or "") if isinstance(page, dict) else ""
display_title = part
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
songs.append(
make_song(
bvid=bvid,
cid=page.get("cid") if isinstance(page, dict) else None,
name=display_title,
artist=author,
album=bvid,
duration=page.get("duration") if isinstance(page, dict) else 0,
cover=cover,
page=i + 1,
)
)
return songs
def build_songs_from_season_sections(
sections: list[Any],
season_title: str,
season_cover: str,
artist_name: str,
archive_index: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections)。"""
index = archive_index or {}
songs: list[dict[str, Any]] = []
artist = artist_name or season_title
for sec in sections or []:
if not isinstance(sec, dict):
continue
for ep in sec.get("episodes") or []:
if not isinstance(ep, dict):
continue
cover = str(ep.get("cover") or "")
meta = index.get(f"bvid:{ep.get('bvid')}") or (index.get(f"cid:{ep.get('cid')}") if ep.get("cid") else None)
if not cover:
arc = as_dict(ep.get("arc"))
cover = str(arc.get("pic") or "")
if not cover:
cover = season_cover or (meta or {}).get("cover", "")
duration = to_number(ep.get("duration"))
if not duration:
page = as_dict(ep.get("page"))
duration = to_number(page.get("duration"))
if not duration:
arc = as_dict(ep.get("arc"))
duration = to_number(arc.get("duration"))
if not duration:
duration = to_number((meta or {}).get("duration"))
page = as_dict(ep.get("page"))
arc = as_dict(ep.get("arc"))
name = str(ep.get("title") or arc.get("title") or page.get("part") or (meta or {}).get("title") or "")
songs.append(
make_song(
bvid=str(ep.get("bvid") or ""),
cid=ep.get("cid"),
name=name,
artist=artist,
album=str(ep.get("bvid") or ""),
duration=duration,
cover=cover,
page=1,
)
)
return songs
async def fetch_season_songs(mid: Any, season_id: Any) -> list[dict[str, Any]]:
"""合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错)。"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
all_songs: list[dict[str, Any]] = []
processed = 0
season_title = ""
season_cover = ""
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict) or not arc.get("cid"):
continue
all_songs.append(
make_song(
bvid=str(arc.get("bvid") or ""),
cid=arc.get("cid"),
name=str(arc.get("title") or ""),
artist="",
album=season_title or str(arc.get("bvid") or ""),
duration=arc.get("duration"),
cover=normalize_cover(arc.get("cover") or season_cover),
page=1,
)
)
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return all_songs
+68
View File
@@ -0,0 +1,68 @@
"""5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块(对应 sdk/5sing/index.ts)。
上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
榜单/热搜/歌手/评论(上游均无)。
取流入口在 ``app/sdk/apis/fivesing.py``;songmid 约定 ``<songId>|<songType>``(如 "932557|yc")。
登录:上游 New(cookie) 的 cookie 会随请求发送;本移植从 auth_store 的 5sing 记录读取(无则匿名)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.fivesing import lyric, music_search, parse, playlist
from app.sdk.platforms.fivesing.util import build_song_link, extract_song_id, get_status
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现,避免 apis → 本包 util 的初始化顺序耦合。"""
from app.sdk.apis.fivesing import get_music_url
return await get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
ids = extract_song_id(song_info.get("songmid") or song_info.get("id"), song_info.get("songType"))
if not ids["songID"] or not ids["songType"]:
return "http://5sing.kugou.com/"
return build_song_link(ids["songType"], ids["songID"])
class _UserPlaylist:
"""上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义。"""
@staticmethod
async def get_list(_page: int = 1, _limit: int = 20) -> dict[str, Any]:
raise RuntimeError("user playlists not supported")
user_playlist = _UserPlaylist()
def status() -> dict[str, Any]:
return get_status()
MODULE = Platform(
id="5sing",
name="5sing",
music_search=music_search,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_music_detail_page_url=get_music_detail_page_url,
)
+41
View File
@@ -0,0 +1,41 @@
"""5sing 歌词(对应 sdk/5sing/lyric.ts,上游 fivesing/lyric.go 的 GetLyrics)。
接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
→ data.dynamicWords 即 LRC 文本。
上游 ID 约定:model.Song.ID = "<songid>|<songtype>",从 songmid 的 "|" 拆分
(路由层只透传 songmid),也接受 songInfo.songType 字段。
统一接口:返回 ``{lyric, tlyric: ''}``(5sing 无翻译歌词)。
实况(2026-10-23 匿名实测):newget 响应不再包含 dynamicWords(搜索前 10 条与多个关键词均如此,
TS 版同样结果),因此此刻匿名取歌词会如实抛 "lyrics not found";绑定 cookie 后是否有变化尚未验证。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_dynamic_lyric
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
async def get_lyric_text(song_id: str, song_type: str) -> str:
"""直接返回歌词文本(内部/测试用)。"""
lyric = await fetch_dynamic_lyric(song_id, song_type)
if not lyric:
raise RuntimeError("lyrics not found")
return lyric
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"]:
raise RuntimeError("invalid id")
lyric = await fetch_dynamic_lyric(ids["songID"], ids["songType"])
if not lyric:
raise RuntimeError("lyrics not found")
return {"lyric": lyric, "tlyric": ""}
+118
View File
@@ -0,0 +1,118 @@
"""5sing 歌曲信息 / 取流底层(对应 sdk/5sing/musicInfo.ts)。
链路(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink):
取流: 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 备用地址;
上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
get_first_valid,
kind_name,
)
QUALITY_TIERS = ("sq", "hq", "lq")
def _tier_url(data: dict[str, Any], tier: str) -> str:
"""data 里某档位的可用地址(主地址 → backup)。"""
return get_first_valid(data.get(f"{tier}url"), data.get(f"{tier}url_backup"))
async def fetch_audio_link(song_id: str, song_type: str, prefer_low: bool = False) -> dict[str, str]:
"""上游 fetchAudioLink:仅获取音频链接。"""
url = (
f"{MOBILE_API}/song/getSongUrl?songid={encode_uri_component(song_id)}"
f"&songtype={encode_uri_component(song_type)}"
)
resp = await fetch_5sing_json(url)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 取流失败(HTTP {resp.status_code})")
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
if code != 1000:
# 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
message = body.get("message")
raise RuntimeError(f"5sing 取流失败({message})" if message else "api returned error code")
data = as_dict(body.get("data"))
order = tuple(reversed(QUALITY_TIERS)) if prefer_low else QUALITY_TIERS
for tier in order:
hit = _tier_url(data, tier)
if hit and hit.startswith(("http://", "https://")):
return {"type": tier, "url": hit}
raise RuntimeError("no valid download url found")
async def fetch_new_get(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象)。"""
url = f"{MOBILE_API}/song/newget?songid={encode_uri_component(song_id)}&songtype={encode_uri_component(song_type)}"
resp = await fetch_5sing_json(url)
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 歌曲信息获取失败(HTTP {resp.status_code})")
data = resp.body.get("data")
return data if isinstance(data, dict) else {}
async def fetch_dynamic_lyric(song_id: str, song_type: str) -> str:
"""上游 newget 歌词:data.dynamicWords(LRC 文本)。"""
data = await fetch_new_get(song_id, song_type)
return str(data.get("dynamicWords") or "")
async def fetch_song_info(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。"""
hit = await fetch_audio_link(song_id, song_type)
name = ""
artist = ""
cover = ""
try:
data = await fetch_new_get(song_id, song_type)
name = str(data.get("SN") or "")
user = as_dict(data.get("user"))
artist = str(user.get("NN") or "")
cover = str(user.get("I") or "")
except Exception: # noqa: BLE001 - 上游 newget 失败时继续(不阻断)
pass
if not name:
name = f"5sing_{song_type}_{song_id}"
return {
"songmid": f"{song_id}|{song_type}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": cover or None,
"lrc": None,
"types": [],
"_types": {},
"url": hit["url"],
"type": hit["type"],
"link": build_song_link(song_type, song_id),
"songType": song_type,
"typeName": kind_name(song_type),
"source": "5sing",
}
def song_interval(song_size: Any) -> int:
"""时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式)。"""
return estimate_duration(song_size)
@@ -0,0 +1,73 @@
"""5sing 歌曲搜索(对应 sdk/5sing/musicSearch.ts,上游 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;
本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import (
SEARCH_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
def map_search_song(item: dict[str, Any]) -> dict[str, Any]:
"""上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing')。"""
song_type = str(item.get("typeEname") or "")
duration = estimate_duration(item.get("songSize"))
return {
"songmid": f"{item.get('songId', '')}|{song_type}",
"name": remove_em_tags(unescape_html(item.get("songName"))),
"singer": remove_em_tags(unescape_html(item.get("singer") or item.get("nickName"))),
"albumId": "",
"albumName": "",
"interval": format_play_time(duration) if duration > 0 else 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
# 统一种类字段(上游 typeEname / typeName)
"songType": song_type,
"typeName": str(item.get("typeName") or kind_name(song_type)),
"size": int(item.get("songSize") or 0),
"link": build_song_link(song_type, item.get("songId")),
"source": "5sing",
}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "allPage": 0, "total": 0, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=0"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
songs = [map_search_song(item) for item in raw_list[:page_size] if isinstance(item, dict)]
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(songs)
# 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
all_page = int(page_info.get("totalPages") or 0) or (page_no + 1 if len(songs) >= page_size else page_no)
return {"list": songs, "allPage": all_page, "total": total, "limit": page_size, "source": "5sing"}
+25
View File
@@ -0,0 +1,25 @@
"""5sing 链接解析(对应 sdk/5sing/parse.ts)。
歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetch_song_info(直链 + newget 元数据,无直链即报错)
歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId)
专辑: 上游无专辑概念(不实现)
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_song_info
from app.sdk.platforms.fivesing.playlist import parse_playlist_link
from app.sdk.platforms.fivesing.util import extract_song_link
async def parse_song(link: str) -> dict[str, Any]:
parsed = extract_song_link(link)
if not parsed:
raise RuntimeError("invalid 5sing link")
return await fetch_song_info(parsed["songID"], parsed["songType"])
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+282
View File
@@ -0,0 +1,282 @@
"""5sing 歌单(对应 sdk/5sing/playlist.ts,上游 fivesing/fivesing.go + playlist.go)。
搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/...
上游对 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"> 块
③ 数字 userId 页会 302 到个性域名页,fetch_5sing 已手动跟跳。
分类/个人歌单:上游显式返回不支持,本移植保留同样语义(抛错)。
链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名与省略 user 段,ID 以 API 反查为准。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
SEARCH_API,
build_playlist_link,
build_song_link,
encode_uri_component,
extract_playlist_link,
fetch_5sing,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:显式不支持。"""
raise RuntimeError("playlist categories not supported")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:显式不支持。"""
raise RuntimeError("playlist categories not supported")
def _map_playlist_song(song_id: str, kind: str, name: str, artist: str) -> dict[str, Any]:
"""歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状。"""
return {
"songmid": f"{song_id}|{kind}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
"songType": kind,
"typeName": kind_name(kind),
"link": build_song_link(kind, song_id),
"source": "5sing",
}
def parse_songs_from_html(html_content: str) -> list[dict[str, Any]]:
"""上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
块正则 ``<li class="p_rel">([\\s\\S]*?)</li>``;块内 href 提取 yc/fc/bz 与 id,
s_soner 提取歌手(上游的拼写错误 soner 保留)。
"""
blocks = re.findall(r'<li class="p_rel">([\s\S]*?)</li>', str(html_content or ""))
if not blocks:
raise RuntimeError("no songs found in playlist html (structure mismatch)")
# 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
song_re = re.compile(r'href="(?:(?:https?:)?//)?5sing\.kugou\.com/(yc|fc|bz)/(\d+)\.html"[^>]*>([^<]+)</a>')
artist_re = re.compile(r'class="s_soner[^"]*".*?>([^<]+)</a>')
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for block in blocks:
song_match = song_re.search(block)
if not song_match:
continue
kind, song_id, raw_name = song_match.group(1), song_match.group(2), song_match.group(3)
artist = "Unknown"
artist_match = artist_re.search(block)
if artist_match:
artist = artist_match.group(1)
unique_key = f"{kind}|{song_id}"
if unique_key in seen:
continue
seen.add(unique_key)
name = remove_em_tags(unescape_html(raw_name)).strip()
artist = remove_em_tags(unescape_html(artist)).strip()
songs.append(_map_playlist_song(song_id, kind, name, artist))
return songs
async def fetch_creator_name(playlist_id: str) -> str:
"""上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名。"""
if not playlist_id:
return ""
try:
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=user"
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return ""
data = resp.body.get("data")
if not isinstance(data, dict):
return ""
user = as_dict(data.get("user"))
return str(user.get("NN") or "")
except Exception: # noqa: BLE001 - 反查失败保留 ID 兜底
return ""
def _map_playlist_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐)。"""
playlist_id = str(item.get("songListId") or "")
user_id = str(item.get("userId") or "")
desc = remove_em_tags(unescape_html(item.get("content")))
creator = str(item.get("userName") or "") or (f"ID: {user_id}" if user_id else "")
return {
"id": playlist_id,
"name": remove_em_tags(unescape_html(item.get("title"))),
"cover": str(item.get("pictureUrl") or ""),
"trackCount": int(item.get("songCnt") or 0),
"playCount": int(item.get("playCount") or 0),
"creator": creator,
"creatorId": user_id,
# 上游怪癖:content 为字符串 "0" 时视为空简介
"description": "" if desc == "0" else desc,
"link": build_playlist_link(user_id, playlist_id),
"source": "5sing",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist(type=1),补创建者名(并发 5)。"""
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "total": 0, "page": page_no, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=1"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 歌单搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
playlists = [_map_playlist_summary(item) for item in raw_list[:page_size] if isinstance(item, dict)]
# 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
targets = [pl for pl in playlists if pl["creatorId"] and pl["creator"] == f"ID: {pl['creatorId']}"]
for start in range(0, len(targets), 5):
chunk = targets[start : start + 5]
async def fill(pl: dict[str, Any]) -> None:
name = await fetch_creator_name(pl["id"])
if name:
pl["creator"] = name
await asyncio.gather(*(fill(pl) for pl in chunk))
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(playlists)
return {"list": playlists, "total": total, "page": page_no, "limit": page_size, "source": "5sing"}
async def fetch_playlist_all(playlist_id_raw: str) -> dict[str, Any]:
"""元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全)。"""
playlist_id = str(playlist_id_raw or "").strip()
if not playlist_id:
raise RuntimeError("invalid 5sing playlist id")
# 1) API 元数据(标题/封面/统计/UserID)
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=ID,user"
)
if resp.status_code != 200:
raise RuntimeError(f"fetch info failed: HTTP {resp.status_code}")
# 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
body = as_dict(resp.body)
data = body.get("data")
if not isinstance(data, dict):
raise RuntimeError("playlist info not found or invalid (api returned empty list)")
user = as_dict(data.get("user"))
user_id = str(user.get("ID") or "")
if not user_id:
raise RuntimeError("playlist user not found or invalid id")
playlist = {
"id": playlist_id,
"name": str(data.get("T") or ""),
"cover": str(data.get("P") or ""),
"trackCount": int(data.get("E") or 0),
"playCount": int(data.get("H") or 0),
"creator": str(user.get("NN") or ""),
"description": str(data.get("C") or ""),
"link": build_playlist_link(user_id, playlist_id),
"userId": user_id,
"source": "5sing",
}
# 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch_5sing 已跟跳)
page = await fetch_5sing(playlist["link"])
if page.status_code != 200:
raise RuntimeError(f"fetch html failed: HTTP {page.status_code}")
# 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
songs: list[dict[str, Any]] = []
try:
songs = parse_songs_from_html(str(page.body if isinstance(page.body, str) else ""))
except Exception: # noqa: BLE001 - 结构不匹配时返回空列表
songs = []
return {"playlist": playlist, "songs": songs}
def _playlist_info(playlist: dict[str, Any]) -> dict[str, Any]:
return {
"id": playlist["id"],
"name": playlist["name"],
"img": playlist["cover"],
"desc": playlist["description"],
"author": playlist["creator"],
"link": playlist["link"],
"trackCount": playlist["trackCount"],
"playCount": playlist["playCount"],
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E)。"""
detail = await fetch_playlist_all(playlist_id)
playlist, songs = detail["playlist"], detail["songs"]
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
start = (page_no - 1) * page_size
total = len(songs)
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs[start : start + page_size],
"page": page_no,
"limit": page_size,
"total": total,
"allPage": math.ceil(total / page_size) or 1,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:仅返回曲目列表(全量)。"""
detail = await fetch_playlist_all(playlist_id)
return detail["songs"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(全量曲目)。"""
parsed = extract_playlist_link(link)
if not parsed:
raise RuntimeError("invalid 5sing playlist link")
detail = await fetch_playlist_all(parsed["playlistId"])
playlist, songs = detail["playlist"], detail["songs"]
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs,
"page": 1,
"limit": len(songs) or 1,
"total": len(songs),
"allPage": 1,
}
+196
View File
@@ -0,0 +1,196 @@
"""5sing 公共工具(对应 sdk/5sing/util.ts,上游 fivesing/fivesing.go 的等价实现)。
平台 id 统一为 ``5sing``(上游 Go 包名 fivesing,model.Source 用 "fivesing";本仓库契约要求 5sing)。
上游 ``New(cookie)`` 的 cookie 实际参与请求(搜索 / getSongUrl / newget / 歌单页),
这里从 auth_store 的 ``5sing`` 记录读取(无则匿名)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
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)
MOBILE_API = "http://mobileapi.5sing.kugou.com"
#: 搜索接口(type=0 单曲 / type=1 歌单)
SEARCH_API = "http://search.5sing.kugou.com/home/json"
#: 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏)
SONG_KINDS: dict[str, str] = {"yc": "原创", "fc": "翻唱", "bz": "伴奏"}
HTML_ENTITIES: dict[str, str] = {
"amp": "&",
"lt": "<",
"gt": ">",
"quot": '"',
"apos": "'",
"nbsp": " ",
"copy": "©",
"reg": "®",
"hellip": "…",
"mdash": "—",
"ndash": "–",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def get_first_valid(*urls: Any) -> str:
"""上游 getFirstValid:返回第一个非空字符串(空串视为无效)。"""
for url in urls:
if url:
return str(url)
return ""
def remove_em_tags(text: Any) -> str:
"""去掉搜索结果里 ``<em class="keyword">`` 高亮标签。"""
return str(text if text is not None else "").replace('<em class="keyword">', "").replace("</em>", "").strip()
def unescape_html(text: Any) -> str:
"""Go html.UnescapeString 的等价实现(常用实体 + 数字实体)。"""
source = str(text if text is not None else "")
def repl(match: re.Match[str]) -> str:
entity = match.group(1)
try:
if entity.startswith(("#x", "#X")):
return chr(int(entity[2:], 16))
if entity.startswith("#"):
return chr(int(entity[1:], 10))
except ValueError:
return match.group(0)
return HTML_ENTITIES.get(entity.lower(), match.group(0))
return re.sub(r"&(#x?[0-9a-fA-F]+|[a-zA-Z]+);", repl, source)
def get_5sing_cookie() -> str:
"""上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用)。"""
cred = get_credentials("5sing") or {}
return str(cred.get("cookie") or "")
def get_status() -> dict[str, Any]:
"""登录态(凭据存在性判断)。"""
cred = get_credentials("5sing") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
def is_foreign_source(song_info: dict[str, Any]) -> bool:
"""是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查)。"""
source = song_info.get("source")
return bool(source) and source != "5sing"
async def fetch_5sing(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""带 Cookie/UA 的请求;httpFetch 不跟随 302,这里手动跟跳最多 3 次。
5sing 老链接会 http→https / 数字 userId → 个性域名跳转,必须跟。
"""
cookie = get_5sing_cookie()
all_headers = {"User-Agent": UserAgent}
if cookie:
all_headers["Cookie"] = cookie
if headers:
all_headers.update({str(key): str(value) for key, value in headers.items()})
current = str(url)
response = None
for _hop in range(4):
response = await http_fetch(current, headers=all_headers, timeout=timeout_ms)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
from urllib.parse import urljoin
try:
current = urljoin(current, location)
except ValueError:
return response
continue
return response
return response
async def fetch_5sing_json(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""JSON 接口带退避重试(手机端 API 频率控制:code 13 / "操作太频繁")。"""
response = None
for attempt in range(3):
response = await fetch_5sing(url, headers=headers, timeout_ms=timeout_ms)
body = response.body if isinstance(response.body, dict) else {}
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
message = str(body.get("message") or "")
if (code == 13 or "太频繁" in message) and attempt < 2:
await asyncio.sleep(0.8 * (attempt + 1))
continue
return response
return response
def build_song_link(song_type: str, song_id: Any) -> str:
"""歌曲页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{song_type}/{song_id}.html" if song_type and song_id else ""
def build_playlist_link(user_id: Any, playlist_id: str) -> str:
"""歌单页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{user_id}/dj/{playlist_id}.html" if user_id and playlist_id else ""
def extract_song_id(songmid: Any, song_type_fallback: Any = None) -> dict[str, str]:
"""解析歌曲的 ``<songid>|<songtype>`` 约定(兼容只给 songType 字段的调用)。"""
raw = str(songmid if songmid is not None else "")
parts = raw.split("|")
id_part = parts[0] if parts else ""
type_part = parts[1] if len(parts) > 1 else ""
return {"songID": id_part or "", "songType": str(type_part or song_type_fallback or "")}
def extract_song_link(link: Any) -> dict[str, str] | None:
"""上游 Parse 的正则:5sing.kugou.com/(\\w+)/(\\d+).html(专辑/歌手页不匹配)。"""
match = re.search(r"5sing\.kugou\.com/(\w+)/(\d+)\.html", str(link if link is not None else ""))
if not match:
return None
return {"songType": match.group(1), "songID": match.group(2)}
def extract_playlist_link(link: Any) -> dict[str, str] | None:
"""歌单链接解析(放宽为任意单段 userId,含个性域名与省略 user 段)。"""
match = re.search(
r"5sing\.kugou\.com/(?:([^/?#]+)/)?dj/([a-zA-Z0-9]+)\.html", str(link if link is not None else "")
)
if not match:
return None
return {"userId": match.group(1) or "", "playlistId": match.group(2)}
def estimate_duration(song_size: Any) -> int:
"""上游 Search 的时长估算:songSize*8/320000(320kbps 假设;0 返回 0)。"""
try:
size = float(song_size or 0)
except (TypeError, ValueError):
size = 0.0
return math.trunc((size * 8) / 320000) if size > 0 else 0
def kind_name(song_type: str) -> str:
"""作品类型中文名(typeName 缺失时兜底)。"""
return SONG_KINDS.get(song_type, "")
+85
View File
@@ -0,0 +1,85 @@
"""JOOX(joox)平台模块(对应 sdk/joox/index.ts)。
匿名可用面(2026-10 实测,www 站点需 X-Forwarded-For 东南亚 IP 绕过地区拦截):
搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+链接解析)/ 歌词 / 链接解析(歌曲)/
取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
通道:
* 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(**无签名**)
* 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(**需 md5 签名**,见 util.joox_sign)
* 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
* 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
⚠ 网络连通性实况(本机出口 IP 非东南亚):
* cache.api.joox.com 与 api.joox.com 的 JSON 接口全部可直连;
* www.joox.com 的 SSR 页面返回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__,
因此 ``playlist.get_categories`` / ``playlist.get_category_playlists`` 与专辑·歌单的页面兜底
在当前网络下会如实抛错(不返回假数据)。这与 TS 注释一致:TS 也依赖伪造 X-Forwarded-For,
本移植带的是同一个头(``util.JOOX_XFF``),能否生效取决于上游是否采信,实测本机未被采信。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.apis.joox import get_music_url
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.joox import album, lyric, music_search, parse, playlist
from app.sdk.platforms.joox.music_info import get_track_detail
from app.sdk.platforms.joox.util import joox_song_link, normalize_joox_id, pick_joox_image
class JOOXSongList:
"""契约别名 ``songList``(对应 TS index.ts 里的内联 shim)。
JOOX 没有独立的「排行榜/歌曲列表」接口:``getTags`` 即歌单分类,``getList`` 即某分类下的歌单。
``sort_list`` 只有「全部」一项,因此路由透传的 ``sort_id`` 恒为空串,``tag_id`` 才是分类号。
TS 的 ``getList(sortId, tagId, page, limit=30)`` 中 ``limit`` 固定在内部,路由不透传。
"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "全部", "id": ""}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: str = "", tag_id: str = "", page: int = 1) -> dict[str, Any]:
return await playlist.get_category_playlists(str(tag_id or sort_id or ""), page, 30)
async def _get_pic(song_info: dict[str, Any]) -> str | None:
"""封面(对应 TS index.ts 的 getPic):走 openjoox2 track 详情后挑图,取不到返回 None。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or "")
detail = await get_track_detail(song_id)
return pick_joox_image(detail.get("images")) or None
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return joox_song_link(song_info.get("songmid") or song_info.get("id") or "")
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断;无在线校验接口)。"""
cred = get_credentials("joox")
if cred and cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="joox",
name="JOOX",
music_search=music_search,
song_list=JOOXSongList,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=_get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
View File
Whitespace-only changes.
+221
View File
@@ -0,0 +1,221 @@
"""JOOX 专辑(对应 sdk/joox/album.ts,上游 joox/album.go 移植 + 新版 openjoox2 通道)。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``section_type=1`` 专辑区,``item.type=2``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
(web 播放器通道,需 md5 签名;实测 2026-10 HTTP 200)
兜底:www.joox.com/<region>/album/<id> SSR ``__NEXT_DATA__.props.pageProps.albumData``
⚠ 该兜底依赖 X-Forwarded-For 伪装的东南亚 IP;本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此该路径在当前网络环境下不可用,仅在 openjoox2 失败时尝试,失败即如实抛出。
链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
路径里的 album id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径,
故路径写法不影响 secret 校验。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_album_link,
joox_artists,
joox_headers,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
to_int,
)
async def fetch_album_tracks(album_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 专辑曲目(含专辑元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/album/{album_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")), album_id)
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), album_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": joox_artists(body.get("artist_list")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def fetch_album_page_data(album_id: str) -> dict[str, Any]:
"""SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分)。"""
page = await fetch_joox_page(joox_album_link(album_id))
next_data = extract_joox_next_data(page["body"])
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
album_data = page_props.get("albumData")
if isinstance(album_data, dict) and normalize_joox_id(album_data.get("id")):
return album_data
nested = ((page_props.get("content") or {}).get("page") or {}).get("albumData")
if isinstance(nested, dict) and normalize_joox_id(nested.get("id")):
return nested
raise RuntimeError("joox 专辑页数据不存在")
async def get_album_detail_from_page(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径)。"""
album_id = normalize_joox_id(album_id)
if not album_id:
raise RuntimeError("joox 专辑 id 无效")
album_data = await fetch_album_page_data(album_id)
track_list = album_data.get("trackList")
items = track_list.get("items") if isinstance(track_list, dict) else None
items = items if isinstance(items, list) else []
page_start = max(0, (page - 1) * limit)
page_items = items[page_start : page_start + limit]
cover = str(first_non_empty(pick_joox_image(album_data.get("images")), album_data.get("imgSrc")))
songs = [
song
for song in (
joox_song_from_track(item, str(album_data.get("title") or ""), cover, album_id) for item in page_items
)
if song
]
if not songs:
raise RuntimeError("joox 专辑无歌曲")
total = first_non_zero(
track_list.get("total_count") if isinstance(track_list, dict) else None,
track_list.get("list_count") if isinstance(track_list, dict) else None,
len(items),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(album_data.get("title"), album_id)),
"img": cover or None,
"desc": str(first_non_empty(album_data.get("description"), album_data.get("publishDate"))),
"author": joox_artists(album_data.get("artistList")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(openjoox2 优先,SSR 页兜底)。"""
normalized = normalize_joox_id(album_id)
if not normalized:
raise RuntimeError("joox 专辑 id 无效")
try:
detail = await fetch_album_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走页面兜底
pass
return await get_album_detail_from_page(normalized, page, limit)
async def get_album_songs(album_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""专辑曲目(上游 GetAlbumSongs:仅曲目列表)。"""
detail = await get_album_detail(album_id, page, limit)
return detail["list"]
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
albums: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict) or to_int(section.get("section_type")) != 1:
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or to_int(item.get("type")) != 2:
continue
raw_album = item.get("album")
album = raw_album if isinstance(raw_album, dict) else {}
album_id = normalize_joox_id(album.get("id"))
if not album_id or album_id in seen:
continue
seen.add(album_id)
publish_date = str(album.get("publish_date") or "").strip()
albums.append(
{
"id": album_id,
"name": str(album.get("name") or ""),
"cover": pick_joox_image(album.get("images")) or None,
"creator": joox_artists(album.get("artist_list")),
"description": publish_date,
"publishDate": publish_date,
"trackCount": 0,
"link": joox_album_link(album_id),
"source": "joox",
}
)
return {"list": albums[:limit], "total": len(albums), "page": page, "limit": limit, "source": "joox"}
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/album/([^/?#]+)",
r"h_activity_id=([^&]+)",
r"albumid=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 10 and "/" not in stripped:
return await get_album_detail(stripped)
raise RuntimeError("invalid joox album link")
+92
View File
@@ -0,0 +1,92 @@
"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。
主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
响应形如 ``MusicJsonCallback({"code":0,"lyric":"<base64>"})``,lyric 为 base64 UTF-8 歌词。
实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。
兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。
解码:base64 → UTF-8 → HTML 实体(&amp; 等)→ ``{ lyric, tlyric: "" }``。
QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。
"""
from __future__ import annotations
import json
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_lyric,
encode_uri_component,
first_non_empty,
joox_headers,
normalize_joox_id,
)
LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric"
def parse_lyric_payload(body: Any) -> dict[str, Any] | None:
"""解析 MusicJsonCallback(...) / 裸 JSON 包裹。"""
if isinstance(body, dict):
return body
text = str(body if body is not None else "")
index = text.find("MusicJsonCallback(")
if index >= 0:
text = text[index + len("MusicJsonCallback(") :]
text = text.rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
if text.endswith(")"):
text = text[:-1].rstrip()
try:
parsed = json.loads(text)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def parse_legacy_lyric(status_code: int, body: Any) -> str:
"""上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。"""
if status_code != 200:
raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})")
payload = parse_lyric_payload(body)
if not payload:
raise RuntimeError("joox 歌词解析失败")
raw = str(first_non_empty(payload.get("lyric"), ""))
if int(payload.get("code") or 0) != 0 or not raw:
raise RuntimeError("joox 歌词不存在或为空")
return decode_joox_lyric(raw)
async def get_legacy_lyric(song_id: str) -> str:
"""上游 GetLyrics 通道(仅旧接口,测试/内部用)。"""
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return parse_legacy_lyric(res.status_code, res.body)
async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]:
"""歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
# 1) 上游 web_lyric 通道
legacy_err: Exception | None = None
try:
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""}
except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底
legacy_err = err
# 2) 兜底:openjoox2 详情 lrc_content(base64)
res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body if isinstance(res.body, dict) else {}
raw = str(first_non_empty(body.get("lrc_content"), ""))
if res.status_code != 200 or not raw:
raise legacy_err or RuntimeError("joox 歌词不存在或为空")
return {"lyric": decode_joox_lyric(raw), "tlyric": ""}
+103
View File
@@ -0,0 +1,103 @@
"""JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)。
对应 sdk/joox/musicInfo.ts。
端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
* 参数按**插入序**参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
* 签名串与真正发出的参数来自同一次序列化(同一份 params dict + 同一 encoder),不做二次拼接。
* 实测(2026-10):不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};
按上式 → 200,返回 play_url_list / refrain_url / lrc_content / qrc_content / vip_flag 等。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 前),本模块为其替代。
``fs=1`` 请求完整曲目(游客仍可能拿到 preview/refrain),``im`` 为 mobile 标记。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
cookie_value,
encode_uri_component,
joox_headers,
joox_login_cookie,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
)
JOOX_OPENJOOX2 = "https://cache.api.joox.com/openjoox2/v1"
def build_track_url(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> str:
"""构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk)。"""
track_id = str(normalize_joox_id(raw_id)).replace("/", "_")
params: dict[str, Any] = {
"country": country or "hk",
"lang": normalize_joox_lang(lang or "zh_TW"),
"lyric": 1,
"fs": 1 if full_song else 0,
"im": 1 if is_mobile else 0,
}
# 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
login_cookie = joox_login_cookie()
uid = cookie_value(login_cookie, "wmid") if login_cookie else ""
usk = cookie_value(login_cookie, "session_key") if login_cookie else ""
if uid:
params["uid"] = uid
if usk:
params["usk"] = usk
# 签名时包含 id(与 web 播放器一致),发布 query 时 id 走路径、不进 query
secret = joox_sign({**params, "id": track_id})
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in {**params, "secret": secret}.items())
return f"{JOOX_OPENJOOX2}/track/{track_id}?{query}"
async def get_track_detail(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> dict[str, Any]:
"""歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现)。"""
track_id = normalize_joox_id(raw_id)
if not track_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(track_id.replace("/", "_"), country, lang, full_song, is_mobile)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 歌曲详情获取失败:{msg or f'HTTP {res.status_code}'}")
return body
def pick_track_url(detail: Any) -> str:
"""从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 &amp; 实体。"""
if not isinstance(detail, dict):
return ""
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = detail.get("play_url_list")
for url in urls if isinstance(urls, list) else []:
text = clean(url)
if text.startswith("http"):
return text
refrain = clean(detail.get("refrain_url"))
return refrain if refrain.startswith("http") else ""
+57
View File
@@ -0,0 +1,57 @@
"""JOOX 搜索(对应 sdk/joox/musicSearch.ts,上游 joox/song.go Search 移植)。
端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
返回 ``section_list``(0 歌曲 / 1 专辑 / 2 歌手 / 3 歌单 / 5 最佳结果 / 7 用户),
歌曲在 ``item_list[].song[].song_info``。
该接口**无分页参数**(上游也未分页),``page`` 仅为统一契约保留;``allPage`` 恒为 1,
``total`` 是去重后的本次结果条数(不是上游分页总数)。
实测(2026-10):中文/英文关键词均 HTTP 200。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.song import joox_song_from_search
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, encode_uri_component, joox_headers
SEARCH_URL = "https://cache.api.joox.com/openjoox/v3/search"
def build_search_url(keyword: str) -> str:
return f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(keyword or '')}"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索歌曲。``page`` 不参与请求(上游无分页),保留以对齐契约。"""
url = build_search_url(str(keyword or ""))
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "allPage": 0, "total": 0, "limit": limit, "source": "joox"}
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict):
continue
for entry in item.get("song") or []:
info = entry.get("song_info") if isinstance(entry, dict) else None
song = joox_song_from_search(info)
if not song or song["songmid"] in seen:
continue
seen.add(song["songmid"])
songs.append(song)
return {
"list": songs[:limit],
"allPage": 1,
"total": len(songs),
"limit": limit,
"source": "joox",
}
+43
View File
@@ -0,0 +1,43 @@
"""JOOX 链接解析(对应 sdk/joox/parse.ts)。
歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.get_album_detail
歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.get_playlist_detail
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.joox.album import parse_album as _parse_album
from app.sdk.platforms.joox.music_info import get_track_detail, pick_track_url
from app.sdk.platforms.joox.playlist import parse_playlist as _parse_playlist
from app.sdk.platforms.joox.song import joox_song_from_detail
from app.sdk.platforms.joox.util import normalize_joox_id
_SINGLE_RE = re.compile(r"joox\.com/.*/single/([^/?#]+)")
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则)。"""
raw = str(link or "").strip()
match = _SINGLE_RE.search(raw)
if match:
song_id = normalize_joox_id(match.group(1))
elif len(raw) > 10 and "/" not in raw:
song_id = normalize_joox_id(raw)
else:
raise RuntimeError("invalid joox link")
detail = await get_track_detail(song_id)
return joox_song_from_detail(detail, pick_track_url(detail))
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接 → 专辑详情(上游 ParseAlbum)。"""
return await _parse_album(link)
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接 → 歌单详情(上游 ParsePlaylist)。"""
return await _parse_playlist(link)
+420
View File
@@ -0,0 +1,420 @@
"""JOOX 歌单(对应 sdk/joox/playlist.ts,上游 joox/playlist.go 移植)。
两条路:OpenJOOX 接口 + 网页数据兜底。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``item.type=1`` → ``editor_playlist``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret=
(上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR ``__NEXT_DATA__`` 的
props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
分类:www.joox.com/sg/playlist(302 → /<region> 首页)``__NEXT_DATA__.props.pageProps.mlList``
⚠ 分类与页面兜底都依赖 X-Forwarded-For 伪装的东南亚 IP。本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此 get_categories / get_category_playlists 在当前网络环境下会如实抛错,不返回假数据。
链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID。
路径里的歌单 id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_base64_text,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_headers,
joox_playlist_link,
joox_playlist_links,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
)
CATEGORY_PAGE = "https://www.joox.com/sg/playlist"
def map_playlist_summary(item: Any) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
playlist_id = normalize_joox_id(item.get("id"))
if not playlist_id:
return None
return {
"id": playlist_id,
"name": str(item.get("name") or ""),
"cover": pick_joox_image(item.get("images")) or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(playlist_id),
"source": "joox",
}
async def fetch_playlist_tracks(playlist_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 歌单曲目(含歌单元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/playlist/{playlist_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")))
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
body.get("track_count"),
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), playlist_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": "",
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
def _track_items(tracks: Any) -> list[Any]:
if not isinstance(tracks, dict):
return []
items = tracks.get("items")
return items if isinstance(items, list) else []
def _track_total(*candidates: Any) -> Any:
return first_non_zero(*candidates)
def _nested_name(detail: dict[str, Any], key: str) -> Any:
"""TS 里 ``detail?.user?.name`` 这类嵌套取值的兑底链(name → userName → nickName → nick)。"""
node = detail.get(key)
if not isinstance(node, dict):
return None
return first_non_empty(node.get("name"), node.get("userName"), node.get("nickName"), node.get("nick"))
def parse_playlist_page_data(next_data: Any, page_url: str) -> dict[str, Any] | None:
"""网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植)。"""
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
detail: dict[str, Any] = page_props.get("playlistDetailList") or {}
tracks = _track_items((page_props.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
nested = (page_props.get("content") or {}).get("page") or {}
detail = nested.get("playlistDetailList") or {}
tracks = _track_items((nested.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
track_count,
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
return None
playlist_id = normalize_joox_id(first_non_empty(detail.get("id"), detail.get("listId"), detail.get("playlistId")))
name = str(first_non_empty(detail.get("name"), detail.get("title"), detail.get("playlistName"), playlist_id))
cover = str(first_non_empty(detail.get("imgSrc"), detail.get("cover"), detail.get("image"), detail.get("pic")))
creator = first_non_empty(
detail.get("creator"),
detail.get("creatorName"),
detail.get("userName"),
detail.get("nickName"),
detail.get("ownerName"),
detail.get("author"),
detail.get("authorName"),
_nested_name(detail, "user"),
_nested_name(detail, "owner"),
_nested_name(detail, "creatorInfo"),
)
description = first_non_empty(detail.get("description"), detail.get("intro"), detail.get("desc"))
return {
"id": playlist_id,
"name": name,
"cover": cover,
"creator": creator,
"description": description,
"trackCount": track_count,
"tracks": tracks,
"pageUrl": page_url,
}
async def get_playlist_detail_from_page(raw_id: str) -> dict[str, Any]:
"""网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径)。"""
playlist_id = normalize_joox_id(raw_id)
if not playlist_id:
raise RuntimeError("joox 歌单 id 无效")
last_err: Exception | None = None
for page_url in joox_playlist_links(playlist_id):
try:
page = await fetch_joox_page(page_url)
parsed = parse_playlist_page_data(extract_joox_next_data(page["body"]), page_url)
if not parsed:
raise RuntimeError("joox 歌单页数据不存在")
page_id = normalize_joox_id(parsed["id"]) or playlist_id
cover = str(parsed["cover"] or "")
songs = [
song
for song in (joox_song_from_track(item, parsed["name"], cover) for item in parsed["tracks"])
if song
]
if not songs:
raise RuntimeError("joox 歌单无歌曲")
return {
"source": "joox",
"info": {
"name": str(parsed["name"] or page_id),
"img": cover or None,
"desc": str(parsed["description"] or ""),
"author": str(parsed["creator"] or ""),
},
"list": songs,
"page": 1,
"limit": len(songs),
"total": first_non_zero(parsed["trackCount"], len(songs)),
}
except Exception as err: # noqa: BLE001 - 逐个区域尝试,全部失败才抛最后一个错误
last_err = err
raise last_err or RuntimeError("joox 歌单页数据不存在")
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留)。"""
normalized = normalize_joox_id(playlist_id)
if not normalized:
raise RuntimeError("joox 歌单 id 无效")
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走网页兜底
pass
return await get_playlist_detail_from_page(normalized)
async def get_playlist_songs(playlist_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底)。"""
normalized = normalize_joox_id(playlist_id)
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail["list"]
except Exception: # noqa: BLE001 - 走网页兜底
pass
from_page = await get_playlist_detail_from_page(normalized)
return from_page["list"]
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(上游 SearchPlaylist)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
playlists: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or _as_int(item.get("type")) != 1:
continue
summary = map_playlist_summary(item.get("editor_playlist"))
if not summary or summary["id"] in seen:
continue
seen.add(summary["id"])
playlists.append(summary)
return {"list": playlists[:limit], "total": len(playlists), "page": page, "limit": limit, "source": "joox"}
async def fetch_playlist_categories_page() -> dict[str, Any]:
"""歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage)。"""
page = await fetch_joox_page(CATEGORY_PAGE)
next_data = extract_joox_next_data(page["body"])
ml_list = ((next_data or {}).get("props") or {}).get("pageProps", {})
ml_list = (ml_list or {}).get("mlList")
if not isinstance(ml_list, dict) or not ml_list.get("category"):
raise RuntimeError("joox 歌单分类不存在")
return ml_list
def category_playlist_id(value: Any) -> str:
"""分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一)。"""
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(int(value))
return normalize_joox_id(value)
def _categories(ml_list: dict[str, Any]) -> list[Any]:
categories = ml_list.get("category")
return categories if isinstance(categories, list) else []
async def get_categories() -> dict[str, Any]:
"""分类列表(上游 GetPlaylistCategories;首个为「全部」)。"""
data = await fetch_playlist_categories_page()
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部", "hot": False}]
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
entries = category.get("itemlist")
entries = entries if isinstance(entries, list) else []
if not name or not entries:
continue
items.append({"id": str(index), "name": name, "group": "JOOX", "hot": False, "count": len(entries)})
if len(items) == 1:
raise RuntimeError("joox 歌单分类不存在")
return {"source": "joox", "list": items}
async def get_category_playlists(category_id: str = "", page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类下歌单(上游 GetCategoryPlaylists)。"""
data = await fetch_playlist_categories_page()
cid = str(category_id or "").strip()
items: list[Any] = []
category_name = "全部"
if not cid:
seen: set[str] = set()
for category in _categories(data):
if not isinstance(category, dict):
continue
for item in category.get("itemlist") or []:
entry_id = category_playlist_id(item.get("id") if isinstance(item, dict) else None)
if not entry_id or entry_id in seen:
continue
seen.add(entry_id)
items.append(item)
else:
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
if str(index) == cid or (name and name.lower() == cid.lower()):
entries = category.get("itemlist")
items = entries if isinstance(entries, list) else []
category_name = name
break
if not items:
raise RuntimeError("joox 歌单分类不存在")
start = max(0, (page - 1) * limit)
if start >= len(items):
raise RuntimeError("joox 分类下无歌单")
page_items = items[start : start + limit]
playlists: list[dict[str, Any]] = []
for item in page_items:
if not isinstance(item, dict):
continue
entry_id = category_playlist_id(item.get("id"))
name = decode_joox_base64_text(item.get("title"))
if not entry_id or not name:
continue
playlists.append(
{
"id": entry_id,
"name": name,
"cover": str(item.get("picurl") or "").replace("%d", "300") or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(entry_id),
"categoryId": cid,
"categoryName": category_name,
"source": "joox",
}
)
if not playlists:
raise RuntimeError("joox 分类下无歌单")
return {"list": playlists, "total": len(items), "page": page, "limit": limit, "source": "joox"}
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接解析(上游 ParsePlaylist)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/playlist/([^/?#]+)",
r"(?:playlistid|playlist_id|id)=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_playlist_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 8 and "/" not in stripped:
return await get_playlist_detail(stripped)
raise RuntimeError("invalid joox playlist link")
def _as_int(value: Any) -> int | None:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
+96
View File
@@ -0,0 +1,96 @@
"""JOOX 歌曲形状映射(对应 sdk/joox/song.ts)。
统一 Song 形状(见 tmp/port-spec.md);兼容来源:openjoox2 track/album/playlist 条目、
openjoox v3 search 的 song_info、以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,
上游 Go 也两套都兜底)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.joox.util import (
first_non_empty,
first_non_zero,
joox_artists,
normalize_joox_id,
pick_joox_image,
to_int,
)
def joox_song_from_track(
item: Any,
fallback_album: str = "",
fallback_cover: str = "",
fallback_album_id: str = "",
) -> dict[str, Any] | None:
"""详情/列表 track 条目 → 统一 Song 形状。"""
if not isinstance(item, dict):
item = {}
songmid = normalize_joox_id(item.get("id"))
if not songmid:
return None
artists = item.get("artist_list")
if not (isinstance(artists, list) and artists):
artists = item.get("artistList")
album_id = normalize_joox_id(first_non_empty(item.get("album_id"), item.get("albumId"), fallback_album_id))
return {
"songmid": songmid,
"name": str(first_non_empty(item.get("name"), item.get("title"), "Unknown")),
"singer": str(first_non_empty(joox_artists(artists), "Unknown")),
"albumId": str(album_id or ""),
"albumName": str(first_non_empty(item.get("album_name"), item.get("albumName"), fallback_album)),
"interval": format_play_time(
first_non_zero(item.get("play_duration"), item.get("playDuration"), item.get("duration"))
),
"img": str(
first_non_empty(
pick_joox_image(item.get("images")),
item.get("imgSrc"),
item.get("cover"),
item.get("image"),
item.get("pic"),
fallback_cover,
)
)
or None,
"lrc": None,
"vip": to_int(item.get("vip_flag") if item.get("vip_flag") is not None else 0) == 1,
"types": [],
"_types": {},
"source": "joox",
}
def joox_song_from_search(info: Any) -> dict[str, Any] | None:
"""openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状。"""
return joox_song_from_track(info)
def joox_song_from_detail(detail: Any, play_url: str = "") -> dict[str, Any]:
"""详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状。"""
if not isinstance(detail, dict):
detail = {}
song = joox_song_from_track(detail)
if song is None:
song = {
"songmid": normalize_joox_id(detail.get("id")),
"name": str(detail.get("name") or ""),
"singer": str(joox_artists(detail.get("artist_list"))),
"albumId": str(normalize_joox_id(detail.get("album_id"))),
"albumName": str(detail.get("album_name") or ""),
"interval": format_play_time(first_non_zero(detail.get("play_duration"))),
"img": str(pick_joox_image(detail.get("images"))) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "joox",
}
if play_url:
song["url"] = play_url
song["vip"] = to_int(detail.get("vip_flag") if detail.get("vip_flag") is not None else 0) == 1
return song
+267
View File
@@ -0,0 +1,267 @@
"""JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取(对应 sdk/joox/util.ts)。
背景(与 TS 注释一致,2026-10 实测):
* JOOX 是东南亚服务,www.joox.com 会按来源 IP 返回「Sorry, JOOX is not available in this
location」;上游通过伪造 ``X-Forwarded-For`` 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
* 新版 web 播放器走 ``cache.api.joox.com/openjoox2/v1/*``,**必须**带 secret 签名:
secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent)
密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
实测:不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};按上式 → 200。
* 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
但 web_lyric 仍可用(歌词首选通道)。
* 登录 cookie 里的 wmid/session_key 在签名参数里作为 uid/usk 参与(VIP 完整曲目依赖它)。
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
JOOX_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
"""上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie)。"""
JOOX_DEFAULT_COOKIE = "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;"
"""上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区拦截)。"""
JOOX_XFF = "36.73.34.109"
"""openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey)。"""
JOOX_SECRET_KEY = "Jo0x@t3Nc3nT"
"""平台统一超时(毫秒),与 TS 各处 timeout: 20000 一致。"""
JOOX_TIMEOUT = 20000
def md5_hex(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def joox_sign(params: dict[str, Any]) -> str:
"""openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)。
**键序即 dict 插入序**(服务端按同序校验),不要重排参数 —— 任何 sorted() 都会破坏签名。
"""
pairs = [f"{key}={encode_uri_component(value)}" for key, value in params.items()]
return md5_hex(JOOX_SECRET_KEY + "&".join(pairs))
def joox_login_cookie() -> str:
"""绑定到 auth_store 的登录 cookie(未登录返回空串,不回退游客 cookie)。"""
cred = get_credentials("joox")
if not cred:
return ""
return str(cred.get("cookie") or "")
def joox_cookie() -> str:
"""请求用 cookie(登录态优先;未登录回退上游游客 cookie)。"""
return joox_login_cookie() or JOOX_DEFAULT_COOKIE
def cookie_value(cookie: str, name: str) -> str:
"""从 cookie 串里取一个字段。"""
match = re.search(rf"(?:^|;\s*){re.escape(name)}=([^;]*)", str(cookie or ""))
return match.group(1).strip() if match else ""
def joox_headers(extra: dict[str, Any] | None = None) -> dict[str, str]:
"""平台公共请求头(含地区伪装与 Cookie)。"""
headers = {
"User-Agent": JOOX_UA,
"Cookie": joox_cookie(),
"X-Forwarded-For": JOOX_XFF,
"Referer": "https://www.joox.com/",
"Accept": "application/json, text/plain, */*",
}
if extra:
headers.update({str(key): str(value) for key, value in extra.items()})
return headers
def normalize_joox_lang(lang: str | None) -> str:
"""语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN)。"""
if lang in ("zh_hk", "zh_mo"):
return "zh_TW"
if lang == "zh_cn":
return "zh_CN"
return lang or "zh_TW"
def normalize_joox_id(raw: Any) -> str:
"""ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +)。"""
normalized = str(raw if raw is not None else "").strip()
if not normalized:
return ""
if "%" in normalized:
try:
normalized = unquote(normalized, errors="strict")
except (UnicodeDecodeError, ValueError):
# 上游 PathUnescape/QueryUnescape 失败时保留原值
pass
normalized = normalized.replace(" ", "+")
return normalized
def joox_song_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/single/{normalize_joox_id(raw_id)}"
def joox_album_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/album/{normalize_joox_id(raw_id)}"
def joox_regional_playlist_link(region: str, raw_id: Any) -> str:
return f"https://www.joox.com/{region}/playlist/{normalize_joox_id(raw_id)}"
def joox_playlist_link(raw_id: Any) -> str:
return joox_regional_playlist_link("id", raw_id)
def joox_playlist_links(raw_id: Any) -> list[str]:
"""歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用)。"""
return [joox_regional_playlist_link(region, raw_id) for region in ("id", "sg", "hk")]
def pick_joox_image(images: Any) -> str:
"""封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空)。"""
items = images if isinstance(images, list) else []
for width in (300, 1000, 100):
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if to_int(image.get("width")) == width and url:
return url
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if url:
return url
return ""
def joox_artists(items: Any) -> str:
"""歌手拼接(上游 joinJooxArtists,分隔符 ' / ')。"""
listing = items if isinstance(items, list) else []
names = [str(entry.get("name") or "").strip() for entry in listing if isinstance(entry, dict)]
return " / ".join(name for name in names if name)
def first_non_empty(*values: Any) -> Any:
for value in values:
if value is None:
continue
if str(value).strip() != "":
return value
return ""
def first_non_zero(*values: Any) -> int | float:
for value in values:
number = to_number(value)
if number is not None and number > 0:
return number
return 0
def to_number(value: Any) -> float | None:
"""对齐 JS Number():有限数值返回 float,其余返回 None。"""
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return 0.0
try:
number = float(text)
except ValueError:
return None
else:
return None
if number != number or number in (float("inf"), float("-inf")):
return None
return number
def to_int(value: Any) -> int | None:
number = to_number(value)
return None if number is None else int(number)
def decode_joox_base64_text(value: Any) -> str:
"""base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充)。"""
raw = str(value if value is not None else "").strip()
if not raw:
return ""
normalized = raw.replace("-", "+").replace("_", "/")
padded = normalized + "=" * ((4 - len(normalized) % 4) % 4)
try:
text = base64.b64decode(padded, validate=False).decode("utf-8", errors="replace")
except (ValueError, TypeError):
return raw
# 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
return raw if "\ufffd" in text else text.strip()
def decode_html_entities(text: Any) -> str:
"""HTML 实体解码(歌词文本兜底)。"""
return (
str(text if text is not None else "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&apos;", "'")
.replace("&#0*39;", "'")
.replace("&nbsp;", " ")
)
def decode_joox_lyric(value: Any) -> str:
"""歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底)。"""
return decode_html_entities(decode_joox_base64_text(value))
_NEXT_DATA_RE = re.compile(r'<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)</script>')
def extract_joox_next_data(html: Any) -> Any:
"""从 SSR 页面提取 __NEXT_DATA__ JSON。"""
match = _NEXT_DATA_RE.search(str(html if html is not None else ""))
if not match:
return None
try:
return json.loads(match.group(1))
except ValueError:
return None
async def fetch_joox_page(url: str, max_redirect: int = 3) -> dict[str, Any]:
"""拉取页面并手动跟随 3xx(request.py 不自动跟跳;Go 客户端会自动跟)。
返回 ``{"statusCode", "body", "finalUrl"}``(非字符串 body 视为空串)。
"""
current = url
for _ in range(max_redirect + 1):
res = await http_fetch(current, headers=joox_headers(), timeout=JOOX_TIMEOUT)
location = res.header("location")
if 300 <= res.status_code < 400 and location:
current = urljoin(current, location)
continue
body = res.body if isinstance(res.body, str) else ""
return {"statusCode": res.status_code, "body": body, "finalUrl": current}
raise RuntimeError("joox 页面重定向次数过多")
+58
View File
@@ -0,0 +1,58 @@
"""酷狗音乐(对应 sdk/kg/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析,
顶层挂 get_music_url(取流在 apis/kg.py)、get_lyric、get_pic、get_music_info
(hash 反查歌名/歌手/时长,供取流失败的酷我兜底使用)、get_music_detail_page_url。
"""
from __future__ import annotations
from typing import Any
from app.sdk.apis.kg import get_music_url
from app.sdk.platforms._base import Platform
from app.sdk.platforms.kg import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_info,
music_search,
parse,
pic,
playlist,
singer,
song_list,
)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
hash_value = song_info.get("hash") or song_info.get("songmid")
album_id = song_info.get("albumId")
return f"https://www.kugou.com/song/#hash={hash_value}&album_id={album_id}"
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""路由兜底调用:传 `{"hash": id, "songmid": id}`,返回含 name/singer/interval 的 dict。"""
return await music_info.get_music_info_for_song_info(song_info)
MODULE = Platform(
id="kg",
name="酷狗音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
singer=singer,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+150
View File
@@ -0,0 +1,150 @@
"""酷狗专辑(对应 sdk/kg/album.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch, encode_uri_component
ALBUM_INFO_URL = (
"http://kmrserviceretry.kugou.com/container/v1/album"
"?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589"
)
ALBUM_SONG_URL = "http://mobiles.kugou.com/api/v3/album/song"
ALBUM_SEARCH_URL = "http://mobilecdn.kugou.com/api/v3/search/album"
_UA_MOBILE = (
"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"
)
def _album_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
async def _get_album_info(id: Any) -> dict[str, Any]:
result = await create_http_fetch(
ALBUM_INFO_URL,
{
"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 not result:
raise RuntimeError("get album info failed.")
info = result[0] if isinstance(result, list) else result
return {
"name": info.get("album_name"),
"image": str(info.get("sizable_cover") or "").replace("{size}", "240"),
"desc": info.get("intro"),
"authorName": info.get("author_name"),
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 200) -> dict[str, Any]:
album_list = await create_http_fetch(
f"{ALBUM_SONG_URL}?version=9108&albumid={album_id}&plat=0&pagesize={limit}"
f"&area_code=0&page={page}&with_res_tag=0"
)
album_list = album_list or {}
if not album_list.get("info"):
raise RuntimeError("Get album list failed.")
songs = await get_music_infos_by_list(album_list.get("info") or [])
info = await _get_album_info(album_id)
return {
"list": songs or [],
"page": page,
"limit": limit,
"total": album_list.get("total"),
"source": "kg",
"info": {
"name": info["name"],
"img": info["image"],
"desc": info["desc"],
"author": info["authorName"],
},
}
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 10))
url = f"{ALBUM_SEARCH_URL}?keyword={encode_uri_component(word)}&format=json&page={page_no}&pagesize={page_size}"
resp = await http_fetch(url, headers={"User-Agent": _UA_MOBILE, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗专辑搜索失败"))
data = body.get("data") or {}
songs: list[dict[str, Any]] = []
for item in data.get("info") or []:
album_id = str(item.get("albumid") or "").strip()
if not album_id or album_id == "0":
continue
songs.append(
{
"id": album_id,
"name": str(item.get("albumname") or "").strip(),
"cover": _album_cover(item.get("imgurl")),
"trackCount": int(item.get("songcount") or 0) or 0,
"creator": str(item.get("singername") or "").strip(),
"description": str(item.get("intro") or "").strip(),
"publishTime": str(item.get("publishtime") or "").strip(),
"link": f"https://www.kugou.com/album/{album_id}.html",
"source": "kg",
"type": "album",
}
)
return {
"source": "kg",
"list": songs,
"page": page_no,
"limit": page_size,
"total": int(data.get("total") or 0) or len(songs),
}
async def get_album_songs(id: Any) -> list[dict[str, Any]]:
album_id = str(id or "").strip()
if not album_id:
raise RuntimeError("酷狗专辑 id 为空")
result = await get_album_detail(album_id, 1, 10000)
return result["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""album/single/<id>.html、yy/album/single/<id>.html、album/<id>.html、albumid=<id>。"""
import re
raw = str(link or "")
for pattern in (
r"album/single/(\d+)\.html",
r"yy/album/single/(\d+)\.html",
r"album/(\d+)\.html",
r"albumid=(\d+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1), 1)
raise RuntimeError("无效的酷狗专辑链接")
+128
View File
@@ -0,0 +1,128 @@
"""酷狗评论(对应 sdk/kg/comment.ts)。
签名:`signatureParams(params)` —— 参数串按键排序拼接后以 android 盐包裹取 MD5。
返回形状是 `{source, comments, total, page, limit, maxPage}`(不是 `list`),
`filterComment` 还要处理 `atlist` 替换与 `pcontent`(楼中楼)两种形态。
"""
from __future__ import annotations
import math
import time
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2, decode_name
from app.sdk.platforms.kg.util import signature_params
_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24"
)
def _comment_params(hash_value: str, page: int, limit: int) -> str:
return (
"dfid=0&mid=16249512204336365674023395779019"
f"&clienttime={int(time.time() * 1000)}&uuid=0&extdata={hash_value}"
"&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&schash={hash_value}&clientver=11409&p={page}&clienttoken=&pagesize={limit}"
"&ver=10&kugouid=0"
)
def _replace_at(raw: str, at_list: list[dict[str, Any]]) -> str:
for item in at_list:
raw = raw.replace(f"[at={item.get('id')}]", f"@{item.get('name')} ")
return raw
def _format_comment(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
content = item.get("content") or ""
if item.get("atlist"):
content = _replace_at(str(content), item["atlist"])
images = [image.get("url") for image in item.get("images") or []] if item.get("images") else []
like = item.get("like") or {}
data = {
"id": item.get("id"),
"text": decode_name(content),
"images": images,
"location": item.get("location"),
"time": item.get("addtime"),
"timeStr": date_format2(item.get("addtime") or 0),
"userName": item.get("user_name"),
"avatar": item.get("user_pic"),
"userId": item.get("user_id"),
"likedCount": like.get("likenum"),
"replyNum": item.get("reply_num"),
"reply": [],
}
if item.get("pcontent"):
# 楼中楼:父评论包一层,原评论挂在 reply 里
result.append(
{
"id": item.get("id"),
"text": decode_name(item["pcontent"]),
"time": None,
"userName": item.get("puser"),
"avatar": None,
"userId": item.get("puser_id"),
"likedCount": None,
"replyNum": None,
"reply": [data],
}
)
else:
result.append(data)
return result
async def _get_comment(hash_value: str, page: int, limit: int, hot: bool) -> dict[str, Any]:
path = "topliked" if hot else "newest"
params = _comment_params(hash_value, page, limit)
resp = await http_fetch(
f"http://m.comment.service.kugou.com/r/v1/rank/{path}?{params}&signature={signature_params(params)}",
headers={"User-Agent": _UA},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
total = body.get("count") or 0
return {
"source": "kg",
"comments": _format_comment(body.get("list") or []),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, False)
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, True)
async def get_reply_comment(
song_info: dict[str, Any], reply_id: Any, page: int = 1, limit: int = 100
) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "")
children_id = str(song_info.get("audioId") or "").split("_")[0] if len(songmid) == 32 else songmid
resp = await http_fetch(
"http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike"
"&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&p={page}&pagesize={limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1"
f"&appid=1001&childrenid={children_id}&tid={reply_id}",
headers={"User-Agent": "Android712-AndroidPhone-8983-18-0-COMMENT-wifi"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取回复评论失败")
return {"source": "kg", "comments": _format_comment(body.get("list") or [])}
+41
View File
@@ -0,0 +1,41 @@
"""酷狗热搜(对应 sdk/kg/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
HOT_API = (
"http://gateway.kugou.com/api/v3/search/hot_tab"
"?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0"
)
HEADERS = {
"dfid": "1ssiv93oVqMp27cirf2CvoF1",
"mid": "156798703528610303473757548878786007104",
"clienttime": "1584257267",
"x-router": "msearch.kugou.com",
"user-agent": "Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi",
"kg-rc": "1",
}
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
result: list[str] = []
for item in raw_list:
for keyword in item.get("keywords") or []:
result.append(decode_name(keyword.get("keyword")))
return result
async def get_list(retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_API, method="get", headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("errcode") != 0:
raise RuntimeError("获取热搜词失败")
data = body.get("data") or {}
return {"source": "kg", "list": _filter_list(data.get("list") or [])}
+102
View File
@@ -0,0 +1,102 @@
"""酷狗 KRC 歌词解密(对应 sdk/kg/krcDecode.ts)。
密文是 base64 → 去掉 4 字节头 → 与 16 字节固定 key 循环异或 → zlib inflate。
解密后是带 `[start,duration]` 行级标签与 `<offset,duration,0>` 字级标签的 KRC 文本,
另外 `[language:<base64>]` 行里藏着 base64(JSON) 的逐行翻译,需按行号拼回 LRC。
"""
from __future__ import annotations
import base64
import json
import re
import zlib
from typing import Any
from app.sdk.common.utils import decode_name
ENC_KEY = bytes([0x40, 0x47, 0x61, 0x77, 0x5E, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2D, 0xCE, 0xD2, 0x6E, 0x69])
_HEAD_EXP = re.compile(r"^.*\[id:\$\w+\]\n")
_TRANS_EXP = re.compile(r"\[language:([\w=\\/+]+)\]")
_TRANS_LINE_EXP = re.compile(r"\[language:[\w=\\/+]+\]\n")
_LINE_EXP = re.compile(r"\[((\d+),\d+)\].*")
_WORD_TAG_EXP = re.compile(r"<(\d+,\d+),\d+>")
_PLAIN_TAG_EXP = re.compile(r"<\d+,\d+>")
def _xor_decode(data: str) -> str:
if not data:
return ""
buf = bytearray(base64.b64decode(data)[4:])
for i in range(len(buf)):
buf[i] ^= ENC_KEY[i % 16]
return zlib.decompress(bytes(buf)).decode("utf-8", errors="replace")
def _format_time(ms: int) -> str:
"""毫秒 → `mm:ss.ms`(毫秒段与原文一致不补零)。"""
minutes, rem = divmod(ms, 60000)
return f"{minutes:02d}:{rem // 1000:02d}.{ms % 1000}"
def _lrc_line(value: Any) -> str:
"""rlyric/tlyric 的单项是字符串数组(逐字),字符串形态也容忍。"""
if isinstance(value, list):
return "".join(str(item) for item in value)
return "" if value is None else str(value)
def parse_lyric(text: str) -> dict[str, str]:
text = text.replace("\r", "")
if _HEAD_EXP.match(text):
text = _HEAD_EXP.sub("", text, count=1)
rlyric: list[Any] | None = None
tlyric: list[Any] | None = None
trans = _TRANS_EXP.search(text)
if trans:
text = _TRANS_LINE_EXP.sub("", text, count=1)
try:
payload = json.loads(base64.b64decode(trans.group(1)).decode("utf-8", errors="replace"))
except ValueError:
payload = {}
for item in payload.get("content") or []:
if item.get("type") == 0:
rlyric = item.get("lyricContent")
elif item.get("type") == 1:
tlyric = item.get("lyricContent")
index = 0
def repl(match: re.Match[str]) -> str:
nonlocal index
line_match = _LINE_EXP.match(match.group(0))
time_ms = int(line_match.group(2)) if line_match else 0
stamp = _format_time(time_ms)
if rlyric is not None:
rlyric[index] = f"[{stamp}]{_lrc_line(rlyric[index]) if index < len(rlyric) else ''}"
if tlyric is not None:
tlyric[index] = f"[{stamp}]{_lrc_line(tlyric[index]) if index < len(tlyric) else ''}"
index += 1
return match.group(0).replace(match.group(1), stamp, 1)
yrc = re.sub(r"\[((\d+),\d+)\].*", repl, text)
rlyric_text = "\n".join(str(item) for item in rlyric) if rlyric else ""
tlyric_text = "\n".join(str(item) for item in tlyric) if tlyric else ""
yrc = _WORD_TAG_EXP.sub(r"<\1>", yrc)
yrc = decode_name(yrc)
lyric = _PLAIN_TAG_EXP.sub("", yrc)
return {
"lyric": lyric,
"tlyric": decode_name(tlyric_text),
"rlyric": decode_name(rlyric_text),
"yrc": yrc,
}
async def decode_krc(data: str) -> dict[str, str]:
return parse_lyric(_xor_decode(data))
__all__ = ["decode_krc", "parse_lyric"]
+110
View File
@@ -0,0 +1,110 @@
"""酷狗榜单(对应 sdk/kg/leaderboard.ts)。
榜单 id 表是硬编码的静态表(原实现 getBoards 直接返回它,不请求上游);
详情走 `mobilecdnbj.kugou.com/api/v3/rank/song`,返回的 hash 需再反查一次 SongInfo
才能拿到多档音质。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, size_formate
LIST_DETAIL_LIMIT = 100
BOARD_LIST: list[dict[str, str]] = [
{"id": "kgtop500", "name": "TOP500", "bangid": "8888"},
{"id": "kgwlhgb", "name": "网络榜", "bangid": "23784"},
{"id": "kgbsb", "name": "飙升榜", "bangid": "6666"},
{"id": "kgfxb", "name": "分享榜", "bangid": "21101"},
{"id": "kgcyyb", "name": "纯音乐榜", "bangid": "33164"},
{"id": "kggfjqb", "name": "古风榜", "bangid": "33161"},
{"id": "kgyyjqb", "name": "粤语榜", "bangid": "33165"},
{"id": "kgomjqb", "name": "欧美榜", "bangid": "33166"},
{"id": "kgdyrgb", "name": "电音榜", "bangid": "33160"},
{"id": "kgjdrgb", "name": "DJ热歌榜", "bangid": "24971"},
{"id": "kghyxgb", "name": "华语新歌榜", "bangid": "31308"},
]
RANK_API = "http://mobilecdnbj.kugou.com/api/v3/rank/song"
def get_url(page: int, bangid: str, limit: int = LIST_DETAIL_LIMIT) -> str:
return (
f"{RANK_API}?version=9108&ranktype=1&plat=0&pagesize={limit}"
f"&area_code=1&page={page}&rankid={bangid}&with_res_tag=0&show_portrait_mv=1"
)
def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "320filesize", "320hash"),
("flac", "sqfilesize", "sqhash"),
("flac24bit", "filesize_high", "hash_high"),
):
if item.get(size_key, 0) == 0:
continue
size = size_formate(item.get(size_key))
types.append({"type": quality, "size": size, "hash": item.get(hash_key)})
_types[quality] = {"size": size, "hash": item.get(hash_key)}
result.append(
{
"singer": _singer(item.get("authors")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(item.get("remark")),
"albumId": item.get("album_id"),
"songmid": item.get("audio_id"),
"source": "kg",
"interval": format_play_time(item.get("duration")),
"img": None,
"lrc": None,
"hash": item.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _singer(authors: list[dict[str, Any]] | None) -> str:
names = [str(author["author_name"]) for author in authors or [] if author.get("author_name")]
return decode_name("、".join(names))
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "kg"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
resp: Any
try:
resp = await http_fetch(get_url(page, bangid))
except Exception: # noqa: BLE001 - 可降级失败:换一次请求重试
return await get_list(bangid, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
return await get_list(bangid, page, retry_num + 1)
data = body.get("data") or {}
return {
"total": data.get("total"),
"list": _filter_data(data.get("info") or []),
"limit": 100,
"page": page,
"source": "kg",
}
def get_detail_page_url(id: Any) -> str:
value = str(id).replace("kg__", "") if isinstance(id, str) else id
return f"https://www.kugou.com/yy/rank/home/1-{value}.html"
+91
View File
@@ -0,0 +1,91 @@
"""酷狗歌词(对应 sdk/kg/lyric.ts)。
两步:`lyrics.kugou.com/search` 按歌名+hash+时长找候选 → `lyrics.kugou.com/download`
取回 base64 内容。`fmt=krc` 需走 krc_decode 解密,`fmt=lrc` 直接 base64 解码。
"""
from __future__ import annotations
import base64
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.krc_decode import decode_krc
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def get_intv(interval: Any) -> int:
"""`mm:ss` → 毫秒(原文把秒当毫秒拼给上游,这里保持一致)。"""
if not interval:
return 0
parts = str(interval).split(":")
total = 0
unit = 1
while parts:
part = parts.pop()
try:
total += int(float(part)) * unit
except ValueError:
pass
unit *= 60
return int(total)
async def _search_lyric(name: str, hash_value: str, time: Any, try_num: int = 0) -> dict[str, str] | None:
from app.sdk.platforms.kg.util import encode_uri_component
url = (
"http://lyrics.kugou.com/search?ver=1&man=yes&client=pc"
f"&keyword={encode_uri_component(name)}&hash={hash_value}&timelength={time}&lrctxt=1"
)
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
return await _search_lyric(name, hash_value, time, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
candidates = body.get("candidates") or []
if not candidates:
return None
info = candidates[0]
fmt = "krc" if info.get("krctype") == 1 and info.get("contenttype") != 1 else "lrc"
return {"id": str(info.get("id")), "accessKey": str(info.get("accesskey")), "fmt": fmt}
async def _get_lyric_download(id: str, access_key: str, fmt: str, try_num: int = 0) -> dict[str, str]:
url = f"http://lyrics.kugou.com/download?ver=1&client=pc&id={id}&accesskey={access_key}&fmt={fmt}&charset=utf8"
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
# 重试必须回到本方法(搜索接口与下载接口的入参语义不同)
return await _get_lyric_download(id, access_key, fmt, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
content = str(body.get("content") or "")
match body.get("fmt"):
case "krc":
return await decode_krc(content)
case "lrc":
return {
"lyric": base64.b64decode(content).decode("utf-8", errors="replace"),
"tlyric": "",
"rlyric": "",
"yrc": "",
}
case _:
raise RuntimeError(f"未知歌词格式: {body.get('fmt')}")
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
name = str(song_info.get("name") or "")
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
time = song_info.get("_interval") or get_intv(song_info.get("interval"))
result = await _search_lyric(name, hash_value, time)
if not result:
raise RuntimeError("Get lyric failed")
return await _get_lyric_download(result["id"], result["accessKey"], result["fmt"])
+138
View File
@@ -0,0 +1,138 @@
"""按 hash 批量反查歌曲信息(对应 sdk/kg/musicInfo.ts)。
多个模块(榜单/歌单/专辑/歌手/解析/路由兜底)都要把 hash 换成完整 SongInfo,
统一走这里:`http://gateway.kugou.com/v3/album_audio/audio`,每批最多 100 个 hash。
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.sdk.common.utils import decode_name, format_play_time, size_formate
from app.sdk.platforms.kg.util import create_http_fetch
MUSIC_INFO_URL = "http://gateway.kugou.com/v3/album_audio/audio"
MUSIC_INFO_HEADERS = {
"KG-THash": "13a3164",
"KG-RC": "1",
"KG-Fake": "0",
"KG-RF": "00869891",
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
"x-router": "kmr.service.kugou.com",
}
_FIELDS = "album_info,author_name,audio_info,ori_audio_name,base,songname,classification"
def _filter_music_info_list(raw_list: list[Any]) -> list[dict[str, Any]]:
ids: set[Any] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
if not item:
continue
audio = item.get("audio_info") or {}
if audio.get("audio_id") in ids:
continue
ids.add(audio.get("audio_id"))
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "filesize_320", "hash_320"),
("flac", "filesize_flac", "hash_flac"),
("flac24bit", "filesize_high", "hash_high"),
):
if str(audio.get(size_key, "0")) == "0":
continue
size = size_formate(int(audio.get(size_key) or 0))
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
album = item.get("album_info") or {}
result.append(
{
"singer": decode_name(item.get("author_name")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(album.get("album_name")),
"albumId": album.get("album_id"),
"songmid": audio.get("audio_id"),
"source": "kg",
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
"img": None,
"lrc": None,
"hash": audio.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _build_tasks(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
base = {
"area_code": "1",
"show_privilege": 1,
"show_album_info": "1",
"is_publish": "",
"appid": 1005,
"clientver": 11451,
"mid": "1",
"dfid": "-",
"clienttime": int(time.time() * 1000),
"key": "OIlwieks28dk2k092lksi2UIkp",
"fields": _FIELDS,
}
tasks: list[dict[str, Any]] = []
rest = list(hashs)
while rest:
tasks.append({"data": rest[:100], **base})
if len(rest) < 100:
break
rest = rest[100:]
return tasks
async def get_music_infos(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 hash 列表批量取 SongInfo(上游按 100 个一批并发,扁平化后去重)。"""
async def one(task: dict[str, Any]) -> list[Any]:
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": task, "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
return [entry[0] if entry else None for entry in items]
chunks = await asyncio.gather(*(one(task) for task in _build_tasks(hashs)))
return _filter_music_info_list([item for chunk in chunks for item in chunk])
async def get_music_info_raw(hash: str) -> Any:
tasks = _build_tasks([{"hash": hash}])
if not tasks:
return None
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": tasks[0], "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
flat = [entry[0] if entry else None for entry in items]
return flat[0] if flat else None
async def get_music_info(hash: str) -> dict[str, Any] | None:
infos = await get_music_infos([{"hash": hash}])
return infos[0] if infos else None
async def get_music_infos_by_list(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""上游返回的 {hash} 列表 → 完整 SongInfo 列表。"""
if not items:
return []
return await get_music_infos([{"hash": item.get("hash")} for item in items])
async def get_music_info_for_song_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""顶层 `get_music_info(song_info)` 的实现:hash 优先,退回 songmid。"""
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "").strip()
if not hash_value:
return None
return await get_music_info(hash_value)
+97
View File
@@ -0,0 +1,97 @@
"""酷狗搜索(对应 sdk/kg/musicSearch.ts)。"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, format_singer_name, size_formate
from app.sdk.platforms.kg.util import encode_uri_component
DEFAULT_LIMIT = 30
_SEARCH_API = "https://songsearch.kugou.com/song_search_v2"
def _filter_data(raw: dict[str, Any]) -> dict[str, Any]:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "FileSize", "FileHash"),
("320k", "HQFileSize", "HQFileHash"),
("flac", "SQFileSize", "SQFileHash"),
("flac24bit", "ResFileSize", "ResFileHash"),
):
if raw.get(size_key, 0) == 0:
continue
size = size_formate(raw.get(size_key))
types.append({"type": quality, "size": size, "hash": raw.get(hash_key)})
_types[quality] = {"size": size, "hash": raw.get(hash_key)}
return {
"singer": decode_name(format_singer_name(raw.get("Singers"), "name")),
"name": decode_name(raw.get("SongName")),
"albumName": decode_name(raw.get("AlbumName")),
"albumId": raw.get("AlbumID"),
"songmid": raw.get("Audioid"),
"source": "kg",
"interval": format_play_time(raw.get("Duration")),
"_interval": raw.get("Duration"),
"img": None,
"lrc": None,
"otherSource": None,
"hash": raw.get("FileHash"),
"types": types,
"_types": _types,
"typeUrl": {},
}
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同名同 hash 去重;`Grp` 是同一首歌的其它版本,也一并展开。"""
ids: set[str] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
key = f"{item.get('Audioid')}{item.get('FileHash')}"
if key in ids:
continue
ids.add(key)
result.append(_filter_data(item))
for child in item.get("Grp") or []:
child_key = f"{child.get('Audioid')}{child.get('FileHash')}"
if child_key in ids:
continue
ids.add(child_key)
result.append(_filter_data(child))
return result
async def search(keyword: str, page: int = 1, limit: int = DEFAULT_LIMIT, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = (
f"{_SEARCH_API}?keyword={encode_uri_component(keyword)}&page={page}&pagesize={page_size}"
"&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1"
)
try:
resp = await http_fetch(url)
except Exception: # noqa: BLE001 - 可降级失败:原实现同样重试同类请求
return await search(keyword, page, limit, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("error_code") != 0:
return await search(keyword, page, limit, retry_num + 1)
data = body.get("data") or {}
raw_list = data.get("lists")
if raw_list is None:
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(raw_list)
total = data.get("total") or 0
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"limit": page_size,
"total": total,
"source": "kg",
}
+133
View File
@@ -0,0 +1,133 @@
"""酷狗链接解析(对应 sdk/kg/parse.ts)。
歌曲:`https://www.kugou.com/song/#hash=<32位hash>`(含 &album_id= 等尾串)或裸 hash。
元数据取 `m.kugou.com/app/i/getSongInfo.php`(songName/author_name/timeLength/album_img/bitRate),
再并发合并音乐信息里的专辑与多档 hash;直链优先用 playInfo 的 url,仍为空时用 apis/kg
签名通道尽力补一次 —— 失败只返回元数据(与上游付费曲语义一致)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kg import album, playlist
from app.sdk.platforms.kg.music_info import get_music_info
_UA = (
"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"
)
HASH_RE = re.compile(r"hash=([a-fA-F0-9]{32})", re.IGNORECASE)
RAW_HASH_RE = re.compile(r"^[a-fA-F0-9]{32}$")
def extract_hash(link: Any) -> str:
"""链接/裸 hash → 32 位小写 hash;都不匹配返回空串。"""
raw = str(link if link is not None else "").strip()
match = HASH_RE.search(raw)
if match:
return match.group(1).lower()
return raw.lower() if RAW_HASH_RE.match(raw) else ""
async def _fetch_song_info_php(hash_value: str) -> dict[str, Any] | None:
resp = await http_fetch(
f"http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash_value}",
method="get",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return None
body = resp.body
if int(body.get("errcode") or 0) != 0 or not body.get("hash"):
return None
return body
async def parse_song(link: str) -> dict[str, Any]:
import asyncio
hash_value = extract_hash(link)
if not hash_value:
raise RuntimeError("无效的酷狗歌曲链接(未找到 32 位 hash)")
raw, info = await asyncio.gather(
_fetch_song_info_php(hash_value), get_music_info(hash_value), return_exceptions=True
)
raw = None if isinstance(raw, BaseException) else raw
info = None if isinstance(info, BaseException) else info
if not raw and not info:
raise RuntimeError("酷狗歌曲不存在或已下架")
raw = raw or {}
info = info or {}
file_name = str(raw.get("fileName") or "")
fallback_name = " - ".join(file_name.split(" - ")[1:]) if " - " in file_name else ""
name = decode_name(str(raw.get("songName") or "").strip() or fallback_name or info.get("name") or "")
singer = decode_name(str(raw.get("author_name") or "").strip() or info.get("singer") or "")
duration = int(raw.get("timeLength") or 0) or 0
song: dict[str, Any] = {
"songmid": str(info.get("songmid") or raw.get("audio_id") or "").strip(),
"hash": str(raw.get("hash") or hash_value).lower(),
"name": name,
"singer": singer,
"albumName": decode_name(str(info.get("albumName") or "").strip()),
"albumId": str(info.get("albumId") or raw.get("albumid") or raw.get("req_albumid") or "").strip(),
"interval": format_play_time(duration) if duration > 0 else (info.get("interval") or "--/--"),
"img": str(raw.get("album_img") or "").replace("{size}", "240") or info.get("img") or None,
"lrc": None,
"otherSource": None,
"types": info.get("types") or [],
"_types": info.get("_types") or {},
"typeUrl": {},
"source": "kg",
"extra": {
"hash": str(raw.get("hash") or hash_value).lower(),
"url": str(raw.get("url") or ""),
"backup_url": raw.get("backup_url") or [],
"bitrate": int(raw.get("bitRate") or 0) or 0,
"link": f"https://www.kugou.com/song/#hash={hash_value}",
},
}
# playInfo 的 url/backup_url 是官方直链(付费曲为空),优先使用
if not song.get("url"):
backup = raw.get("backup_url") or []
raw_url = str(raw.get("url") or "") or str(backup[0] if backup else "")
if raw_url:
song["url"] = raw_url
song["type"] = "320k" if (int(raw.get("bitRate") or 0) or 0) >= 320000 else "128k"
# 仍无直链时(风控/付费)用签名通道尽力补一次
if not song.get("url"):
try:
from app.sdk.apis.kg import get_music_url
hit = await get_music_url(
{
"hash": song["hash"],
"albumId": song["albumId"],
"songmid": song["songmid"],
"_types": song["_types"],
},
"128k",
)
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游付费曲无直链的失败语义一致:元数据仍返回
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more