From 47ce651f46ca4b1cceb7dbd6eca90963e8bd968f Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:23:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=20P0/P1=20?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=EF=BC=88=E5=90=AB=E6=B5=8B=E8=AF=95=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查明细见 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 路由接入与相应用例),一并随本提交入库。 --- sdk/auth/store.ts | 42 ++++- sdk/kg/krcDecode.ts | 4 +- sdk/kg/lyric.ts | 3 +- sdk/kw/songList.ts | 6 +- sdk/lr/index.ts | 3 +- src/http/migu-stream.ts | 120 ++++++++++++++ src/routes-login.ts | 96 +++++++++++- src/routes.ts | 336 ++++++++++++++++++++++++++++++++++++---- src/server.ts | 26 ++-- test/smoke-test.ts | 262 +++++++++++++++++++++++++++++-- 10 files changed, 825 insertions(+), 73 deletions(-) create mode 100644 src/http/migu-stream.ts diff --git a/sdk/auth/store.ts b/sdk/auth/store.ts index efd1279..defda4f 100644 --- a/sdk/auth/store.ts +++ b/sdk/auth/store.ts @@ -1,12 +1,17 @@ // 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库) -// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} } -// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build +// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} } +// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换, +// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。 +// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。 -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json') +const DATA_DIR = process.env.DATA_DIR + ? process.env.DATA_DIR + : join(dirname(fileURLToPath(import.meta.url)), '../../data') +const DATA_FILE = join(DATA_DIR, 'auth.json') export interface WyCredentials { cookie: string @@ -72,9 +77,23 @@ let loaded = false const load = (): Record => { if (loaded) return cache + if (!existsSync(DATA_FILE)) { + cache = {} + loaded = true + return cache + } try { - cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {} - } catch { + cache = JSON.parse(readFileSync(DATA_FILE, 'utf8')) + } catch (err) { + // 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救; + // 否则下一次 setCredentials 就会用空表覆盖掉本可恢复的文件。 + const backup = `${DATA_FILE}.corrupt-${Date.now()}` + try { + renameSync(DATA_FILE, backup) + console.error(`[auth/store] ${DATA_FILE} 解析失败,已备份为 ${backup},本次以空凭据启动: ${(err as Error).message}`) + } catch { + console.error(`[auth/store] ${DATA_FILE} 解析失败且备份未成功,本次以空凭据启动: ${(err as Error).message}`) + } cache = {} } loaded = true @@ -85,7 +104,16 @@ const save = (data: Record): void => { cache = data loaded = true mkdirSync(dirname(DATA_FILE), { recursive: true }) - writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)) + // 原子写:先落同目录临时文件(0600,仅属主可读),再 rename 覆盖。 + // 直接覆写会在进程中途被杀时留下截断 JSON —— 那正是「凭据全丢」的触发路径。 + const tmp = `${DATA_FILE}.${process.pid}.tmp` + try { + writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }) + renameSync(tmp, DATA_FILE) + } catch (err) { + try { unlinkSync(tmp) } catch { /* 清理失败不掩盖原始错误 */ } + throw err + } } export const getCredentials = (platform: string): any => load()[platform] ?? null diff --git a/sdk/kg/krcDecode.ts b/sdk/kg/krcDecode.ts index 49db809..ed25a8a 100644 --- a/sdk/kg/krcDecode.ts +++ b/sdk/kg/krcDecode.ts @@ -7,7 +7,9 @@ import { decodeName } from '../common/utils.ts' const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69]) const decodeLyric = str => new Promise((resolve, reject) => { - if (!str.length) return + // 空歌词必须显式 resolve:executor 里裸 return 会让 Promise 永久 pending, + // 调用方的 await 将一直挂起(连接与内存无法释放,只能等客户端超时) + if (!str.length) return resolve('') const buf_str = Buffer.from(str, 'base64').subarray(4) for (let i = 0, len = buf_str.length; i < len; i++) { buf_str[i] = buf_str[i] ^ enc_key[i % 16] diff --git a/sdk/kg/lyric.ts b/sdk/kg/lyric.ts index cbff5b8..12781e3 100644 --- a/sdk/kg/lyric.ts +++ b/sdk/kg/lyric.ts @@ -69,7 +69,8 @@ export default { requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => { if (statusCode !== 200) { if (tryNum > 5) return Promise.reject(new Error('歌词获取失败')) - let tryRequestObj = (this.getLyric as any)(id, accessKey, fmt, ++tryNum) + // 重试必须回到本方法 getLyricDownload;原先误调 getLyric(参数语义不同)导致重试恒失败 + const tryRequestObj = this.getLyricDownload(id, accessKey, fmt, ++tryNum) requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) return tryRequestObj.promise } diff --git a/sdk/kw/songList.ts b/sdk/kw/songList.ts index 6163321..53e05d0 100644 --- a/sdk/kw/songList.ts +++ b/sdk/kw/songList.ts @@ -193,14 +193,16 @@ export default { if (tryNum > 2) return Promise.reject(new Error('try max num')) const requestObj = httpFetch(`http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node=${id}&pn=0&rn=1&fmt=json&src=mbox&level=2`) return requestObj.promise.then(({ statusCode, body }) => { - if (statusCode != 200 || !body.child) return this.getListDetail(id, ++tryNum) + // 本方法须返回 detailId(调用方据此再查曲目),失败时应重试自身; + // 原先误调 getListDetail(返回歌单详情对象、且把 tryNum 当 page)导致返回类型错位 + if (statusCode != 200 || !body.child) return this.getListDetailDigest5Info(id, ++tryNum) // console.log(body) return body.child.length ? body.child[0].sourceid : null }) }, getListDetailDigest5Music(id, page, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) - const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`) + const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`) return requestObj.promise.then(({ body }) => { // console.log(body) if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum) diff --git a/sdk/lr/index.ts b/sdk/lr/index.ts index 3ed9307..c1a6bb3 100644 --- a/sdk/lr/index.ts +++ b/sdk/lr/index.ts @@ -50,5 +50,6 @@ const lr = { }, } -export { status } from './auth.ts' +// login 一并导出:/login/lr 路由直接引用 sdk/lr/auth.ts,SDK 使用者也需要能拿到登录能力 +export { login, status } from './auth.ts' export default lr diff --git a/src/http/migu-stream.ts b/src/http/migu-stream.ts new file mode 100644 index 0000000..111a70e --- /dev/null +++ b/src/http/migu-stream.ts @@ -0,0 +1,120 @@ +// 咪咕加密流票据 —— /url/migu 命中 Z3D/I3D 加密候选时登记,/stream/migu/:id 凭票解密 +// (统一入口为 /stream/:source/:id,source=migu 时 id 即此处返回的票据)。 +// 不把上游 URL 塞进播放地址(Fastify path 参数限长,且 URL 含时效签名与中文路径), +// 票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。 +import { randomUUID } from 'node:crypto' +import { get as httpGet } from 'node:http' +import { get as httpsGet } from 'node:https' + +interface MiguStreamTicket { + url: string + fileKey: string + createdAt: number +} + +const TTL_MS = 10 * 60 * 1000 +const STREAM_CACHE_LIMIT = 200 +const tickets = new Map() + +const prune = () => { + const now = Date.now() + for (const [id, ticket] of tickets) { + if (now - ticket.createdAt > TTL_MS) tickets.delete(id) + } + // 超出容量时按插入顺序淘汰最旧票据 + while (tickets.size > STREAM_CACHE_LIMIT) { + const oldest = tickets.keys().next().value + if (oldest == null) break + tickets.delete(oldest) + } +} + +/** 登记一条加密流,返回可直接放入 /stream/migu/:id 的短票据 id */ +export const registerMiguStream = (url: string, fileKey: string): string => { + prune() + const id = randomUUID().replace(/-/g, '') + tickets.set(id, { url, fileKey, createdAt: Date.now() }) + return id +} + +/** 取出票据(过期返回 null) */ +export const takeMiguStream = (id: string): MiguStreamTicket | null => { + const ticket = tickets.get(id) + if (!ticket) return null + if (Date.now() - ticket.createdAt > TTL_MS) { + tickets.delete(id) + return null + } + return ticket +} + +const MIGU_STREAM_UA = 'Android_migu/7.41.13 okhttp/3.12.13' + +/** 按扩展名推断明文的 content-type */ +export const miguStreamContentType = (url: string): string => { + if (url.includes('.flac')) return 'audio/flac' + if (url.includes('.wav')) return 'audio/wav' + if (url.includes('.m4a')) return 'audio/mp4' + return 'audio/mpeg' +} + +/** + * 流式下载密流并逐块解密后写入响应。 + * 咪咕 Z3D 的解密是逐字节 `plain[i] = cipher[i] - streamKey[i % 32]`, + * 因此可按块处理(保持全局偏移),无需把整段音频(可达上百 MB,超过 httpFetch 的缓冲上限)读进内存。 + * @param decryptChunk 处理单块(参数:密文块、块在流中的起始偏移),返回明文块 + */ +export const pipeDecryptedMiguStream = ( + url: string, + transform: (chunk: Buffer, offset: number) => Buffer, + onChunk: (plain: Buffer, offset: number) => void, + redirectsLeft = 3, +): Promise => new Promise((resolve, reject) => { + const get = url.startsWith('https:') ? httpsGet : httpGet + // 只 settle 一次:异常后不再 resolve/reject,避免二次结算被忽略或覆盖真实错误 + let settled = false + const settle = (fn: () => void) => { + if (settled) return + settled = true + fn() + } + const req = get(url, { + headers: { 'User-Agent': MIGU_STREAM_UA, Referer: 'https://music.migu.cn/' }, + }, (res: any) => { + const status = res.statusCode ?? 0 + if (status >= 300 && status < 400 && res.headers.location) { + res.resume() + if (redirectsLeft <= 0) return settle(() => reject(new Error('重定向次数过多'))) + const next = new URL(res.headers.location, url).toString() + return pipeDecryptedMiguStream(next, transform, onChunk, redirectsLeft - 1).then( + () => settle(resolve), + err => settle(() => reject(err)), + ) + } + if (status !== 200) { + res.resume() + return settle(() => reject(new Error(`上游返回 ${status}`))) + } + let offset = 0 + // transform/onChunk 由调用方提供(路由层会在解密结果非音频时 throw)。 + // 它们在 data 事件回调里同步执行:若直接抛出会逃逸成 uncaughtException 并终止整个进程, + // 因此必须就地捕获、转成 reject,并 destroy 上游停止后续读取。 + const fail = (err: unknown) => settle(() => { + res.destroy() + reject(err) + }) + res.on('data', (chunk: Buffer) => { + if (settled) return + try { + const plain = transform(chunk, offset) + offset += chunk.length + onChunk(plain, offset - plain.length) + } catch (err) { + fail(err) + } + }) + res.on('end', () => settle(resolve)) + res.on('error', fail) + }) + req.on('error', (err: unknown) => settle(() => reject(err))) +}) diff --git a/src/routes-login.ts b/src/routes-login.ts index 5a0d90a..5dc56f3 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -5,8 +5,13 @@ 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' +import { status as qianStatus } from '../sdk/qian/index.ts' +import { status as jooxStatus } from '../sdk/joox/index.ts' +import { status as appleStatus } from '../sdk/apple/index.ts' +import { status as fivesingStatus } from '../sdk/5sing/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' @@ -288,8 +293,11 @@ export const loginRoutes = async(fastify) => { fastify.post('/login/qs/qr', { schema: { summary: '获取汽水音乐扫码登录二维码', - description: '用「抖音 APP」扫码(汽水 App 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' + - '返回 qrimg(data URL)可直接展示,token 需原样回传。登录后 qs 取流走签名 track_v2,解锁完整曲目。', + 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) => { @@ -303,7 +311,7 @@ export const loginRoutes = async(fastify) => { fastify.post('/login/qs/qr/check', { schema: { summary: '轮询汽水音乐扫码状态', - description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据)。', + description: 'code:800 二维码过期/被拒 / 801 等待扫码 / 802 已扫码待确认(含需二次验证)/ 803 登录成功(自动保存凭据)。', tags: ['登录'], body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] }, }, @@ -382,6 +390,78 @@ export const loginRoutes = async(fastify) => { return { logged: false } }) + // ---------- 懒人听书 ---------- + + fastify.post('/login/lr', { + schema: { + summary: '懒人听书账号登录', + description: 'm.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化到 data/auth.json,' + + '此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。', + tags: ['登录'], + body: { + type: 'object', + properties: { + account: { type: 'string', description: '账号(手机号)' }, + password: { type: 'string', description: '密码(明文传入,服务端自行编码)' }, + }, + required: ['account', 'password'], + }, + }, + }, async(request, reply) => { + const { account, password } = request.body + try { + return await lrAuth.login({ account, password }) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.delete('/login/lr', { + schema: { summary: '退出懒人听书登录', tags: ['登录'] }, + }, async() => { + clearCredentials('lr') + return { logged: false } + }) + + // ---------- 哔哩哔哩 ---------- + + fastify.post('/login/bili/qr', { + schema: { + summary: '获取哔哩哔哩扫码登录二维码', + description: '用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。', + tags: ['登录'], + }, + }, async(request, reply) => { + try { + return await biliAuth.createQRLogin() + } catch (err) { + return fail(reply, err) + } + }) + + fastify.post('/login/bili/qr/check', { + schema: { + summary: '轮询哔哩哔哩扫码状态', + description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' + + '/ expired 二维码过期 / failed 失败。code 为 B站原始返回码(86101/86038/86090/0)。', + tags: ['登录'], + body: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] }, + }, + }, async(request, reply) => { + try { + return await biliAuth.checkQRLogin(request.body.key) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.delete('/login/bili', { + schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] }, + }, async() => { + clearCredentials('bili') + return { logged: false } + }) + // ---------- 通用 ---------- fastify.post('/login/refresh', { @@ -396,13 +476,14 @@ export const loginRoutes = async(fastify) => { fastify.get('/login/status', { schema: { summary: '查看登录状态', - description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间),qs/xm/me/lr 为已存凭据。', + description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' + + 'qs/xm/me/lr/qian/joox/apple/5sing 为已存凭据。', tags: ['登录'], }, }, async() => { const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())]) const tx = getTxAuth() - const txCred = getCredentials('tx') as { vipType?: string; updatedAt?: string } | null + const txCred = getCredentials('tx') as { vipType?: string, updatedAt?: string } | null const kgCred = getCredentials('kg') as { updatedAt?: string } | null return { wy: wy ? { logged: true, ...wy } : { logged: false }, @@ -412,6 +493,11 @@ export const loginRoutes = async(fastify) => { xm: xmStatus(), me: meStatus(), lr: lrAuth.status(), + bili: biliAuth.status(), + qian: qianStatus(), + joox: jooxStatus(), + apple: appleStatus(), + '5sing': fivesingStatus(), } }) } diff --git a/src/routes.ts b/src/routes.ts index b86d60c..a85690a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,6 +5,7 @@ import { notFound, upstream } from './http/api-error.ts' import { signStream } from './http/stream-sign.ts' +import { registerMiguStream, takeMiguStream } from './http/migu-stream.ts' import { getMusicInfo } from '../sdk/kg/musicInfo.ts' const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit'] @@ -48,12 +49,29 @@ const pId = { type: 'string', description: '平台歌曲 id', example: '28423286 const qs = (properties: any, required: string[] = []) => ({ type: 'object', properties, required }) const params = (properties: any, required?: string[]) => qs(properties, required ?? Object.keys(properties)) +/** 能力位取值:支持 `album.searchAlbum` 形式的嵌套路径 */ +const capabilityOf = (m: any, capability?: string) => { + if (!m || !capability) return m + let cur = m + for (const key of capability.split('.')) { + if (cur == null) return undefined + cur = cur[key] + } + return cur +} + const getSource = (musicSdk: any, name: string, capability?: string) => { const m = musicSdk[name] - if (!m || (capability && !m[capability])) throw notFound(`不支持的平台: ${name}`) + if (!m || !capabilityOf(m, capability)) throw notFound(`不支持的平台: ${name}`) return m } +/** 歌单分类返回形状不一({list}/裸数组),统一为 { source, list } */ +const normalizeCategories = (source: string, res: any) => { + const list = Array.isArray(res) ? res : (res?.list ?? []) + return { source, list } +} + /** * 注册全部业务路由 * @param {import('fastify').FastifyInstance} fastify @@ -160,9 +178,14 @@ export const musicRoutes = async(fastify: any) => { // 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配) const finish = (res: any, via: string) => { if (!res?.url) throw new Error('获取播放地址失败') + // 加密流(migu 的 Z3D/I3D):客户端无法自行解密,改指向服务端解密代理 /stream/migu/<票据> + let rawUrl = String(res.url) + if (res.encrypted && res.fileKey) { + rawUrl = `/stream/migu/${registerMiguStream(rawUrl, String(res.fileKey))}` + } // qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器); // 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身) - let url = String(res.url) + let url = rawUrl if (url.startsWith('/')) { const suffix = process.env.AUTH_KEY ? `${url.includes('?') ? '&' : '?'}${signStream(process.env.AUTH_KEY, url.split('?')[0])}` @@ -237,27 +260,69 @@ export const musicRoutes = async(fastify: any) => { }, }, urlHandler) - // qs 解密流代理:登录态取流返回 /stream/qs/:id 相对地址,本端点完成"签名 track_v2 → - // 密音频下载 → spade key + AES-CTR 解密 → 回明文流"。raw 豁免 { code, data } 信封包装 - fastify.get('/stream/qs/:id', { + // 加密流解密代理(qs 汽水解密流 / migu 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。 + // - qs:/url/qs 登录态取流返回相对地址 /stream/qs/,本端点完成"签名 track_v2 → + // 密音频下载 → spade key + AES-CTR 解密 → 回明文流" + // - migu:/url/migu 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/migu/<票据>,本端点凭票下载密流逐字节解密 + // 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装 + fastify.get('/stream/:source/:id', { config: { raw: true }, schema: { - summary: '汽水音乐解密流代理', - description: '登录态取流地址(/url/qs 返回的相对地址)。服务端完成 track_v2 签名请求与 cenc-aes-ctr 本地解密,返回明文音频。', + summary: '加密流解密代理(qs / migu)', + description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/migu 命中加密流时的地址),' + + '服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' + + 'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' + + 'source=migu 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/migu 取流)。', tags: ['歌曲资源'], - params: params({ id: pId }), + params: params({ + source: { type: 'string', enum: ['qs', 'migu'], description: '平台 id:qs=汽水音乐解密流 / migu=咪咕加密流', example: 'qs' }, + id: { type: 'string', description: 'qs: 汽水 track id;migu: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' }, + }), }, }, async(request, reply) => { - const { id } = request.params - try { - const entry = await fastify.musicSdk.qs.getStreamBuffer(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 { 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/migu 取流)') + // 流式下载 + 逐块解密:咪咕 Z3D 密钥流按 32 字节循环,块内保持全局偏移即可, + // 避免把上百 MB 的 wav/flac 整段读进内存(httpFetch 有 64MB 缓冲上限) + const { miguZ3DStreamKey, isMiguAudioMagic } = await import('../sdk/migu/util.ts') + const { pipeDecryptedMiguStream, miguStreamContentType } = await import('./http/migu-stream.ts') + const streamKey = miguZ3DStreamKey(ticket.fileKey) + reply.raw.writeHead(200, { + 'Content-Type': miguStreamContentType(ticket.url), + 'Accept-Ranges': 'none', + 'Transfer-Encoding': 'chunked', + }) + try { + await pipeDecryptedMiguStream(ticket.url, (chunk, offset) => { + const plain = Buffer.alloc(chunk.length) + for (let i = 0; i < chunk.length; i++) plain[i] = chunk[i]! - streamKey[(offset + i) & 31]! + return plain + }, (plain, offset) => { + if (offset === 0 && !isMiguAudioMagic(plain)) throw new Error('解密结果不是有效音频') + reply.raw.write(plain) + }) + reply.raw.end() + } catch (err: any) { + // 响应头已发出(流式),只能中断连接;未发出时走统一错误处理 + reply.raw.destroy(new Error(err?.message || 'migu 解密流失败')) + } + return reply }) const lyricHandler = async(request) => { @@ -382,7 +447,8 @@ export const musicRoutes = async(fastify: any) => { page: pPage, }, withPage ? undefined : ['source']), querystring: qs({ - limit: pLimit, + // 注:本接口无 limit —— 各平台的 getList 签名不一致(如 kw 为 (sortId, tagId, page, tryNum)), + // 条数由平台接口自身决定,无法统一透传;此前 schema 声明 limit 但实现忽略,属文档说谎 sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' }, tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' }, }), @@ -447,7 +513,8 @@ export const musicRoutes = async(fastify: any) => { const albumSchema = (withPage: boolean) => ({ summary: '获取专辑详情与曲目列表', - description: '支持 kg/kw/tx/xm/me(见 /sources 的 album 能力位)。kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。', + description: '支持 kg/kw/tx/wy/xm/me/qt/lr/migu/qian/joox/apple(见 /sources 的 album 能力位)。' + + 'kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。', tags: ['歌手专辑'], params: params({ source: pSource('album'), @@ -468,6 +535,205 @@ export const musicRoutes = async(fastify: any) => { return album.getAlbumListDetail(id, page).catch(err => { throw upstream(err.message) }) }) + fastify.get('/album/:source/search/:keyword', { + schema: { + summary: '搜索专辑', + description: '支持 kg/kw/tx/wy/migu/qian/joox/apple(见 /sources 的 albumSearch 能力位)。', + tags: ['歌手专辑'], + params: params({ source: pSource('album.searchAlbum'), keyword: { type: 'string', description: '专辑名关键词', example: '魔杰座' } }), + querystring: qs({ page: pPage, limit: pLimit }), + response: responses('AlbumDetailResult', { notFoundDoc: false }), + }, + }, async(request) => { + const { source, keyword } = request.params + const { page, limit } = request.query + const m = getSource(musicSdk, source, 'album.searchAlbum').album + try { + const res = await m.searchAlbum(keyword, page, limit) + // 各平台返回形状不一(有的 {list,total},有的裸数组),统一成列表形状 + if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length } + return { source, ...res } + } catch (err: any) { + throw upstream(err.message) + } + }) + + // ---------- 歌单(搜索 / 详情 / 分类) ---------- + + fastify.get('/playlist/:source/search/:keyword', { + schema: { + summary: '搜索歌单', + description: '支持 kg/kw/tx/wy/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。' + + '部分平台该能力受限(如 qian 歌单搜索接口已下线、bili 需登录),失败时返回 502。', + tags: ['歌单'], + params: params({ source: pSource('playlist.searchPlaylist'), keyword: { type: 'string', example: '华语' } }), + querystring: qs({ page: pPage, limit: pLimit }), + response: responses('PlaylistSearchResult'), + }, + }, async(request) => { + const { source, keyword } = request.params + const { page, limit } = request.query + const m = getSource(musicSdk, source, 'playlist.searchPlaylist').playlist + try { + const res = await m.searchPlaylist(keyword, page, limit) + if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length } + return { source, ...res } + } catch (err: any) { + throw upstream(err.message) + } + }) + + fastify.get('/playlist/:source/categories', { + schema: { + summary: '获取歌单分类', + description: '支持 kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。', + tags: ['歌单'], + params: params({ source: pSource('playlist.getCategories') }), + response: responses('PlaylistCategoriesResult'), + }, + }, async(request) => { + const m = getSource(musicSdk, request.params.source, 'playlist.getCategories').playlist + try { + return normalizeCategories(request.params.source, await m.getCategories()) + } catch (err: any) { + throw upstream(err.message) + } + }) + + const categoriesResult = async(source: string, categoryId: string, page: number, limit: number) => { + const m = getSource(musicSdk, source, 'playlist').playlist + if (typeof m.getCategoryPlaylists !== 'function') throw notFound(`平台 ${source} 不支持分类歌单`) + try { + const res = await m.getCategoryPlaylists(categoryId, page, limit) + if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length } + return { source, ...res } + } catch (err: any) { + throw upstream(err.message) + } + } + + fastify.get('/playlist/:source/categories/:categoryId', { + schema: { + summary: '获取分类下的歌单', + description: 'categoryId 为 /playlist/:source/categories 返回的分类 id(部分平台 id 即分类名)。', + tags: ['歌单'], + params: params({ + source: pSource('playlist.getCategories'), + categoryId: { type: 'string', description: '分类 id(分类列表返回)', example: '华语' }, + }), + querystring: qs({ page: pPage, limit: pLimit }), + response: responses('PlaylistSearchResult'), + }, + }, async(request) => categoriesResult( + request.params.source, request.params.categoryId, request.query.page, request.query.limit, + )) + + const playlistSchema = (withPage: boolean) => ({ + summary: '获取歌单详情与曲目列表', + description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:::、' + + 'joox/qian/migu 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。', + tags: ['歌单'], + params: params({ + source: pSource('playlist'), + id: { type: 'string', description: '歌单 id(或平台歌单链接)', example: '123456789' }, + page: pPage, + }, withPage ? undefined : ['source', 'id']), + querystring: qs({ limit: pLimit }), + response: responses('PlaylistDetailResult'), + }) + pagedGet('/playlist/:source/:id', playlistSchema, async(request) => { + const { source, id } = request.params + const { limit } = request.query + const page = Number(request.params.page) || 1 + const m = getSource(musicSdk, source, 'playlist').playlist + try { + // 上游/移植的详情接口普遍自带分页(page, limit) + return await m.getPlaylistDetail(id, page, limit) + } catch (err: any) { + // 部分平台详情签名只有 id:退化为取全量后本地切片 + try { + const songs = await m.getPlaylistSongs(id) + if (!Array.isArray(songs)) throw err + const start = (page - 1) * limit + return { + source, + info: { name: id, img: '', desc: '', author: '' }, + list: songs.slice(start, start + limit), + page, + limit, + total: songs.length, + } + } catch { + throw upstream(err.message) + } + } + }) + + // ---------- 链接解析(歌曲/专辑/歌单链接 → 结构化数据) ---------- + + const parseBodySchema = { type: 'object', properties: { link: { type: 'string', description: '平台链接或裸 id' } }, required: ['link'] } + + const parseHandler = (kind: 'song' | 'album' | 'playlist') => async(request: any) => { + const { source } = request.params + const link = String(request.body?.link ?? '').trim() + const m = getSource(musicSdk, source, 'parse') + const impl = m.parse?.[`parse${kind[0]!.toUpperCase()}${kind.slice(1)}`] + if (typeof impl !== 'function') throw notFound(`平台 ${source} 不支持解析该类型链接`) + try { + const out = await impl(link) + // 统一响应:{ source, type, song | playlist+list } + if (kind === 'song') { + if (out?.list || out?.songs) { + const list = out.list ?? out.songs + return { source, type: 'playlist', playlist: out.info ?? out.playlist ?? null, list, total: list.length } + } + return { source, type: 'song', song: out } + } + const list = out?.list ?? out?.songs ?? [] + return { source, type: kind, playlist: out?.info ?? out?.playlist ?? out, list, total: list.length } + } catch (err: any) { + throw upstream(err.message) + } + } + + for (const kind of ['song', 'album', 'playlist'] as const) { + fastify.post(`/parse/${kind}/:source`, { + schema: { + summary: `解析${kind === 'song' ? '歌曲' : kind === 'album' ? '专辑' : '歌单'}链接`, + description: '把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;' + + '5sing 歌曲/歌单、apple/bili/joox/qian/migu 三类、kw/kg/tx/wy 部分支持。', + tags: ['链接解析'], + params: params({ source: pSource('parse.parseSong') }), + body: parseBodySchema, + response: responses('ParseResult'), + }, + }, parseHandler(kind)) + } + + // ---------- 个人歌单(需登录态) ---------- + + fastify.get('/user-playlist/:source', { + schema: { + summary: '获取个人歌单列表', + description: '需先登录(见 /login/*)。当前仅 tx/wy 有完整实现(见 /sources 的 userPlaylist 能力位);' + + '未登录或平台不支持时返回 404/502。', + tags: ['歌单'], + params: params({ source: pSource('userPlaylist.getList') }), + querystring: qs({ page: pPage, limit: pLimit }), + response: responses('UserPlaylistResult'), + }, + }, async(request) => { + const { source } = request.params + const { page, limit } = request.query + const m = getSource(musicSdk, source, 'userPlaylist.getList').userPlaylist + try { + const list = await m.getList(page, limit) + return { source, list: Array.isArray(list) ? list : (list?.list ?? []), page, limit } + } catch (err: any) { + throw upstream(err.message) + } + }) + // ---------- 系统 ---------- fastify.get('/health', { @@ -494,22 +760,24 @@ export const musicRoutes = async(fastify: any) => { platforms: musicSdk.sources.map(({ id, name }) => { const m = musicSdk[id] || {} const qualities = musicSdk.supportQuality?.[id] ?? [] - return { - id, - name, - qualities, - capabilities: { - search: Boolean(m.musicSearch), - url: Boolean(m.getMusicUrl), - lyric: Boolean(m.getLyric), - pic: Boolean(m.getPic), - boards: Boolean(m.leaderboard), - hotSearch: Boolean(m.hotSearch), - songList: Boolean(m.songList), - singer: Boolean(m.singer), - album: Boolean(m.album), - }, + const caps = { + search: Boolean(m.musicSearch), + url: Boolean(m.getMusicUrl), + lyric: Boolean(m.getLyric), + pic: Boolean(m.getPic), + boards: Boolean(m.leaderboard), + hotSearch: Boolean(m.hotSearch), + songList: Boolean(m.songList), + singer: Boolean(m.singer), + album: Boolean(m.album?.getAlbumDetail || m.album?.getAlbumListDetail), + albumSearch: Boolean(m.album?.searchAlbum), + playlist: Boolean(m.playlist?.getPlaylistDetail), + playlistSearch: Boolean(m.playlist?.searchPlaylist), + playlistCategories: Boolean(m.playlist?.getCategories), + parse: Boolean(m.parse?.parseSong || m.parse?.parseAlbum || m.parse?.parsePlaylist), + userPlaylist: Boolean(m.userPlaylist?.getList), } + return { id, name, qualities, capabilities: caps } }), })) } diff --git a/src/server.ts b/src/server.ts index e219dc9..8aa95f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,19 +1,26 @@ // HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API // 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动) // -// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略时聚合全部平台) -// GET /find?name=&singer=&albumName=&interval= 跨平台精确匹配 +// GET /search/:source/:keyword[/:page] 搜索(source 为平台 id 或 all 聚合) +// GET /find/:source/:keyword 跨平台精确匹配 // GET /url/:source/:id 播放地址 // GET /lyric/:source/:id 歌词 // GET /pic/:source/:id 封面 // GET /boards/:source 榜单列表 -// GET /boards/:source/:boardId 榜单详情 +// GET /boards/:source/:boardId[/:page] 榜单详情 // GET /hot-search/:source 热搜词 -// GET /song-list/:source 歌单列表 +// GET /song-list/:source[/:page] 歌单列表 // GET /singer/:source/:id 歌手信息(kg/tx/wy) -// GET /singer/:source/:id/songs 歌手歌曲(kg/tx/wy) -// GET /singer/:source/:id/albums 歌手专辑(kg/tx/wy) -// GET /album/:source/:id 专辑详情+曲目(kg/kw) +// GET /singer/:source/:id/songs[/:page] 歌手歌曲(kg/tx/wy) +// GET /singer/:source/:id/albums[/:page] 歌手专辑(kg/tx/wy) +// GET /album/:source/:id[/:page] 专辑详情+曲目 +// GET /album/:source/search/:keyword 专辑搜索 +// GET /playlist/:source/:id[/:page] 歌单详情+曲目 +// GET /playlist/:source/search/:keyword 歌单搜索 +// GET /playlist/:source/categories[/:categoryId] 歌单分类 +// POST /parse/{song|album|playlist}/:source 链接解析 +// GET /user-playlist/:source 个人歌单 +// GET /stream/:source/:id 解密流代理(source: qs 汽水 / migu 咪咕) // GET /sources /health GET /docs /redoc /openapi.json // // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 @@ -40,8 +47,9 @@ import { redocHtml } from './openapi/ui.ts' const PORT = process.env.PORT || 16688 const DOCS_PREFIX = '/docs' const API_TITLE = 'MusicServer 聚合音乐 API' -const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' + - '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)平台。' +const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' + + '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ migu(咪咕)/ qian(千千)/ ' + + 'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。' const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')) diff --git a/test/smoke-test.ts b/test/smoke-test.ts index c31361d..14d4996 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -7,6 +7,7 @@ import http from 'node:http' import net from 'node:net' +import path from 'node:path' import { spawn } from 'node:child_process' // 端口策略:默认在随机空闲端口自起独立实例(避免连到 16688 等常驻实例—— @@ -75,13 +76,33 @@ const test = async(name: string, fn: () => Promise) => { } } +// 测试数据目录:与真实 data/ 隔离,避免 /login/refresh 等用例改写开发者的真实登录态 +const TEST_DATA_DIR = path.join(process.cwd(), 'tmp', 'test-data') + +// 已启动的全部子实例。无论脚本正常收尾还是中途抛错,退出时统一回收 —— +// 否则异常路径会留下监听端口的孤儿 node(历史上曾累积 5 个泄漏实例) +const spawned: any[] = [] +process.on('exit', () => { + for (const proc of spawned) { + try { proc.kill() } catch { /* 已自行退出 */ } + } +}) + /** * 启动本地服务器(指定端口,可覆盖环境变量)并返回进程句柄 + * 显式关闭外部音源(SOURCE_ENABLED=''):本套用例验证的是 SDK 官方直连通道, + * 若继承开发者 .env 的 SOURCE_ENABLED=true,取流会先走 sources/ 脚本链,掩盖 SDK 侧回归。 */ const startLocalServer = (port: number, envExtra: Record = {}) => new Promise((resolve, reject) => { const proc = spawn(process.execPath, ['src/server.ts'], { cwd: process.cwd(), - env: { ...process.env, PORT: String(port), ...envExtra }, + env: { + ...process.env, + PORT: String(port), + SOURCE_ENABLED: '', + DATA_DIR: TEST_DATA_DIR, + ...envExtra, + }, stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' @@ -90,16 +111,22 @@ const startLocalServer = (port: number, envExtra: Record = {}) = const timer = setTimeout(() => { proc.kill() - reject(new Error('启动本地服务器超时')) - }, 10000) + reject(new Error(`启动本地服务器超时(30s)\n--- 子进程输出(末 2000 字)---\n${stdout.slice(-2000) || '(无输出)'}`)) + }, 30000) const check = () => { + // 子进程提前退出(端口占用 / 语法错误等):立即带输出报错,不再空等到超时 + if (proc.exitCode !== null) { + clearTimeout(timer) + return reject(new Error(`本地服务器进程已退出(code ${proc.exitCode})\n--- 输出(末 2000 字)---\n${stdout.slice(-2000) || '(无输出)'}`)) + } // 启用 AUTH_KEY 的实例全路径要求秘钥,探活也须携带 const headers = envExtra.AUTH_KEY ? { 'X-Server-Key': envExtra.AUTH_KEY } : {} http.get(`http://localhost:${port}/health`, { headers }, res => { res.resume() if (res.statusCode === 200) { clearTimeout(timer) + spawned.push(proc) resolve(proc) } else { setTimeout(check, 200) @@ -123,7 +150,8 @@ if (externalServer) { }) const port = PORT || freePort BASE = `http://localhost:${port}` - localServer = await startLocalServer(port) + // 主实例关闭限流:用例数已超默认阈值(120/min),限流行为由下方独立低阈值实例专项验证 + localServer = await startLocalServer(port, { RATE_LIMIT: '0' }) console.log(`本地服务器已启动(:${port})\n`) } @@ -148,12 +176,19 @@ await test('聚合搜索', async() => { // kw 匿名通道 2026-10-01 接入 mobi.s convert_url_with_sign 主通道后: // 免费歌全档位可用,VIP 歌也放行真音频(320k/flac 实测与 N_MINFO 元数据逐字节吻合), // web playUrl 仅作回退(匿名恒 128k) +// 酷我 CDN 回吐的 url query 用 $ 顶替 =(?bitrate$320&format$mp3…),非标准分隔符, +// 取了 kw 的流就必须确认 SDK 已归正,否则播放器解析不出参数 +const assertKwUrl = (url: string): string => { + if (url.includes('$')) throw new Error(`URL 含未归正的 $ 分隔符: ${url}`) + return url.slice(0, 60) +} + await test('URL kw 128k 免费歌', async() => { const r = await get('/url/kw/151826618?quality=128k') if (r.code != 0) throw new Error(r.msg) if (!r.data?.url) throw new Error('无 url') if (r.data.via !== undefined) throw new Error(`直接命中不应有 via 字段,实际 ${r.data.via}`) - return r.data.url.slice(0, 60) + return assertKwUrl(r.data.url) }) await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => { @@ -161,7 +196,7 @@ await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => { if (r.code != 0) throw new Error(r.msg) if (!r.data?.url) throw new Error('无 url') // mobi 通道严格校验 bitrate==320 才放行,url 必然是 320k 档 - return r.data.url.slice(0, 60) + return assertKwUrl(r.data.url) }) await test('URL kw VIP 歌 flac 降级链', async() => { @@ -169,7 +204,7 @@ await test('URL kw VIP 歌 flac 降级链', async() => { if (r.code != 0) throw new Error(r.msg) if (!r.data?.url) throw new Error('无 url') // 晴天无 hi-res 档,应逐级降级命中 2000k flac 而非报错 - return r.data.url.slice(0, 60) + return assertKwUrl(r.data.url) }) await test('URL wy 128k', async() => { @@ -479,6 +514,202 @@ await test('专辑详情 /album/lr', async() => { return `${r.data.info.total} 节` }) +// ---- 新平台(migu/qian/joox/bili/5sing/apple 移植回归) ---- + +for (const source of ['migu', 'qian', 'joox', 'bili', '5sing', 'apple']) { + await test(`搜索 ${source} 直查`, async() => { + const r = await get(`/search/${source}/${encodeURIComponent(source === '5sing' ? '董贞' : '周杰伦')}?limit=5`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空列表') + return `${r.data.list.length} 条,首条 ${r.data.list[0].name}` + }) +} + +await test('URL migu(多档候选链)', async() => { + const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) + if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') + const song = r.data.list.find((s: any) => !s.payPlay) ?? r.data.list[0] + const u = await get(`/url/migu/${encodeURIComponent(song.songmid)}?quality=128k`) + if (u.code != 0) throw new Error(u.msg) + const url = String(u.data.url) + if (!url.startsWith('http') && !url.includes('/stream/migu/')) throw new Error('url 非法') + return `实际 ${u.data.type},${url.slice(0, 60)}` +}) + +await test('URL qian 免费曲', async() => { + // qian 免费曲目少,用「SSR」(上游测试同款厂牌)搜到带直链的曲目 + const r = await get(`/search/qian/${encodeURIComponent('SSR')}?limit=10`) + if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') + const song = r.data.list.find((s: any) => !s.payPlay) + if (!song) throw new Error('未搜到免费曲目') + const u = await get(`/url/qian/${encodeURIComponent(song.songmid)}?quality=128k`) + if (u.code != 0) throw new Error(u.msg) + return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}` +}) + +await test('URL 5sing(档位 sq/hq/lq)', async() => { + const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`) + if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流') + const u = await get(`/url/5sing/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`) + if (u.code != 0) throw new Error(u.msg) + return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}` +}) + +await test('歌词 migu', async() => { + const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) + const song = r.data?.list?.[0] + if (!song) throw new Error('搜索失败') + const l = await get(`/lyric/migu/${encodeURIComponent(song.songmid)}`) + if (l.code != 0) throw new Error(l.msg) + if (!l.data?.lyric) throw new Error('空歌词') + return l.data.lyric.slice(0, 40) +}) + +await test('歌词 5sing', async() => { + const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`) + const song = r.data?.list?.[0] + if (!song) throw new Error('搜索失败') + const l = await get(`/lyric/5sing/${encodeURIComponent(song.songmid)}`) + if (l.code != 0) throw new Error(l.msg) + if (!l.data?.lyric) throw new Error('空歌词') + return l.data.lyric.slice(0, 40) +}) + +await test('封面 migu', async() => { + const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) + const song = r.data?.list?.[0] + if (!song) throw new Error('搜索失败') + const p = await get(`/pic/migu/${encodeURIComponent(song.songmid)}`) + if (p.code != 0) throw new Error(p.msg) + if (!String(p.data?.url ?? '').startsWith('http')) throw new Error('封面 url 非法') + return String(p.data.url).slice(0, 60) +}) + +for (const source of ['migu', 'qian', 'joox', 'apple']) { + await test(`专辑搜索 /album/${source}/search`, async() => { + const r = await get(`/album/${source}/search/${encodeURIComponent('Hello')}?limit=3`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空列表') + return `${r.data.list.length} 张,首张 ${r.data.list[0].name}` + }) +} + +await test('专辑详情 /album/migu', async() => { + const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`) + const albumId = r.data?.list?.find((s: any) => s.albumId)?.albumId + if (!albumId) throw new Error('搜索结果未携带 albumId') + const a = await get(`/album/migu/${albumId}`) + if (a.code != 0) throw new Error(a.msg) + if (!a.data?.list?.length) throw new Error('专辑无曲目') + return `${a.data.info.name}(${a.data.total} 首)` +}) + +for (const source of ['migu', 'qian', 'joox', 'apple']) { + await test(`歌单分类 /playlist/${source}/categories`, async() => { + const r = await get(`/playlist/${source}/categories`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空分类') + return `${r.data.list.length} 个分类` + }) + + await test(`分类歌单 /playlist/${source}/categories/:id`, async() => { + const c = await get(`/playlist/${source}/categories`) + const cat = c.data.list[0] + if (!cat) throw new Error('无分类') + const r = await get(`/playlist/${source}/categories/${encodeURIComponent(cat.id)}?limit=3`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('分类下无歌单') + return `${r.data.list.length} 个歌单` + }) +} + +await test('歌单详情 /playlist/migu', async() => { + const c = await get('/playlist/migu/categories') + const cat = c.data.list[0] + const pl = await get(`/playlist/migu/categories/${encodeURIComponent(cat.id)}?limit=3`) + const id = pl.data?.list?.[0]?.id + if (!id) throw new Error('未取到歌单 id') + const d = await get(`/playlist/migu/${id}?limit=5`) + if (d.code != 0) throw new Error(d.msg) + if (!d.data?.list?.length) throw new Error('歌单无曲目') + return `${d.data.total} 首,本页 ${d.data.list.length} 首` +}) + +await test('歌单详情 /playlist/bili(合集)', async() => { + const s = await get(`/playlist/bili/search/${encodeURIComponent('周杰伦')}?limit=3`) + const id = s.data?.list?.[0]?.id + if (!id) throw new Error('搜索无歌单') + const d = await get(`/playlist/bili/${encodeURIComponent(id)}?limit=5`) + if (d.code != 0) throw new Error(d.msg) + if (!d.data?.list?.length) throw new Error('歌单无曲目') + return `${d.data.total} 首` +}) + +await test('链接解析 /parse/song/migu', async() => { + const r = await postJson('/parse/song/migu', { link: 'https://music.migu.cn/v3/music/song/600913000002601091' }) + if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) + if (!r.body.data?.song?.songmid) throw new Error('缺少 song') + return `${r.body.data.song.name} / ${r.body.data.song.singer}` +}) + +await test('链接解析 /parse/playlist/qian', async() => { + const r = await postJson('/parse/playlist/qian', { link: 'https://music.91q.com/songlist/309735' }) + if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) + if (!r.body.data?.list?.length) throw new Error('无曲目') + return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)` +}) + +await test('链接解析 /parse/album/apple', async() => { + const r = await postJson('/parse/album/apple', { link: 'https://music.apple.com/us/album/the-life-of-a-showgirl-the-encore/6814997249' }) + if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) + if (!r.body.data?.list?.length) throw new Error('无曲目') + return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)` +}) + +await test('链接解析 不支持的平台 → 404', async() => { + // qs 无 parse 实现(以 /sources 的 parse 能力位为准)。 + // 原先此处断言 kw 返回 404 —— 但 kw 的 parse 早已实现(sdk/kw/parse.ts), + // 该断言过期后恒失败,导致 pnpm test 永远无法全绿。 + const r = await postJson('/parse/song/qs', { link: 'https://music.douyin.com/qishui/share/track?track_id=1' }) + if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`) + return 'qs 未实现 parse(按上游)' +}) + +await test('链接解析 /parse/song/kw(正向)', async() => { + const r = await postJson('/parse/song/kw', { link: 'https://www.kuwo.cn/play_detail/28423286' }) + if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) + if (!r.body.data?.song?.songmid) throw new Error('未返回 song.songmid') + return `kw 解析成功: ${r.body.data.song.name}` +}) + +await test('扫码二维码创建 POST /login/bili/qr', async() => { + const r = await postRaw('/login/bili/qr') + const body = JSON.parse(r.body) + if (r.status !== 200 || body.code != 0 || !body.data?.key) throw new Error(r.body.slice(0, 120)) + return `key=${String(body.data.key).slice(0, 12)}…` +}) + +await test('扫码轮询 POST /login/bili/qr/check', async() => { + const created = JSON.parse((await postRaw('/login/bili/qr')).body) + const key = created.data?.key + if (!key) throw new Error('未取到 key') + const r = await postJson('/login/bili/qr/check', { key }) + if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120)) + return `status=${r.body.data.status} code=${r.body.data.code}` +}) + +await test('个人歌单 未支持的平台 → 502', async() => { + const r = await getRaw('/user-playlist/5sing') + if (r.status !== 502 && r.status !== 404) throw new Error(`期望 502/404,实际 ${r.status}`) + return JSON.parse(r.body).msg?.slice(0, 60) +}) + +await test('咪咕解密流代理 无票据 → 404', async() => { + const r = await getRaw('/stream/migu/ffffffffffffffffffffffffffffffff') + if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`) + return JSON.parse(r.body).msg?.slice(0, 50) +}) + await test('参数校验 422(非法页码)', async() => { const r = await getRaw(`/search/kw/${encodeURIComponent('稻香')}/0`) if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) @@ -594,6 +825,8 @@ await test('扫码二维码创建 POST /login/qs/qr', async() => { const r = await postJson('/login/qs/qr', {}) if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`) if (!r.body.data?.token || !r.body.data?.qrimg?.startsWith('data:image/')) throw new Error('响应不完整') + // 二维码必须是官方 PC 客户端同款扫码页(旧的 ucenter_web/app/sdk-next 已 404,抖音扫出「无法访问页面」) + if (!String(r.body.data?.scanUrl ?? '').includes('/light/invoke/scan_login')) throw new Error(`二维码内容非官方扫码页: ${r.body.data?.scanUrl}`) return `scanApp=${r.body.data.scanApp || '?'}` }) @@ -655,12 +888,15 @@ if (!externalServer && !process.env.AUTH_KEY) { if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) return 'ok' }) - // 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性 - for (const path of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) { - const r = await authFetch(path) - if (r.status !== 401) throw new Error(`${path} 期望 401,实际 ${r.status}(应全路径要求秘钥)`) - } - await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => 'ok') + // 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性。 + // 断言必须包在 test() 内 —— 裸抛错会中断整个脚本,跳过后续用例与实例回收(留下孤儿进程) + await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => { + for (const p of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) { + const r = await authFetch(p) + if (r.status !== 401) throw new Error(`${p} 期望 401,实际 ${r.status}(应全路径要求秘钥)`) + } + return '6 条路径均 401' + }) // /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃 await test('AUTH_KEY 流代理签名机制', async() => { // 裸秘钥 ?key= 已不生效