feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
import crypto from 'node:crypto'
export const txHeaders = {
'User-Agent': 'QQMusic 14090508(android 12)'
}
export const TX_MUSICU_URL = 'https://u.y.qq.com/cgi-bin/musicu.fcg'
const QQ_MID_PREFIX_RE = /^qqmid:/i
const QQ_LEGACY_ID_RE = /^\d+$/
const TX_DETAIL_CACHE_TTL = 6 * 60 * 60 * 1000
// 签名算法常量
const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19]
const PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5]
const SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
function pickHashByIdx(hash: string, indexes: number[]) {
return indexes.map((idx) => hash[idx]).join('')
}
function base64Encode(data: Buffer | string) {
return Buffer.from(data)
.toString('base64')
.replace(/[\\/+=]/g, '')
}
/**
* zzcSign 签名算法
* 模拟官方客户端签名,抗封锁能力更强
*/
export async function zzcSign(text: string) {
const hash = crypto.createHash('sha1').update(text).digest('hex')
const part1 = pickHashByIdx(hash, PART_1_INDEXES)
const part2 = pickHashByIdx(hash, PART_2_INDEXES)
const part3 = SCRAMBLE_VALUES.map((value, i) => value ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16))
const b64Part = base64Encode(Buffer.from(part3)).replace(/[\\/+=]/g, '')
return `zzc${part1}${b64Part}${part2}`.toLowerCase()
}
/**
* 使用签名请求 QQ 音乐 API
* 相比普通 musicu.fcg,musics.fcg?sign=... 的签名请求更稳定
*/
export const txSignedRequest = async (body: any, options: { signal?: AbortSignal } = {}) => {
const sign = await zzcSign(JSON.stringify(body))
try {
const response = await $fetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
method: 'POST',
headers: txHeaders,
body,
responseType: 'json',
signal: options.signal
})
return response
} catch (error) {
console.error('TX Signed Request Error:', error)
throw error
}
}
type TxIdType = 'legacy-id' | 'mid'
interface TxNormalizedMusicId {
rawMusicId: string
normalizedMusicId: string
idType: TxIdType
}
interface TxSongPlayableInfo extends TxNormalizedMusicId {
songmid: string
songId?: string
strMediaMid?: string
}
const txSongDetailCache = new Map<
string,
{
expiresAt: number
value: TxSongPlayableInfo
}
>()
/**
* PC 客户端版 searchid:32 位大写十六进制 GUID + 5 位补零随机数(37 字符)
*/
const getTxSearchId = () => {
let guid = ''
for (let i = 0; i < 32; i++) guid += Math.floor(Math.random() * 16).toString(16)
return guid.toUpperCase() + String(Math.floor(Math.random() * 100000)).padStart(5, '0')
}
export const createTxSearchBody = (str: string, page: number, limit: number) => {
return {
comm: {
_channelid: '0',
_os_version: '6.2.9200-2',
ct: '19',
cv: '2151',
guid: '1F70E520B2EAA7D25E11760783C53CA9',
patch: '118',
psrf_access_token_expiresAt: 0,
psrf_qqaccess_token: '',
psrf_qqopenid: '',
psrf_qqunionid: '',
tmeAppID: 'qqmusic',
tmeLoginType: 0,
uin: '0',
wid: '7223299733393904640'
},
'music.search.SearchCgiService': {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicDesktop',
param: {
grp: 1,
num_per_page: limit,
page_num: page,
query: str,
remoteplace: 'txt.newclient.top',
search_type: 0,
searchid: getTxSearchId()
}
}
}
}
export const normalizeTxMusicId = (musicId: string | number): TxNormalizedMusicId => {
const rawMusicId = String(musicId ?? '').trim()
const normalizedMusicId = rawMusicId.replace(QQ_MID_PREFIX_RE, '').trim()
if (!normalizedMusicId) {
throw createError({ statusCode: 400, message: '缺少 QQ 音乐ID' })
}
return {
rawMusicId,
normalizedMusicId,
idType: QQ_LEGACY_ID_RE.test(normalizedMusicId) ? 'legacy-id' : 'mid'
}
}
export const upgradeTxAudioUrl = (url: string) => {
return url.startsWith('http://') ? url.replace('http://', 'https://') : url
}
export const createTxSongDetailBody = (musicId: TxNormalizedMusicId) => {
const param =
musicId.idType === 'legacy-id'
? { song_type: 0, song_id: Number(musicId.normalizedMusicId) }
: { song_type: 0, song_mid: musicId.normalizedMusicId }
return {
comm: {
ct: '19',
cv: '1859',
uin: '0'
},
req: {
module: 'music.pf_song_detail_svr',
method: 'get_song_detail_yqq',
param
}
}
}
const getCachedTxSongDetail = (key: string) => {
const cached = txSongDetailCache.get(key)
if (!cached) return null
if (cached.expiresAt <= Date.now()) {
txSongDetailCache.delete(key)
return null
}
return cached.value
}
const setCachedTxSongDetail = (key: string, value: TxSongPlayableInfo) => {
if (txSongDetailCache.size >= 1000) {
const firstKey = txSongDetailCache.keys().next().value
if (firstKey !== undefined) {
txSongDetailCache.delete(firstKey)
}
}
txSongDetailCache.set(key, {
expiresAt: Date.now() + TX_DETAIL_CACHE_TTL,
value
})
}
export const getTxSongPlayableInfo = async (
musicId: string | number
): Promise<TxSongPlayableInfo> => {
const normalized = normalizeTxMusicId(musicId)
const cacheKey = `${normalized.idType}:${normalized.normalizedMusicId}`
const cached = getCachedTxSongDetail(cacheKey)
if (cached) return cached
// 旧 QQ 数字 ID 只在播放时转 MID,避免批量改历史数据。
let result: any
try {
result = await txRequest(TX_MUSICU_URL, createTxSongDetailBody(normalized))
} catch (error) {
if (normalized.idType === 'mid') {
const value = {
...normalized,
songmid: normalized.normalizedMusicId
}
setCachedTxSongDetail(cacheKey, value)
return value
}
throw error
}
if (!result || result.code !== 0 || result.req?.code !== 0) {
if (normalized.idType === 'mid') {
const value = {
...normalized,
songmid: normalized.normalizedMusicId
}
setCachedTxSongDetail(cacheKey, value)
return value
}
throw createError({ statusCode: 502, message: 'QQ 音乐详情接口异常' })
}
const trackInfo = result.req?.data?.track_info
const songmid = trackInfo?.mid || normalized.normalizedMusicId
if (!songmid) {
throw createError({ statusCode: 502, message: 'QQ 音乐详情缺少 MID' })
}
const value = {
...normalized,
songmid,
songId: String(trackInfo?.id || normalized.normalizedMusicId),
strMediaMid: trackInfo?.file?.media_mid
}
setCachedTxSongDetail(cacheKey, value)
setCachedTxSongDetail(`mid:${songmid}`, {
...value,
normalizedMusicId: songmid,
idType: 'mid'
})
return value
}
export const txRequest = async (
url: string,
body: any,
options: { signal?: AbortSignal } = {}
) => {
try {
const response = await $fetch(url, {
method: 'POST',
headers: txHeaders,
body,
responseType: 'json',
signal: options.signal
})
return response
} catch (error) {
console.error('TX Request Error:', error)
throw error
}
}