feat: 平台注册与路由收尾——9 平台(含 xm/me/qt/lr 有声)、复合 id 约定、64k 档位、测试与文档

This commit is contained in:
snowy-yang committed 2026-10-02 19:43:21 +08:00
1 parent 6fb866e343
commit 1fec828547
7 files changed
+307 -14

No files matched your search

+120 -1
View File
@@ -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(),
}
})
}
+5 -5
View File
@@ -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 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /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 }),