feat(qs): 汽水音乐平台接入——匿名搜索/试听、抖音扫码登录、解密流代理
- sdk/qs 平台模块(安卓参数指纹匿名搜索)+ sdk/apis/qs 取流: 匿名 30s 试听,登录态走签名 track_v2 + spade key AES-CTR 解密 - /login/qs/qr 抖音扫码登录(授权后 sessionid 解锁完整曲目) - /stream/qs/:id 解密流代理,/url 返回的相对地址按请求 host 补全 - 回归测试覆盖 kw 双通道(VIP 真音频/降级链)与 qs 试听;start.bat 去掉 SOURCE_ENABLED 直启
This commit is contained in:
1 parent
aa4f2eec67
commit
0f9cff2a08
26 files changed
+2172
-9
No files matched your search
+209
@@ -0,0 +1,209 @@
|
||||
// 汽水音乐 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(抖音扫码 /login/qs/qr)。
|
||||
// 登录请求失败自动降级匿名试听。
|
||||
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import musicInfo from '../qs/musicInfo.ts'
|
||||
import { getDevice } from '../qs/device.ts'
|
||||
import { getSessionId } from '../qs/passport.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 视账号权益)
|
||||
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 resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
||||
const device = await getDevice()
|
||||
const sessionId = getSessionId()
|
||||
const url = buildSignedUrl(device)
|
||||
const requestBody = JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||
const headers = await signRequest(url, requestBody)
|
||||
const { statusCode, body } = (await httpFetch(url, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
...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: `sessionid=${sessionId}` } : {}),
|
||||
},
|
||||
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())
|
||||
}
|
||||
|
||||
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 }
|
||||
Reference in new issue
Block a user