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)
This commit is contained in:
snowy-yang committed 2026-10-21 23:08:04 +08:00
1 parent 76fb812a50
commit 800871ef6e
18 files changed
+47 -1612

No files matched your search

+2 -2
View File
@@ -3,13 +3,13 @@
// 与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口
import crypto from 'node:crypto'
// 流地址有效期(毫秒),与 qs 流缓存 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
// 流地址有效期(毫秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
export const STREAM_SIGN_TTL = 10 * 60_000
const hmac = (authKey: string, data: string) =>
crypto.createHmac('sha256', authKey).update(data).digest('hex').slice(0, 32)
/** 生成流地址的签名参数(path 为不含 query 的路径,如 /stream/qs/123) */
/** 生成流地址的签名参数(path 为不含 query 的路径,如 /stream/mg/123) */
export const signStream = (authKey: string, path: string) => {
const exp = Math.floor((Date.now() + STREAM_SIGN_TTL) / 1000)
return `exp=${exp}&sig=${hmac(authKey, `${path}:${exp}`)}`
+3 -107
View File
@@ -1,11 +1,11 @@
// 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie,qs 扫码/短信/sessionid,
// 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie,
// xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json
// (汽水音乐登录已随 qs 平台拆分至独立服务端 SodaServer)
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
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 * as biliAuth from '../sdk/bili/login.ts'
import { status as xmStatus } from '../sdk/xm/index.ts'
import { status as meStatus } from '../sdk/me/index.ts'
@@ -309,109 +309,6 @@ export const loginRoutes = async(fastify) => {
return { logged: false }
})
// ---------- 汽水音乐 ----------
fastify.post('/login/qs/qr', {
schema: {
summary: '获取汽水音乐扫码登录二维码',
description: '用「汽水音乐 APP」扫一扫(官方 PC 客户端同款文案:打开汽水音乐 APP → 搜索框右侧扫一扫;' +
'抖音 APP 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' +
'返回 qrimg(data URL)可直接展示,token 需原样回传;二维码内容为官方 PC 客户端同款扫码页' +
'(bff-pc.qishui.com/light/invoke/scan_login,另见 scanUrl 字段)。轮询间隔建议 ≥2 秒。' +
'登录后 qs 取流走签名 track_v2,解锁完整曲目。',
tags: ['登录'],
},
}, async(_request, reply) => {
try {
return await qsPassport.createQr()
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs/qr/check', {
schema: {
summary: '轮询汽水音乐扫码状态',
description: 'code:800 二维码过期/被拒 / 801 等待扫码 / 802 已扫码待确认(含需二次验证)/ 803 登录成功(自动保存凭据)。',
tags: ['登录'],
body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] },
},
}, async(request, reply) => {
try {
return await qsPassport.checkQr(request.body.token)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs/sms', {
schema: {
summary: '发送汽水音乐登录短信验证码',
description: '手机号验证码登录第一步(官方 PC 客户端同款 /passport/web/send_code/,type=24)。' +
'mobile 支持 "13800138000" 或自带区号 "86 13800138000";海外号码用 cc 参数指定区号。' +
'发送成功后用 /login/qs/sms/login 提交验证码完成登录。',
tags: ['登录'],
body: {
type: 'object',
properties: { mobile: { type: 'string' }, cc: { type: 'string' } },
required: ['mobile'],
},
},
}, async(request, reply) => {
try {
return await qsPassport.sendSmsCode(request.body.mobile, request.body.cc)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs/sms/login', {
schema: {
summary: '汽水音乐短信验证码登录',
description: '提交 /login/qs/sms 发送的验证码完成登录,成功自动保存凭据并返回 803。' +
'失败经 msg 返回官方文案(1200-1300 为验证码/手机号错误;2046 需去汽水 App 完成安全验证;1075 账号注销中)。',
tags: ['登录'],
body: {
type: 'object',
properties: { mobile: { type: 'string' }, code: { type: 'string' }, cc: { type: 'string' } },
required: ['mobile', 'code'],
},
},
}, async(request, reply) => {
try {
return await qsPassport.smsLogin(request.body.mobile, request.body.code, request.body.cc)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/qs', {
schema: {
summary: '绑定汽水音乐 sessionid',
description: 'sessionid 从汽水 App / 官方 PC 客户端抓包获取(登录态 Cookie 的 sessionid 字段),' +
'也可传完整 cookie 串(须含 sessionid= 字段,整体保存)。无上游校验接口,不做有效性验证,' +
'失效会在取流时报错。推荐优先用 /login/qs/qr 扫码或 /login/qs/sms 短信登录。',
tags: ['登录'],
body: {
type: 'object',
properties: { sessionid: { type: 'string' }, cookie: { type: 'string' } },
},
},
}, async(request, reply) => {
try {
return await qsPassport.loginBySessionId(request.body.sessionid || request.body.cookie)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/qs', {
schema: { summary: '退出汽水音乐登录', tags: ['登录'] },
}, async() => {
clearCredentials('qs')
return { logged: false }
})
// ---------- 喜马拉雅 ----------
fastify.post('/login/xm', {
@@ -559,7 +456,7 @@ export const loginRoutes = async(fastify) => {
schema: {
summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' +
'qs/xm/me/lr/bd/joox/apple/5sing 为已存凭据。',
'xm/me/lr/bd/joox/apple/5sing 为已存凭据。',
tags: ['登录'],
},
}, async() => {
@@ -571,7 +468,6 @@ export const loginRoutes = async(fastify) => {
wy: wy ? { logged: true, ...wy } : { logged: false },
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(),
+10 -26
View File
@@ -183,7 +183,7 @@ export const musicRoutes = async(fastify: any) => {
if (res.encrypted && res.fileKey) {
rawUrl = `/stream/mg/${registerMiguStream(rawUrl, String(res.fileKey))}`
}
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
// mg 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
let url = rawUrl
if (url.startsWith('/')) {
@@ -240,7 +240,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/url/:source/:id', {
schema: {
summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs/xm/me 用声音 id,qt 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。' +
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,xm/me 用声音 id,qt 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。' +
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
@@ -260,41 +260,25 @@ export const musicRoutes = async(fastify: any) => {
},
}, urlHandler)
// 加密流解密代理(qs 汽水解密流 / mg 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。
// - qs:/url/qs 登录态取流返回相对地址 /stream/qs/<trackId>,本端点完成"签名 track_v2 →
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"
// - mg:/url/mg 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/mg/<票据>,本端点凭票下载密流逐字节解密
// 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装
// 加密流解密代理(mg 咪咕 Z3D/I3D 加密流):/url/mg 命中加密候选时登记票据并返回
// /stream/mg/<票据>,本端点凭票下载密流逐字节解密;由 /url 产出相对地址,路由层负责补全 origin;
// raw 豁免 { code, data } 信封包装(汽水解密流已拆分至独立服务端 SodaServer)
fastify.get('/stream/:source/:id', {
config: { raw: true },
schema: {
summary: '加密流解密代理(qs / mg)',
description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/mg 命中加密流时的地址),' +
summary: '加密流解密代理(mg)',
description: '取流响应中返回的相对地址(/url/mg 命中加密流时的地址),' +
'服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' +
'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' +
'source=mg 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。',
'id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。',
tags: ['歌曲资源'],
params: params({
source: { type: 'string', enum: ['qs', 'mg'], description: '平台 id:qs=汽水音乐解密流 / mg=咪咕加密流', example: 'qs' },
id: { type: 'string', description: 'qs: 汽水 track id;mg: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
source: { type: 'string', enum: ['mg'], description: '平台 id:mg=咪咕加密流', example: 'mg' },
id: { type: 'string', description: '取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
}),
},
}, async(request, reply) => {
const { source, id } = request.params
// 汽水:track id → 签名请求 → 下载密音频 → 本地解密为明文
if (source === 'qs') {
try {
const entry = await fastify.musicSdk.qs.getStreamBuffer(String(id))
reply.type(entry.contentType || 'audio/mp4')
reply.header('Accept-Ranges', 'none')
reply.header('Content-Length', entry.buffer.length)
return reply.send(entry.buffer)
} catch (err: any) {
throw upstream(err.message)
}
}
// 咪咕:票据 → 逐块解密流
const ticket = takeMiguStream(String(id))
if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/mg 取流)')
+4 -4
View File
@@ -20,7 +20,7 @@
// GET /playlist/:source/categories[/:categoryId] 歌单分类
// POST /parse/{song|album|playlist}/:source 链接解析
// GET /user-playlist/:source 个人歌单
// GET /stream/:source/:id 解密流代理(source: qs 汽水 / mg 咪咕)
// GET /stream/:source/:id 解密流代理(source: mg 咪咕)
// GET /sources /health GET /docs /redoc /openapi.json
//
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
@@ -48,8 +48,8 @@ const PORT = process.env.PORT || 16688
const DOCS_PREFIX = '/docs'
const API_TITLE = 'MusicServer 聚合音乐 API'
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' +
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。'
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)/ bd(千千)/ ' +
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。汽水音乐已拆分至独立服务端 SodaServer。'
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
@@ -178,7 +178,7 @@ fastify.register(swagger, {
{ name: '榜单', description: '排行榜' },
{ name: '歌单', description: '歌单与热搜' },
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie、qs 抖音扫码)与状态' },
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie)与状态' },
{ name: '系统', description: '健康检查、平台信息、文档' },
],
},