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 固定值兜底。
This commit is contained in:
snowy-yang committed 2026-10-04 17:31:33 +08:00
1 parent cec63f864e
commit 6b05fde64b
19 files changed
+111 -1271

No files matched your search

+7 -73
View File
@@ -1,8 +1,9 @@
// 汽水音乐 URL 获取(直连)
// 两条取流通道:
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr)
// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin)
// 登录态: 官方 PC 签名 X-Helios/X-Medusa(见 sdk/qs/mssdk.ts)→ /luna/pc/track_v2
// → 加密音频(cenc-aes-ctr)→ spade key 解密 → /stream/qs/:id 代理端点回明文流
// (相对地址,路由层负责补全 origin)
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
// 登录请求失败自动降级匿名试听。
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
@@ -11,11 +12,9 @@
import { httpFetch } from '../common/request.ts'
import { createHash, randomBytes } from 'node:crypto'
import musicInfo from '../qs/musicInfo.ts'
import { getDevice } from '../qs/device.ts'
import { getCookie, getSessionId } from '../qs/passport.ts'
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
import { signRequest } from '../qs/crypto/sign.ts'
import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts'
import { decryptAudioBuffer, resolveAudioKey } from '../qs/audiodecrypt.ts'
import type { HttpResponse } from '../types.ts'
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
@@ -76,43 +75,7 @@ const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
return url
}
// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频
const buildSignedUrl = (device: Awaited<ReturnType<typeof getDevice>>): string => {
const params = new URLSearchParams({
device_platform: 'android',
os: 'android',
ssmix: 'a',
cdid: device.cdid,
channel: device.channel,
aid: '386088',
app_name: 'luna',
version_code: '100198030',
version_name: '19.8.0',
manifest_version_code: '100198030',
update_version_code: '100198030',
resolution: '1080*1920',
dpi: '480',
device_type: device.device_type,
device_brand: device.device_brand,
language: 'zh',
os_api: '35',
os_version: device.os_version,
ac: 'wifi',
device_model: device.device_model,
device_id: device.device_id,
iid: device.install_id,
tz_name: 'Asia/Shanghai',
tz_offset: '28800',
sim_region: 'cn',
package: 'com.luna.music',
luna_apk_type: 'normal_apk',
output_device_type: 'Phone',
openudid: device.openudid,
_rticket: String(Date.now()),
})
return `${TRACK_V2_URL}?${params.toString()}`
}
// 登录态通道:官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
const pickBestEntry = (playInfoList: any) => {
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
if (!entries.length) return null
@@ -138,28 +101,9 @@ const fetchTrackPlayer = async (url: string, headers: Record<string, string>, re
const TRACK_V2_BODY = (trackId: string) =>
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
// 通道 A(默认):安卓 App 参数 + X-Argus/X-Ladon 自签
const fetchTrackPlayerByApp = async (trackId: string) => {
const device = await getDevice()
const sessionId = getSessionId()
const url = buildSignedUrl(device)
const requestBody = TRACK_V2_BODY(trackId)
const headers = await signRequest(url, requestBody)
return fetchTrackPlayer(url, {
...headers,
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
'Content-Type': 'application/json; charset=utf-8',
'X-Luna-Background-Type': 'foreground',
'X-Luna-Is-Background-Req': '0',
'X-Luna-Is-Local-User': '1',
...(sessionId ? { Cookie: getCookie() || `sessionid=${sessionId}` } : {}),
}, requestBody)
}
// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
const fetchTrackPlayerByPc = async (trackId: string) => {
const sessionId = getSessionId()
if (!mssdkAvailable()) throw new Error('PC 签名不可用')
if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)')
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
const requestBody = TRACK_V2_BODY(trackId)
const headers: Record<string, string> = {
@@ -177,18 +121,8 @@ const fetchTrackPlayerByPc = async (trackId: string) => {
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
}
const resolveTrackPlayer = async (trackId: string) => {
try {
return await fetchTrackPlayerByApp(trackId)
} catch (err: any) {
if (!mssdkAvailable()) throw err
console.log(`[qs/track_v2] 安卓自签通道失败(${err?.message ?? err}),降级官方 PC 签名`)
return fetchTrackPlayerByPc(trackId)
}
}
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
const trackPlayer = await resolveTrackPlayer(trackId)
const trackPlayer = await fetchTrackPlayerByPc(trackId)
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)