Files
MusicServer/sdk/apis/qs.ts
T
snowy-yang ecca1b8458 feat(qs): PC 签名改双模式供给,签名工具链迁出独立仓库 qs-signer
- mssdk.ts:进程内 bdms.node 加载移除;优先 QS_SIGN_SERVER 调签名服务
  (兼容 qs-signer 仓库 server.mjs 与 mssdk-sign 两个实现,/sign 契约互通),
  无服务时 QS_HELIOS/QS_MEDUSA 或 data/qs-sign.json 固定值兜底,都没有走安卓自签
- musicSearch:PC 通道签名失败/请求异常/风控空结果自动降级安卓通道(原为同通道重试)
- signer/、tools/mssdk-sign/、scripts/qs-local-login.mjs 迁至 qs-signer
  (gitea.4i.hk/hhhge/qs-signer),soda-mssdk/ 二进制目录一并挪走
2026-10-03 20:45:22 +08:00

256 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 汽水音乐 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)
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
// 登录请求失败自动降级匿名试听。
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
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 type { HttpResponse } from '../types.ts'
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
// 用户上传曲匿名即可整曲(实测 2026-10-02:DJ 版整曲 204.84s / 三档 68k・132k・260k)
export const supportQualitys = ['128k', '320k']
const TRACK_V2_URL = 'https://api.qishui.com/luna/pc/track_v2'
const STREAM_PREFIX = '/stream/qs/'
const STREAM_CACHE_TTL = 10 * 60_000
const STREAM_CACHE_MAX = 3
interface StreamEntry {
buffer: Buffer
contentType: string
bitrate: number
size: number
resolvedAt: number
}
// 已解密音频缓存(/url 解析一次,/stream 直接消费;LRU + TTL)
const streamCache = new Map<string, StreamEntry>()
const inflight = new Map<string, Promise<StreamEntry>>()
const cachePut = (trackId: string, entry: StreamEntry) => {
streamCache.delete(trackId) // 重新插入以刷新 LRU 顺序
streamCache.set(trackId, entry)
while (streamCache.size > STREAM_CACHE_MAX) {
streamCache.delete(streamCache.keys().next().value!)
}
}
/** 已缓存的明文音频(/stream/qs/:id 用;缓存未命中返回 null,由路由触发重解析) */
export const getCachedStream = (trackId: string): StreamEntry | null => {
const entry = streamCache.get(trackId)
if (!entry) return null
if (Date.now() - entry.resolvedAt > STREAM_CACHE_TTL) {
streamCache.delete(trackId)
return null
}
return entry
}
// 匿名通道:码率最高的未加密条目(带 PlayAuth = 加密,直接回 URL 不可播,跳过)
const pickPlainAudioUrl = (playInfoList: any): string | null => {
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl && !item.PlayAuth)
if (!entries.length) return null
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
return entries[0].MainPlayUrl
}
const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
const body = await musicInfo(trackId).promise
const playerInfoUrl = body?.track_player?.url_player_info
if (!playerInfoUrl) throw new Error('获取失败')
const { statusCode, body: playInfo } = await httpFetch(playerInfoUrl, { method: 'get', timeout: 15000 }).promise
const url = statusCode == 200 ? pickPlainAudioUrl(playInfo?.Result?.Data?.PlayInfoList) : null
if (!url) throw new Error('获取失败')
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()}`
}
const pickBestEntry = (playInfoList: any) => {
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
if (!entries.length) return null
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
return entries[0]
}
const fetchTrackPlayer = async (url: string, headers: Record<string, string>, requestBody: string) => {
const { statusCode, body } = (await httpFetch(url, {
method: 'post',
headers,
body: requestBody,
timeout: 15000,
}).promise) as HttpResponse
const trackPlayer = (body as any)?.track_player
if (statusCode != 200 || !trackPlayer?.url_player_info) {
const errMsg = (body as any)?.status_info?.status_msg || (body as any)?.message || ''
throw new Error(`track_v2 获取失败: HTTP ${statusCode} ${errMsg}`.trim())
}
return trackPlayer as { url_player_info: string }
}
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 签名不可用')
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
const requestBody = TRACK_V2_BODY(trackId)
const headers: Record<string, string> = {
'content-type': 'application/json; charset=utf-8',
'user-agent': PC_UA,
'x-luna-background-type': 'foreground',
'x-luna-is-background-req': '0',
'x-luna-is-local-user': '0',
'x-tt-trace-id': `00-${randomBytes(8).toString('hex')}-${randomBytes(8).toString('hex')}-01`,
'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(),
...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}),
}
const signed = await signQishuiRequest(url, headers, requestBody)
if (!signed) throw new Error('官方签名失败')
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 playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
if (!entry) throw new Error('无可用音频资源')
const audioRes = await httpFetch(entry.MainPlayUrl, { method: 'get', timeout: 30000 }).promise
const encrypted = audioRes.raw
if (!encrypted?.length) throw new Error('音频下载失败')
let buffer: Buffer = encrypted
let contentType = 'audio/mp4'
if (entry.PlayAuth) {
const hexKey = resolveAudioKey({ spade_a: entry.PlayAuth })
const decrypted = decryptAudioBuffer(new Uint8Array(encrypted), hexKey)
buffer = Buffer.from(decrypted.buffer)
contentType = decrypted.content_type
}
const streamEntry: StreamEntry = {
buffer,
contentType,
bitrate: entry.Bitrate || 0,
size: buffer.length,
resolvedAt: Date.now(),
}
cachePut(trackId, streamEntry)
return streamEntry
}
/** 解析明文音频流(/stream/qs/:id 用;先查缓存,未命中重新解析) */
export const getStreamBuffer = async (trackId: string): Promise<StreamEntry> => {
const cached = getCachedStream(trackId)
if (cached) return cached
let work = inflight.get(trackId)
if (!work) {
work = resolveSignedStream(trackId).finally(() => inflight.delete(trackId))
inflight.set(trackId, work)
}
return work
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const trackId = String(songInfo.songmid ?? '')
const sessionId = getSessionId()
if (sessionId) {
// 登录态:签名 track_v2 + 服务端解密,/url 阶段完成解析(快速失败),/stream 直接消费缓存
requestObj.promise = getStreamBuffer(trackId)
.then(entry => {
console.log(`[qs/url] ${trackId} 解密流就绪 bitrate=${entry.bitrate} size=${entry.size}`)
return { type, url: `${STREAM_PREFIX}${trackId}` }
})
.catch(err => {
console.log(`[qs/url] ${trackId} 登录态取流失败,降级匿名试听: ${err.message}`)
return anonymousPreviewUrl(trackId).then(url => ({ type, url }))
})
return requestObj
}
// 匿名:未加密试听直链
requestObj.promise = anonymousPreviewUrl(trackId).then(url => ({ type, url }))
return requestObj
}
export default { getMusicUrl, getStreamBuffer, getCachedStream }