diff --git a/README.md b/README.md index afe365c..7b8224f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 功能 -- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs) +- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)+ 有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr)(有声平台不参与聚合搜索) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单 @@ -26,9 +26,9 @@ pnpm typecheck # 仅类型检查,不运行 | 接口 | 说明 | | --- | --- | -| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) | +| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有音乐平台,xm 不参与聚合需显式指定) | | `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | -| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 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 /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | | `GET /pic/:source/:id` | 获取封面 | @@ -39,15 +39,20 @@ pnpm typecheck # 仅类型检查,不运行 | `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) | | `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) | -| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx) | +| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx/xm) | | `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) | | `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | | `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) | | `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 | +| `POST /login/kg/password` | 酷狗账号密码登录(触发 20028 风控时返回 needVerify 挑战,配合 `/login/kg/verify` 提交图形/短信验证后重试) | +| `POST /login/kg/verify` | 提交酷狗风控验证结果(verifycode 为图形验证凭证或短信码) | | `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | | `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) | | `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) | | `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(**抖音 App** 扫码,登录后解锁完整曲目与无损取流) | +| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) | +| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) | +| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /sources` | 平台列表(音质与能力) | | `GET /health` | 健康检查 | @@ -62,6 +67,10 @@ pnpm typecheck # 仅类型检查,不运行 - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **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 后取流 +- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流 +- **qt**:`:` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目 +- **lr**:`:
` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态) `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 @@ -94,7 +103,7 @@ export async function getMusicUrl({ source, id, songInfo, quality, signal }) { src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) server.ts HTTP 服务器入口(pnpm start 实际启动文件) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) - routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/token、tx 扫码/cookie、qs 抖音扫码) + routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 抖音扫码、xm/me cookie 绑定) source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK) source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载) diff --git a/sdk/auth/store.ts b/sdk/auth/store.ts index bf7773e..efd1279 100644 --- a/sdk/auth/store.ts +++ b/sdk/auth/store.ts @@ -45,6 +45,28 @@ export interface TxCredentials { updatedAt?: string } +export interface XmCredentials { + // www.ximalaya.com 登录 Cookie(须含 1&_token,从已登录浏览器请求头复制) + // baseInfo 付费内容授权依赖它;无法主动续期,失效后需重新复制绑定 + cookie: string + updatedAt?: string +} + +export interface MeCredentials { + // App 登录 Cookie(须含 token=,从猫耳 App 抓包或已登录 m.missevan.com 浏览器复制) + // App API 付费声音授权依赖它;无法主动续期 + cookie: string + updatedAt?: string +} + +export interface LrCredentials { + // m.lrts.me 登录会话 Cookie(/login/lr 账号密码登录后由服务端保存) + // 取流接口必须携带(免费章节也要求登录态) + cookie: string + phone?: string + updatedAt?: string +} + let cache: Record = {} let loaded = false diff --git a/sdk/index.ts b/sdk/index.ts index 90df692..22cd66a 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -3,11 +3,19 @@ import kg from './kg/index.ts' import tx from './tx/index.ts' import wy from './wy/index.ts' import qs from './qs/index.ts' +import xm from './xm/index.ts' +import me from './me/index.ts' +import qt from './qt/index.ts' +import lr from './lr/index.ts' import { supportQualitys as kwQualitys } from './apis/kw.ts' import { supportQualitys as kgQualitys } from './apis/kg.ts' import { supportQualitys as txQualitys } from './apis/tx.ts' import { supportQualitys as wyQualitys } from './apis/wy.ts' import { supportQualitys as qsQualitys } from './apis/qs.ts' +import { supportQualitys as xmQualitys } from './apis/xm.ts' +import { supportQualitys as meQualitys } from './apis/me.ts' +import { supportQualitys as qtQualitys } from './apis/qt.ts' +import { supportQualitys as lrQualitys } from './apis/lr.ts' import type { PlatformModule, SearchResult, SongInfo } from './types.ts' import { sizeFormate, @@ -22,17 +30,22 @@ import { export { sizeFormate, formatPlayTime, dateFormat, dateFormat2, formatPlayCount, decodeName } const SINGERS_SEPARATOR = /、|&|;|;|\/|,|,|\||\]|\[|!|!/g -const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule }> = [ +// noAggregate:有声/播客类平台不参与聚合搜索与跨平台匹配(xm/me),仅支持 source 直查 +const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule; noAggregate?: boolean }> = [ { id: 'kw', name: '酷我音乐', module: kw }, { id: 'kg', name: '酷狗音乐', module: kg }, { id: 'tx', name: 'QQ音乐', module: tx }, { id: 'wy', name: '网易音乐', module: wy }, { id: 'qs', name: '汽水音乐', module: qs }, + { id: 'xm', name: '喜马拉雅', module: xm, noAggregate: true }, + { id: 'me', name: '猫耳FM', module: me, noAggregate: true }, + { id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true }, + { id: 'lr', name: '懒人听书', module: lr, noAggregate: true }, ] const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module])) -export const sources = SUPPORTED_SOURCES.map(({ id, name }) => ({ id, name })) +export const sources = SUPPORTED_SOURCES.map(({ id, name, noAggregate }) => ({ id, name, ...(noAggregate ? { noAggregate: true } : {}) })) export const init = (): Promise => Promise.all(SUPPORTED_SOURCES.map((s) => s.module.init?.()).filter(Boolean)) @@ -45,6 +58,10 @@ export const supportQuality: Record = { tx: txQualitys, wy: wyQualitys, qs: qsQualitys, + xm: xmQualitys, + me: meQualitys, + qt: qtQualitys, + lr: lrQualitys, } const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string) @@ -96,6 +113,8 @@ export const searchMusic = async ( const tasks: Array> = [] for (const source of SUPPORTED_SOURCES) { if (!source.module.musicSearch) continue + // 有声平台(noAggregate)仅在显式 only 指定时参与搜索 + if (source.noAggregate && source.id !== only) continue if (only ? source.id !== only : source.id === excludeSource) continue tasks.push( source.module.musicSearch diff --git a/sdk/types.ts b/sdk/types.ts index 5290b3b..5aeb601 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -2,7 +2,7 @@ // 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。 /** 音乐平台标识 */ -export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' +export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'xm' | 'me' | 'qt' | 'lr' /** 音质标识(wav 仅部分接口声明使用) */ export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav' diff --git a/src/routes-login.ts b/src/routes-login.ts index 4bde48c..5a0d90a 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -5,6 +5,9 @@ import * as wyAuth from '../sdk/auth/wy.ts' import * as kgAuth from '../sdk/auth/kg.ts' import * as txAuth from '../sdk/auth/tx.ts' import * as qsPassport from '../sdk/qs/passport.ts' +import { status as xmStatus } from '../sdk/xm/index.ts' +import { status as meStatus } from '../sdk/me/index.ts' +import * as lrAuth from '../sdk/lr/auth.ts' import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts' import { refreshAll } from './refresh.ts' @@ -159,6 +162,59 @@ export const loginRoutes = async(fastify) => { } }) + fastify.post('/login/kg/password', { + schema: { + summary: '酷狗账号密码登录', + description: '触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):' + + '滑块类需浏览器完成图形验证(返回的 url/txappid 供前端加载腾讯验证码)后调 /login/kg/verify,' + + '再重试本接口;短信类(v_type=32)将收到的短信码先提交 /login/kg/verify,随后带 code 重试本接口完成登录。' + + '风控较严,频繁失败建议改用扫码或短信登录。', + tags: ['登录'], + body: { + type: 'object', + properties: { + username: { type: 'string' }, + password: { type: 'string' }, + code: { type: 'string', description: '短信验证码(20028 短信验证通过后重试登录时携带)' }, + }, + required: ['username', 'password'], + }, + }, + }, async(request, reply) => { + try { + return await kgAuth.passwordLogin(request.body) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.post('/login/kg/verify', { + schema: { + summary: '提交酷狗风控验证结果', + description: '配合 /login/kg/password 的 needVerify 挑战使用:verifycode 为图形验证码凭证' + + '(格式 KGCodeXX|{ticket,randstr,txappid})或短信验证码。sid/edt 省略时服务端自动生成行为指纹。' + + '验证通过后按挑战类型重试登录(短信码带 code 重试 /login/kg/password)。', + tags: ['登录'], + body: { + type: 'object', + properties: { + eventid: { type: 'string' }, + v_type: { type: 'integer', description: '验证类型(needVerify 返回的 v_type)' }, + verifycode: { type: 'string' }, + sid: { type: 'string' }, + edt: { type: 'string' }, + }, + required: ['eventid', 'v_type', 'verifycode'], + }, + }, + }, async(request, reply) => { + try { + return await kgAuth.verifyUserInfo(request.body) + } catch (err) { + return fail(reply, err) + } + }) + fastify.delete('/login/kg', { schema: { summary: '退出酷狗登录', tags: ['登录'] }, }, async() => { @@ -266,6 +322,66 @@ export const loginRoutes = async(fastify) => { return { logged: false } }) + // ---------- 喜马拉雅 ---------- + + fastify.post('/login/xm', { + schema: { + summary: '绑定喜马拉雅 cookie', + description: 'cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段),' + + '用于解锁 xm 付费/已购内容取流。无法自动续期,失效后需重新复制绑定。', + tags: ['登录'], + body: { + type: 'object', + properties: { cookie: { type: 'string' } }, + required: ['cookie'], + }, + }, + }, async(request, reply) => { + const cookie = String(request.body.cookie || '') + if (!/1&_token=\S+/.test(cookie)) { + return reply.code(422).send({ code: 422, msg: 'cookie 需包含 1&_token 字段' }) + } + setCredentials('xm', { cookie }) + return { logged: true } + }) + + fastify.delete('/login/xm', { + schema: { summary: '退出喜马拉雅登录', tags: ['登录'] }, + }, async() => { + clearCredentials('xm') + return { logged: false } + }) + + // ---------- 猫耳FM ---------- + + fastify.post('/login/me', { + schema: { + summary: '绑定猫耳FM token', + description: 'cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段),' + + '用于解锁 me 付费声音取流(App API 签名通道会自动携带)。无法自动续期,失效后需重新绑定。', + tags: ['登录'], + body: { + type: 'object', + properties: { cookie: { type: 'string' } }, + required: ['cookie'], + }, + }, + }, async(request, reply) => { + const cookie = String(request.body.cookie || '') + if (!/(?:^|;\s*)token=\S+/.test(cookie)) { + return reply.code(422).send({ code: 422, msg: 'cookie 需包含 token 字段' }) + } + setCredentials('me', { cookie }) + return { logged: true } + }) + + fastify.delete('/login/me', { + schema: { summary: '退出猫耳FM登录', tags: ['登录'] }, + }, async() => { + clearCredentials('me') + return { logged: false } + }) + // ---------- 通用 ---------- fastify.post('/login/refresh', { @@ -280,7 +396,7 @@ export const loginRoutes = async(fastify) => { fastify.get('/login/status', { schema: { summary: '查看登录状态', - description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间)。', + description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间),qs/xm/me/lr 为已存凭据。', tags: ['登录'], }, }, async() => { @@ -293,6 +409,9 @@ export const loginRoutes = async(fastify) => { kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false }, tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false }, qs: qsPassport.status(), + xm: xmStatus(), + me: meStatus(), + lr: lrAuth.status(), } }) } diff --git a/src/routes.ts b/src/routes.ts index 50fc829..5ef73f3 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -6,7 +6,7 @@ import { notFound, upstream } from './http/api-error.ts' import { getMusicInfo } from '../sdk/kg/musicInfo.ts' -const QUALITIES = ['128k', '320k', 'flac', 'flac24bit'] +const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit'] /** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */ const unwrap = (result: any): any => ( @@ -65,7 +65,7 @@ export const musicRoutes = async(fastify: any) => { fastify.get('/search', { schema: { summary: '搜索歌曲', - description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。', + description: 'source 省略时聚合全部平台(排除 xm/me/qt/lr 有声平台)搜索,失败的平台会被跳过。', tags: ['搜索'], querystring: qs({ name: { type: 'string', description: '歌名', example: '稻香' }, @@ -94,7 +94,7 @@ export const musicRoutes = async(fastify: any) => { const lists = await Promise.all( musicSdk.sources - .filter(s => musicSdk[s.id]?.musicSearch) + .filter(s => musicSdk[s.id]?.musicSearch && !(s as any).noAggregate) .map(s => doSearch(s.id).catch(() => [])), ) return { source: 'all', list: lists.flat(), page, limit } @@ -188,7 +188,7 @@ export const musicRoutes = async(fastify: any) => { fastify.get('/url/:source/:id', { schema: { summary: '获取播放地址', - description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs 用 track id。' + + description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs/xm/me 用声音 id,qt 为 :(见 /album/qt),lr 为 :
(见 /album/lr)。' + '取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' + '酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' + '其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。', @@ -408,7 +408,7 @@ export const musicRoutes = async(fastify: any) => { fastify.get('/album/:source/:id', { schema: { summary: '获取专辑详情与曲目列表', - description: '支持 kg/kw/mg(见 /sources 的 album 能力位)。kw 单页固定 1000 条、limit 不生效。', + description: '支持 kg/kw/tx/xm/me(见 /sources 的 album 能力位)。kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。', tags: ['歌手专辑'], params: params({ source: pSource('album'), id: { type: 'string', description: '平台专辑 id(搜索结果中的 albumId)' } }), querystring: qs({ page: pPage, limit: pLimit }), diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 8982ee3..ea8822f 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -194,6 +194,76 @@ await test('URL qs 试听 128k', async() => { return r.data.url.slice(0, 60) }) +// xm 匿名免费曲直链(12096669 = 苏荷酒吧我爱的人,免费,2026-10-02 实测可用) +await test('URL xm 免费曲', async() => { + const r = await get('/url/xm/12096669?quality=128k') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.url || !/xmcdn\.com/.test(r.data.url)) throw new Error('非 xmcdn 直链') + return `${r.data.type} ${r.data.url.slice(0, 60)}` +}) + +// me 匿名免费声音明文直链(1557130 = 穿堂惊掠琵琶声·总预告,App API 签名通道,2026-10-02 实测可用) +await test('URL me 免费声音', async() => { + const r = await get('/url/me/1557130?quality=128k') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.url || !/maoercdn\.com/.test(r.data.url)) throw new Error('非 maoercdn 直链') + return `${r.data.type} ${r.data.url.slice(0, 60)}` +}) + +// qt 匿名节目直链(304214:12302057 = 诡吹灯续·片花,audiostream 签名通道,2026-10-02 实测可用) +await test('URL qt 免费节目', async() => { + const r = await get('/url/qt/304214:12302057?quality=64k') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.url || !/audiostream\/redirect/.test(r.data.url)) throw new Error('非 audiostream 签名直链') + return `${r.data.type} ${r.data.url.slice(0, 70)}` +}) + +// qt/lr 直查 + 聚合排除 +await test('搜索 qt 直查', async() => { + const r = await get(`/search?source=qt&name=${encodeURIComponent('郭德纲')}&limit=3`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('响应不完整') + return `首条 ${r.data.list[0].name}` +}) + +await test('搜索 lr 直查', async() => { + const r = await get(`/search?source=lr&name=${encodeURIComponent('三体')}&limit=3`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('响应不完整') + return `首条 ${r.data.list[0].name}` +}) + +// lr 未登录取流应返回明确错误(502 上游) +await test('URL lr 未登录 → 明确错误', async() => { + const r = await getRaw('/url/lr/50678018:1?quality=64k') + if (r.status !== 502) throw new Error(`期望 502,实际 ${r.status}`) + if (!JSON.parse(r.body).msg.includes('/login/lr')) throw new Error(`错误信息应引导登录,实际 ${JSON.parse(r.body).msg}`) + return JSON.parse(r.body).msg.slice(0, 40) +}) + +// me 有声内容:直查可用、聚合排除 +await test('搜索 me 直查', async() => { + const r = await get(`/search?source=me&name=${encodeURIComponent('广播剧')}&limit=3`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('响应不完整') + return `首条 ${r.data.list[0].name}` +}) + +// xm 有声内容:直查可用、聚合排除 +await test('搜索 xm 直查', async() => { + const r = await get(`/search?source=xm&name=${encodeURIComponent('郭德纲')}&limit=3`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('响应不完整') + return `首条 ${r.data.list[0].name}` +}) + +await test('聚合搜索排除 xm', async() => { + const r = await get(`/search?name=${encodeURIComponent('郭德纲')}&limit=2`) + if (r.code != 0) throw new Error(r.msg) + if (r.data?.list?.some((item: any) => ['xm', 'me', 'qt', 'lr'].includes(item.source))) throw new Error('聚合结果不应包含有声平台') + return 'ok' +}) + // 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道) await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => { const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29') @@ -357,6 +427,38 @@ await test('专辑详情 /album/tx', async() => { return `${r.data.info.name}(${r.data.list.length} 首)` }) +// xm 专辑(3595841 = 苏荷酒吧专辑,2026-10-02 实测可用) +await test('专辑详情 /album/xm', async() => { + const r = await get('/album/xm/3595841') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整') + return `${r.data.info.name}(${r.data.info.total} 集)` +}) + +// me 剧集(25276 = 穿堂惊掠琵琶声,2026-10-02 实测可用) +await test('专辑详情 /album/me', async() => { + const r = await get('/album/me/25276') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整') + return `${r.data.info.name}(${r.data.info.total} 集)` +}) + +// qt 频道(304214 = 诡吹灯续,2026-10-02 实测可用) +await test('专辑详情 /album/qt', async() => { + const r = await get('/album/qt/304214') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length || !String(r.data.list[0].songmid).includes(':')) throw new Error('响应不完整或缺少复合 id') + return `${r.data.list.length} 节目,首条 ${r.data.list[0].name}` +}) + +// lr 书籍章节(50678018 = 三体广播剧,2026-10-02 实测可用) +await test('专辑详情 /album/lr', async() => { + const r = await get('/album/lr/50678018') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length || !String(r.data.list[0].songmid).includes(':')) throw new Error('响应不完整或缺少复合 id') + return `${r.data.info.total} 节` +}) + await test('参数校验 422(缺少 name)', async() => { const r = await getRaw('/search') if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) @@ -473,6 +575,28 @@ await test('kg 假 token 拒绝 POST /login/kg', async() => { return r.body?.msg }) +// xm cookie 绑定校验(缺 1&_token 拒绝) +await test('xm 坏 cookie 拒绝 POST /login/xm', async() => { + const r = await postJson('/login/xm', { cookie: 'foo=bar; baz=1' }) + if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) + return `status ${r.status}` +}) + +// me cookie 绑定校验(缺 token 拒绝) +await test('me 坏 cookie 拒绝 POST /login/me', async() => { + const r = await postJson('/login/me', { cookie: 'foo=bar; baz=1' }) + if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) + return `status ${r.status}` +}) + +// xm 登录状态展示 +await test('登录状态含 xm 字段', async() => { + const r = await get('/login/status') + if (r.code != 0) throw new Error(r.msg) + if (!('xm' in (r.data ?? {})) || !('logged' in r.data.xm)) throw new Error('xm 登录状态缺失') + return `xm.logged=${r.data.xm.logged}` +}) + // 汇总 const passed = results.filter(r => r.ok).length const failed = results.filter(r => !r.ok)