1471 lines
48 KiB
TypeScript
1471 lines
48 KiB
TypeScript
import {
|
||
checkQQLoginQr,
|
||
getLyric,
|
||
getMusicPlay,
|
||
getQQLoginQr,
|
||
getSearchByKey,
|
||
search
|
||
} from '@sansenjian/qq-music-api/sdk'
|
||
import {
|
||
checkWXLoginQr,
|
||
getWXLoginQr,
|
||
getUserAvatar,
|
||
getUserDetail,
|
||
getVipInfo
|
||
} from '@sansenjian/qq-music-api/services'
|
||
import { txHeaders, txRequest, upgradeTxAudioUrl, zzcSign } from '~~/server/utils/native_tx'
|
||
import { getServerTimestamp } from '~~/server/utils/serverTime'
|
||
import { randomUUID } from 'node:crypto'
|
||
import { inflateRawSync, inflateSync, unzipSync } from 'node:zlib'
|
||
|
||
type QqSdkResponse = {
|
||
status: number
|
||
body?: Record<string, any>
|
||
}
|
||
|
||
const QQ_SDK_QUALITY_MAP: Record<string, string> = {
|
||
'4': '128',
|
||
'8': '320',
|
||
'10': 'flac',
|
||
'11': 'flac',
|
||
'14': 'flac',
|
||
'128': '128',
|
||
'320': '320',
|
||
'128k': '128',
|
||
'320k': '320',
|
||
flac: 'flac',
|
||
sq: 'flac',
|
||
hires: 'flac'
|
||
}
|
||
|
||
const QQ_AUTH_COOKIE_KEYS = ['qqmusic_key', 'qm_keyst', 'music_key']
|
||
const QQ_MUSICU_URL = 'https://u.y.qq.com/cgi-bin/musicu.fcg'
|
||
const QQ_MUSICS_URL = 'https://u.y.qq.com/cgi-bin/musics.fcg'
|
||
// CDN 调度缓存;失败时短暂缓存空列表,避免每次播放都重试
|
||
let qqCdnCache: { sips: string[]; expireAt: number } | null = null
|
||
const QQ_CDN_FALLBACK_DOMAIN = 'https://isure.stream.qqmusic.qq.com/'
|
||
const QQ_CDN_CACHE_TTL = 60 * 60 * 1000
|
||
const QQ_CDN_FAIL_TTL = 60 * 1000
|
||
const QQ_COMMON_QUERY = {
|
||
g_tk: '1124214810',
|
||
hostUin: '0',
|
||
inCharset: 'utf8',
|
||
outCharset: 'utf-8',
|
||
notice: '0',
|
||
platform: 'yqq.json',
|
||
needNewCode: '0'
|
||
}
|
||
const QQ_OFFICIAL_HEADERS = {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
referer: 'https://y.qq.com/portal/player.html',
|
||
host: 'u.y.qq.com',
|
||
'content-type': 'application/x-www-form-urlencoded'
|
||
}
|
||
|
||
const QQ_PLAY_FILE_TYPE_MAP: Record<string, { prefix: string; suffix: string }> = {
|
||
m4a: { prefix: 'C400', suffix: '.m4a' },
|
||
'128': { prefix: 'M500', suffix: '.mp3' },
|
||
'320': { prefix: 'M800', suffix: '.mp3' },
|
||
flac: { prefix: 'F000', suffix: '.flac' },
|
||
hires: { prefix: 'RS01', suffix: '.flac' }
|
||
}
|
||
|
||
// 原生直连链路音质档位,自高到低;批量请求时按请求档位起向下降级
|
||
const QQ_OFFICIAL_QUALITY_ORDER = ['hires', 'flac', '320', '128', 'm4a']
|
||
|
||
// 原生直连音质映射:11/14(master)可解析 Hi-Res FLAC,区别于 SDK 链路
|
||
const QQ_OFFICIAL_QUALITY_MAP: Record<string, string> = {
|
||
...QQ_SDK_QUALITY_MAP,
|
||
'11': 'hires',
|
||
'14': 'hires',
|
||
master: 'hires',
|
||
hires: 'hires'
|
||
}
|
||
|
||
const unwrapQqSdkResponse = (response: QqSdkResponse, fallbackMessage: string) => {
|
||
const status = Number(response?.status || 500)
|
||
const body = response?.body || {}
|
||
|
||
if (status >= 400 || body.error) {
|
||
throw new Error(String(body.error || body.message || fallbackMessage))
|
||
}
|
||
|
||
return body
|
||
}
|
||
|
||
const parseCookieObject = (cookie?: string) => {
|
||
const result: Record<string, string> = {}
|
||
if (!cookie) return result
|
||
|
||
cookie.split(';').forEach((item) => {
|
||
const separatorIndex = item.indexOf('=')
|
||
if (separatorIndex <= 0) return
|
||
|
||
const key = item.slice(0, separatorIndex).trim()
|
||
const value = item.slice(separatorIndex + 1).trim()
|
||
if (key && value) result[key] = value
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
const serializeCookieObject = (cookieObject: Record<string, string>) => {
|
||
return Object.entries(cookieObject)
|
||
.filter(([key, value]) => key && value)
|
||
.map(([key, value]) => `${key}=${value}`)
|
||
.join('; ')
|
||
}
|
||
|
||
export const normalizeQqCookie = (cookie?: string) => {
|
||
const cookieObject = parseCookieObject(cookie)
|
||
const fallbackKey = cookieObject.qm_keyst || cookieObject.music_key || cookieObject.qqmusic_key
|
||
if (fallbackKey) {
|
||
cookieObject.qqmusic_key = fallbackKey
|
||
}
|
||
|
||
return serializeCookieObject(cookieObject)
|
||
}
|
||
|
||
export const getQqCookieDiagnostic = (cookie?: string) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
const authKeys = QQ_AUTH_COOKIE_KEYS.filter((key) => Boolean(cookieObject[key]))
|
||
|
||
return {
|
||
hasCookie: Boolean(normalizedCookie),
|
||
hasUin: Boolean(cookieObject.uin),
|
||
uinType: cookieObject.uin?.startsWith('o') ? 'openid' : (cookieObject.uin ? 'uin' : 'missing'),
|
||
hasAuthKey: authKeys.length > 0,
|
||
authKeySource: cookieObject.qm_keyst
|
||
? 'qm_keyst'
|
||
: (cookieObject.music_key ? 'music_key' : (cookieObject.qqmusic_key ? 'qqmusic_key' : 'missing')),
|
||
authKeys
|
||
}
|
||
}
|
||
|
||
const decodeMaybeBase64 = (value: unknown) => {
|
||
if (typeof value !== 'string' || !value) return ''
|
||
|
||
const normalizedValue = value.trim()
|
||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalizedValue) || normalizedValue.length % 4 !== 0) {
|
||
return value
|
||
}
|
||
|
||
try {
|
||
const decoded = Buffer.from(normalizedValue, 'base64').toString()
|
||
// 刻意检测解码结果中的控制字符,用于排除二进制内容
|
||
// eslint-disable-next-line no-control-regex
|
||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFD]/.test(decoded)) {
|
||
return value
|
||
}
|
||
if (!/[\r\n[\]\u4e00-\u9fa5]/.test(decoded)) {
|
||
return value
|
||
}
|
||
return decoded || value
|
||
} catch {
|
||
return value
|
||
}
|
||
}
|
||
|
||
const isHttpAudioUrl = (value: string) => /^https?:\/\//i.test(value.trim())
|
||
|
||
const findFirstUrl = (value: unknown): string => {
|
||
if (!value) return ''
|
||
|
||
if (typeof value === 'string') {
|
||
return isHttpAudioUrl(value) ? value : ''
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const url = findFirstUrl(item)
|
||
if (url) return url
|
||
}
|
||
return ''
|
||
}
|
||
|
||
if (typeof value === 'object') {
|
||
const record = value as Record<string, any>
|
||
if (typeof record.url === 'string' && isHttpAudioUrl(record.url)) {
|
||
return record.url
|
||
}
|
||
|
||
for (const item of Object.values(record)) {
|
||
const url = findFirstUrl(item)
|
||
if (url) return url
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
const collectPlayUrlErrors = (value: unknown): string[] => {
|
||
if (!value) return []
|
||
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item) => collectPlayUrlErrors(item))
|
||
}
|
||
|
||
if (typeof value === 'object') {
|
||
const record = value as Record<string, any>
|
||
const currentError = typeof record.error === 'string' ? [record.error] : []
|
||
return [
|
||
...currentError,
|
||
...Object.values(record).flatMap((item) => collectPlayUrlErrors(item))
|
||
]
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
const normalizeQqSdkQuality = (quality: unknown) => {
|
||
const key = String(quality ?? '8').toLowerCase()
|
||
return QQ_SDK_QUALITY_MAP[key] || '320'
|
||
}
|
||
|
||
const pickPlayableDomain = (sip: unknown) => {
|
||
if (!Array.isArray(sip)) return ''
|
||
|
||
const urls = sip.filter((item) => typeof item === 'string' && item.length > 0)
|
||
return urls.find((url) => !url.startsWith('http://ws')) ||
|
||
urls.find((url) => url.startsWith('https://')) ||
|
||
urls[0] ||
|
||
''
|
||
}
|
||
|
||
const joinUrl = (domain: string, path: string) => {
|
||
if (domain.endsWith('/') && path.startsWith('/')) return `${domain}${path.slice(1)}`
|
||
if (!domain.endsWith('/') && !path.startsWith('/')) return `${domain}/${path}`
|
||
return `${domain}${path}`
|
||
}
|
||
|
||
const buildQqOfficialPlayUrl = (
|
||
domain: string,
|
||
info: Record<string, any> | undefined,
|
||
guid: string
|
||
) => {
|
||
if (!domain || !info) return ''
|
||
if (info.purl) return joinUrl(domain, info.purl)
|
||
if (info.vkey && info.filename) {
|
||
return `${joinUrl(domain, info.filename)}?vkey=${info.vkey}&guid=${guid}&fromtag=66`
|
||
}
|
||
return ''
|
||
}
|
||
|
||
const resolveQqVkeyUin = (cookieObject: Record<string, string>) => {
|
||
// 兼容 uin 仅存于 qqmusic_uin 的会话;与资料链路一致,剥离 openid 的 o 前缀
|
||
const raw = cookieObject.uin || cookieObject.qqmusic_uin || ''
|
||
return raw ? raw.replace(/^o/i, '') : '0'
|
||
}
|
||
|
||
const requestQqOfficialVkey = async (
|
||
payload: Record<string, any>,
|
||
cookie: string,
|
||
useSignedRequest: boolean
|
||
) => {
|
||
const headers: Record<string, string> = useSignedRequest
|
||
? { ...txHeaders }
|
||
: { ...QQ_OFFICIAL_HEADERS }
|
||
if (cookie) headers.Cookie = cookie
|
||
|
||
if (useSignedRequest) {
|
||
return await $fetch<any>(`${QQ_MUSICS_URL}?sign=${await zzcSign(JSON.stringify(payload))}`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: payload,
|
||
responseType: 'json',
|
||
signal: AbortSignal.timeout(6000)
|
||
})
|
||
}
|
||
|
||
return await $fetch<any>(QQ_MUSICU_URL, {
|
||
method: 'GET',
|
||
headers,
|
||
query: {
|
||
...QQ_COMMON_QUERY,
|
||
loginUin: payload.loginUin || '0',
|
||
format: 'json',
|
||
data: JSON.stringify(payload)
|
||
},
|
||
responseType: 'json',
|
||
signal: AbortSignal.timeout(6000)
|
||
})
|
||
}
|
||
|
||
const parseQqOfficialPlayUrl = (
|
||
response: Record<string, any> | undefined,
|
||
filenames: string[],
|
||
guid: string,
|
||
cdnSip: string
|
||
) => {
|
||
const data = response?.req_0?.data
|
||
const domain = cdnSip || pickPlayableDomain(data?.sip) || QQ_CDN_FALLBACK_DOMAIN
|
||
const midurlinfo = Array.isArray(data?.midurlinfo) ? data.midurlinfo : []
|
||
|
||
// 按候选顺位取第一个拿到直链的音质(降级命中)
|
||
let info = filenames
|
||
.map((filename) => midurlinfo.find((item: Record<string, any>) => item?.filename === filename))
|
||
.find((item) => item && (item.purl || item.vkey))
|
||
info = info || midurlinfo.find((item: Record<string, any>) => item?.purl || item?.vkey)
|
||
// 未命中可播放条目时回退首个请求档位,保留拒绝码用于诊断
|
||
info = info || midurlinfo.find((item: Record<string, any>) => item?.filename === filenames[0])
|
||
const url = buildQqOfficialPlayUrl(domain, info, guid)
|
||
|
||
return {
|
||
url,
|
||
info
|
||
}
|
||
}
|
||
|
||
// 登录态注入 comm 的 qq/authst/tmeLoginType,已登录账号才能解析 VIP/高音质直链
|
||
const resolveQqLoginType = (cookieObject: Record<string, string>, authst: string) => {
|
||
const raw = Number(cookieObject.tmeLoginType)
|
||
if (Number.isFinite(raw) && raw > 0) return raw
|
||
return authst.startsWith('W_X') ? 1 : 2
|
||
}
|
||
|
||
const createQqOfficialVkeyPayload = ({
|
||
songmid,
|
||
filenames,
|
||
guid,
|
||
uin,
|
||
authst,
|
||
tmeLoginType
|
||
}: {
|
||
songmid: string
|
||
filenames: string[]
|
||
guid: string
|
||
uin: string
|
||
authst?: string
|
||
tmeLoginType?: number
|
||
}) => {
|
||
const loginComm = authst ? { qq: uin, authst, tmeLoginType: tmeLoginType || 2 } : {}
|
||
return {
|
||
req_0: {
|
||
module: 'music.vkey.GetVkey',
|
||
method: 'UrlGetVkey',
|
||
param: {
|
||
uin: uin !== '0' ? uin : '',
|
||
filename: filenames,
|
||
guid,
|
||
songmid: filenames.map(() => songmid),
|
||
songtype: filenames.map(() => 0),
|
||
ctx: 0
|
||
}
|
||
},
|
||
loginUin: uin,
|
||
comm: {
|
||
uin,
|
||
format: 'json',
|
||
ct: 24,
|
||
cv: 0,
|
||
...loginComm
|
||
}
|
||
}
|
||
}
|
||
|
||
// CDN 调度取最新可用流媒体节点;失败时由调用方回退响应 sip 与兜底域名
|
||
const fetchQqCdnSip = async (): Promise<string> => {
|
||
const now = getServerTimestamp()
|
||
if (qqCdnCache && qqCdnCache.expireAt > now) return qqCdnCache.sips[0] || ''
|
||
|
||
try {
|
||
const payload = {
|
||
req_0: {
|
||
module: 'music.audioCdnDispatch.cdnDispatch',
|
||
method: 'GetCdnDispatch',
|
||
param: {
|
||
guid: randomUUID().replace(/-/g, ''),
|
||
uid: '0',
|
||
use_new_domain: 1,
|
||
use_ipv6: 1
|
||
}
|
||
},
|
||
comm: { uin: 0, format: 'json', ct: 24, cv: 0 }
|
||
}
|
||
const response = await $fetch<any>(
|
||
`${QQ_MUSICS_URL}?sign=${await zzcSign(JSON.stringify(payload))}`,
|
||
{
|
||
method: 'POST',
|
||
headers: { ...txHeaders },
|
||
body: payload,
|
||
responseType: 'json',
|
||
signal: AbortSignal.timeout(6000)
|
||
}
|
||
)
|
||
const sips = Array.isArray(response?.req_0?.data?.sip)
|
||
? response.req_0.data.sip.filter(
|
||
(sip: unknown) => typeof sip === 'string' && sip.startsWith('https://')
|
||
)
|
||
: []
|
||
qqCdnCache = { sips, expireAt: now + QQ_CDN_CACHE_TTL }
|
||
} catch (err) {
|
||
console.warn('[qq_music_sdk] CDN 调度失败,回退响应节点与兜底域名:', err)
|
||
qqCdnCache = { sips: [], expireAt: now + QQ_CDN_FAIL_TTL }
|
||
}
|
||
|
||
return qqCdnCache.sips[0] || ''
|
||
}
|
||
|
||
export const resolveQqOfficialPlayUrl = async ({
|
||
songmid,
|
||
quality,
|
||
cookie,
|
||
mediaId
|
||
}: {
|
||
songmid: string
|
||
quality?: unknown
|
||
cookie?: string
|
||
mediaId?: string
|
||
}) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
const qualityKey = QQ_OFFICIAL_QUALITY_MAP[String(quality ?? '8').toLowerCase()] || '320'
|
||
const songmidValue = String(songmid || '').trim()
|
||
|
||
if (!songmidValue) {
|
||
throw new Error('QQ 官方接口缺少 songmid')
|
||
}
|
||
|
||
// 无 media_mid 时上游规则是 mid 拼两次
|
||
const fileBase = String(mediaId || '').trim() || `${songmidValue}${songmidValue}`
|
||
|
||
// 自请求档位起向低档生成候选,批量请求、顺位命中,权限不足时链内降级
|
||
const qualityOrderIndex = QQ_OFFICIAL_QUALITY_ORDER.indexOf(qualityKey)
|
||
const qualityCandidates =
|
||
qualityOrderIndex >= 0 ? QQ_OFFICIAL_QUALITY_ORDER.slice(qualityOrderIndex) : ['320', '128', 'm4a']
|
||
const filenames = qualityCandidates.map((key) => {
|
||
const fileType = QQ_PLAY_FILE_TYPE_MAP[key]
|
||
return `${fileType.prefix}${fileBase}${fileType.suffix}`
|
||
})
|
||
|
||
const guid = randomUUID().replace(/-/g, '')
|
||
const uin = resolveQqVkeyUin(cookieObject)
|
||
const authst = cookieObject.qqmusic_key || cookieObject.qm_keyst || cookieObject.music_key || ''
|
||
const payload = createQqOfficialVkeyPayload({
|
||
songmid: songmidValue,
|
||
filenames,
|
||
guid,
|
||
uin,
|
||
authst,
|
||
tmeLoginType: authst ? resolveQqLoginType(cookieObject, authst) : undefined
|
||
})
|
||
const cdnSip = await fetchQqCdnSip()
|
||
|
||
let url: string | undefined
|
||
let info: Record<string, any> | undefined
|
||
|
||
try {
|
||
const normalResponse = await requestQqOfficialVkey(payload, normalizedCookie, false)
|
||
const parsed = parseQqOfficialPlayUrl(normalResponse, filenames, guid, cdnSip)
|
||
url = parsed.url
|
||
info = parsed.info
|
||
} catch (normalErr) {
|
||
console.warn('[qq_music_sdk] 未签名请求失败,尝试签名请求:', normalErr)
|
||
}
|
||
|
||
if (!url) {
|
||
try {
|
||
const signedResponse = await requestQqOfficialVkey(payload, normalizedCookie, true)
|
||
const signedResult = parseQqOfficialPlayUrl(signedResponse, filenames, guid, cdnSip)
|
||
url = signedResult.url || url
|
||
info = signedResult.info || info
|
||
} catch (signedErr) {
|
||
console.error('[qq_music_sdk] 签名请求也失败,保留已获取的诊断信息:', signedErr)
|
||
}
|
||
}
|
||
|
||
if (!url) {
|
||
const resultCode = info?.result === undefined ? 'missing' : String(info.result)
|
||
const subcode = info?.subcode === undefined ? 'missing' : String(info.subcode)
|
||
const mid = info?.mid || 'missing'
|
||
const tips = info?.tips ? `, tips=${String(info.tips)}` : ''
|
||
throw new Error(
|
||
`QQ 官方接口未返回播放链接:filename=${filenames.join('|')}, result=${resultCode}, subcode=${subcode}, mid=${mid}, purl=${Boolean(info?.purl)}, vkey=${Boolean(info?.vkey)}${tips}`
|
||
)
|
||
}
|
||
|
||
return upgradeTxAudioUrl(url)
|
||
}
|
||
|
||
// CgiGetVkey/UrlGetVkey 常见拒绝码语义
|
||
const QQ_VKEY_RESULT_HINTS: Record<string, string> = {
|
||
'104003': '该歌曲或音质需要绿钻/付费权限,或受版权、风控限制',
|
||
'104002': '账号权益不足或登录态被限制',
|
||
'104004': 'VKey 生成失败或歌曲文件不存在',
|
||
'104013': '播放设备受限或触发风控',
|
||
'-1': '请求参数或登录态不被接受',
|
||
'-2': '歌曲不存在或已下架'
|
||
}
|
||
|
||
const describeVkeyResult = (result: unknown) => {
|
||
if (result === undefined || result === null) return ''
|
||
return QQ_VKEY_RESULT_HINTS[String(result)] || ''
|
||
}
|
||
|
||
export const resolveQqSdkPlayUrl = async (
|
||
songmid: string,
|
||
quality?: unknown,
|
||
cookie?: string,
|
||
mediaId?: string
|
||
) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
// resType=all 保留上游完整数据(含 midurlinfo 的 result/tips),便于诊断失败原因
|
||
const body = unwrapQqSdkResponse(
|
||
await getMusicPlay({
|
||
songmid,
|
||
quality: normalizeQqSdkQuality(quality),
|
||
resType: 'all',
|
||
mediaId,
|
||
cookie: normalizedCookie
|
||
}),
|
||
'qq-music-api 未返回播放链接'
|
||
)
|
||
|
||
const data = body?.response || body?.data || body
|
||
const url =
|
||
findFirstUrl(data?.url) ||
|
||
findFirstUrl(data?.playUrl?.[songmid]) ||
|
||
findFirstUrl(data?.playUrl)
|
||
|
||
if (!url || typeof url !== 'string') {
|
||
const errors = collectPlayUrlErrors(data?.playUrl?.[songmid] || data?.playUrl)
|
||
const diagnostic = getQqCookieDiagnostic(normalizedCookie)
|
||
const reason = errors.length ? `:${[...new Set(errors)].join('、')}` : ''
|
||
const authHint = diagnostic.hasCookie
|
||
? `(Cookie: uin=${diagnostic.hasUin ? `present:${diagnostic.uinType}` : 'missing'}, authKey=${diagnostic.hasAuthKey ? `${diagnostic.authKeys.join('/')}, used=${diagnostic.authKeySource}` : 'missing'})`
|
||
: '(未传 Cookie)'
|
||
|
||
// 上游逐歌曲信息:区分「Cookie 无效」与「账号无权益/非 VIP」
|
||
const midurlinfo = Array.isArray(data?.req_0?.data?.midurlinfo) ? data.req_0.data.midurlinfo : []
|
||
const resultCodes = midurlinfo.map((item: Record<string, any>) => String(item?.result))
|
||
const semanticHint = [...new Set(resultCodes)]
|
||
.map(describeVkeyResult)
|
||
.filter(Boolean)
|
||
.join(';')
|
||
const upstreamHint = midurlinfo.length
|
||
? `;上游: ${midurlinfo
|
||
.map((item: Record<string, any>) =>
|
||
[
|
||
`result=${item?.result ?? 'missing'}`,
|
||
`subcode=${item?.subcode ?? 'missing'}`,
|
||
`tips=${String(item?.tips || '') || 'none'}`,
|
||
`vkey=${Boolean(item?.vkey)}`,
|
||
`filename=${item?.filename || 'none'}`
|
||
].join(', ')
|
||
)
|
||
.join(' | ')}`
|
||
: ''
|
||
|
||
const cause = semanticHint || (reason ? reason.replace(/^:/, '') : '')
|
||
throw new Error(
|
||
`${cause ? `QQ 官方链路未下发播放链接(${cause})` : 'qq-music-api 未返回播放链接'}${upstreamHint}${authHint}`
|
||
)
|
||
}
|
||
|
||
return upgradeTxAudioUrl(url)
|
||
}
|
||
|
||
export const resolveQqSdkLyric = async ({
|
||
songmid,
|
||
songid,
|
||
cookie
|
||
}: {
|
||
songmid?: string
|
||
songid?: string
|
||
cookie?: string
|
||
}) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const body = unwrapQqSdkResponse(
|
||
await getLyric({
|
||
songmid,
|
||
songid,
|
||
isFormat: false,
|
||
cookie: normalizedCookie
|
||
}),
|
||
'qq-music-api 未返回歌词'
|
||
)
|
||
|
||
const data = body.response || body.data || body
|
||
const lrc = typeof data?.lyric === 'string'
|
||
? data.lyric
|
||
: decodeMaybeBase64(data?.lrc)
|
||
const trans = decodeMaybeBase64(data?.trans)
|
||
|
||
if (!lrc) {
|
||
throw new Error('qq-music-api 未返回歌词')
|
||
}
|
||
|
||
return { lrc, trans }
|
||
}
|
||
|
||
export const getQqLoginQr = async () => {
|
||
const body = unwrapQqSdkResponse(await getQQLoginQr(), '获取 QQ 登录二维码失败')
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
export const checkQqLogin = async (ptqrtoken: string | number, qrsig: string) => {
|
||
const body = unwrapQqSdkResponse(
|
||
await checkQQLoginQr({ ptqrtoken, qrsig }),
|
||
'检查 QQ 登录状态失败'
|
||
)
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
// 微信区扫码:本地库新链路,session cookie 与 QQ 扫码同构(tmeLoginType=1)
|
||
export const getQqWxLoginQr = async () => {
|
||
const body = unwrapQqSdkResponse(await getWXLoginQr(), '获取微信登录二维码失败')
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
export const checkQqWxLogin = async (uuid: string) => {
|
||
const body = unwrapQqSdkResponse(await checkWXLoginQr({ params: { uuid } }), '检查微信登录状态失败')
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
export const getQqUserAvatar = async ({
|
||
uin,
|
||
k,
|
||
size = 140
|
||
}: {
|
||
uin?: string
|
||
k?: string
|
||
size?: number
|
||
}) => {
|
||
return await getUserAvatar({ uin, k, size })
|
||
}
|
||
|
||
export const getQqVipInfo = async ({ cookie }: { cookie?: string }) => {
|
||
const body = unwrapQqSdkResponse(await getVipInfo({ cookie }), '获取 QQ VIP 信息失败')
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
export const getQqUserDetail = async ({ uin, cookie }: { uin: string; cookie?: string }) => {
|
||
const body = unwrapQqSdkResponse(
|
||
await getUserDetail({ uin, cookie }),
|
||
'获取 QQ 用户信息失败'
|
||
)
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
// 在响应树中递归查找首个含非空昵称字段的节点(上游资料页结构多变,做防御性解析)
|
||
const findProfileNode = (value: unknown, depth = 0): Record<string, any> | undefined => {
|
||
if (depth > 8 || !value || typeof value !== 'object') return undefined
|
||
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const found = findProfileNode(item, depth + 1)
|
||
if (found) return found
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
const record = value as Record<string, any>
|
||
const nick =
|
||
typeof record.nick === 'string'
|
||
? record.nick
|
||
: typeof record.nickname === 'string'
|
||
? record.nickname
|
||
: ''
|
||
if (nick.trim()) return record
|
||
|
||
for (const child of Object.values(record)) {
|
||
const found = findProfileNode(child, depth + 1)
|
||
if (found) return found
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/**
|
||
* 从 vip_login_base 响应数据判断账号是否具备 VIP/超级会员权益。
|
||
* 字段形态因登录方式而异,统一做数值归一化,异常值视为非 VIP。
|
||
*/
|
||
export const isQqVipFromLoginBaseData = (data: unknown): boolean => {
|
||
const toNum = (value: unknown): number => {
|
||
const num = Number(value)
|
||
return Number.isFinite(num) ? num : 0
|
||
}
|
||
const record = (data && typeof data === 'object' ? data : {}) as Record<string, any>
|
||
const identity = (
|
||
record.identity && typeof record.identity === 'object' ? record.identity : {}
|
||
) as Record<string, any>
|
||
return (
|
||
toNum(record.svip) > 0 ||
|
||
toNum(identity.vip) > 0 ||
|
||
toNum(identity.ExpVip) > 0 ||
|
||
toNum(identity.HugeVip) > 0 ||
|
||
toNum(identity.GroupVipFlag) > 0 ||
|
||
toNum(identity.ChildVip) > 0 ||
|
||
Boolean(record.mygreen)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 校验 QQ 音乐登录 Cookie 并提取用户档案。
|
||
* vip_login_base 是账户级接口,失效 Cookie 会返回错误码,作为有效性的主判据;
|
||
* 主页资料接口用于补充昵称/头像。
|
||
* 探针不走库内封装:微信区 Cookie(tmeLoginType=1,无 p_skey)在库的
|
||
* 默认 comm 下会被上游拒绝,必须补齐 tmeLoginType 与计算出的 g_tk。
|
||
*/
|
||
export const checkQqCookie = async ({ cookie }: { cookie?: string }) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const diagnostic = getQqCookieDiagnostic(normalizedCookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
const rawUin = String(cookieObject.uin || '').replace(/^o/i, '')
|
||
|
||
let vipOk = false
|
||
let vipCode: unknown
|
||
let isVip = false
|
||
let detailOk = false
|
||
|
||
try {
|
||
const numericLoginType = Number(cookieObject.tmeLoginType)
|
||
const resp: any = await callQqMusicu({
|
||
module: 'VipLogin.VipLoginInter',
|
||
method: 'vip_login_base',
|
||
param: {},
|
||
cookie: normalizedCookie,
|
||
extraComm: Number.isFinite(numericLoginType) ? { tmeLoginType: numericLoginType } : undefined
|
||
})
|
||
const reqData = resp?.req_1 || {}
|
||
vipCode = reqData.code
|
||
vipOk = Number(reqData.code) === 0
|
||
if (vipOk) {
|
||
isVip = isQqVipFromLoginBaseData(reqData.data)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[qq_music_sdk] vip_login_base 失败:', error instanceof Error ? error.message : error)
|
||
}
|
||
|
||
let profile: { nickname?: string; avatarUrl?: string; userId?: string } | undefined
|
||
|
||
// 主页直连(正确 g_tk)作为资料来源与会话佐证
|
||
if (/^\d+$/.test(rawUin)) {
|
||
try {
|
||
const homepage: any = await requestQqProfileHomepage({
|
||
uin: rawUin,
|
||
cookie: normalizedCookie,
|
||
gtk: getQqGtk(cookieObject)
|
||
})
|
||
const payload = homepage?.data || {}
|
||
if (Number(homepage?.code) === 0 && ['mydiss', 'mymusic', 'creator'].some((key) => key in payload)) {
|
||
detailOk = true
|
||
}
|
||
const node = findProfileNode(homepage?.data || {})
|
||
if (node) {
|
||
profile = buildProfileFromNode(node, rawUin)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[qq_music_sdk] 主页直连失败:', error instanceof Error ? error.message : error)
|
||
}
|
||
|
||
// 直连未取得资料时回退库封装(历史 QQ 区路径)
|
||
if (!profile) {
|
||
try {
|
||
const detail = await getQqUserDetail({ uin: rawUin, cookie: normalizedCookie })
|
||
const node = findProfileNode(detail)
|
||
if (node) {
|
||
detailOk = true
|
||
profile = buildProfileFromNode(node, rawUin)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[qq_music_sdk] getUserDetail 回退失败:', error instanceof Error ? error.message : error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 微信区 cookie 在 homepage/GetLoginUserInfo 均返回 1000,改从创建歌单的创建者字段提取昵称
|
||
if (!profile && /^\d+$/.test(rawUin)) {
|
||
try {
|
||
const resp: any = await callQqMusicu({
|
||
module: 'music.musicasset.PlaylistBaseRead',
|
||
method: 'GetPlaylistByUin',
|
||
param: { uin: rawUin },
|
||
cookie: normalizedCookie
|
||
})
|
||
const list = Array.isArray(resp?.req_1?.data?.v_playlist) ? resp.req_1.data.v_playlist : []
|
||
const creator = list.find((item: any) => item?.nick?.trim() || item?.avatar)
|
||
if (creator?.nick?.trim() || creator?.avatar) {
|
||
profile = {
|
||
nickname: creator.nick?.trim() || undefined,
|
||
avatarUrl: typeof creator.avatar === 'string' ? creator.avatar : undefined,
|
||
userId: rawUin
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('[qq_music_sdk] 歌单创建者信息兜底失败:', error instanceof Error ? error.message : error)
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: vipOk || detailOk,
|
||
isVip,
|
||
signals: {
|
||
vipOk,
|
||
vipCode: vipCode === undefined ? null : vipCode,
|
||
detailOk
|
||
},
|
||
profile,
|
||
authDiagnostic: diagnostic
|
||
}
|
||
}
|
||
|
||
export const searchQqMusic = async ({
|
||
key,
|
||
limit,
|
||
page,
|
||
cookie
|
||
}: {
|
||
key: string
|
||
limit?: number
|
||
page?: number
|
||
cookie?: string
|
||
}) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const body = unwrapQqSdkResponse(
|
||
await search({
|
||
key,
|
||
limit,
|
||
page,
|
||
option: normalizedCookie
|
||
? { headers: { Cookie: normalizedCookie } }
|
||
: undefined
|
||
}),
|
||
'qq-music-api 搜索失败'
|
||
)
|
||
|
||
return body.response || body.data || body
|
||
}
|
||
|
||
// ─── QRC 解密 ─────────────────────────────────────────────────────────────────
|
||
|
||
const QRC_KEY = Buffer.from('!@#)(*$%123ZXC!@!@#)(NHL', 'utf8')
|
||
|
||
const SBOX: number[][] = [
|
||
[14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7,0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8,4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0,15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13],
|
||
[15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10,3,13,4,7,15,2,8,15,12,0,1,10,6,9,11,5,0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15,13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9],
|
||
[10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8,13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1,13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7,1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12],
|
||
[7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15,13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9,10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4,3,15,0,6,10,10,13,8,9,4,5,11,12,7,2,14],
|
||
[2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9,14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6,4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14,11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3],
|
||
[12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11,10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8,9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6,4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13],
|
||
[4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1,13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6,1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2,6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12],
|
||
[13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7,1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2,7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8,2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11],
|
||
]
|
||
|
||
const _bn = (a: Buffer, b: number, c: number): number => {
|
||
const bi = Math.floor(b/32)*4+3-Math.floor((b%32)/8)
|
||
return ((a[bi]>>(7-(b%8)))&1)<<c
|
||
}
|
||
const _bni = (a: number, b: number, c: number): number => ((a>>(31-b))&1)<<c
|
||
const _bnl = (a: number, b: number, c: number): number => (((a<<b)&0x80000000)>>>c)>>>0
|
||
const _sb = (a: number): number => (a&32)|((a&31)>>1)|((a&1)<<4)
|
||
|
||
const _ip = (inp: Buffer): [number,number] => {
|
||
const s0=((_bn(inp,57,31)|_bn(inp,49,30)|_bn(inp,41,29)|_bn(inp,33,28)|_bn(inp,25,27)|_bn(inp,17,26)|_bn(inp,9,25)|_bn(inp,1,24)|_bn(inp,59,23)|_bn(inp,51,22)|_bn(inp,43,21)|_bn(inp,35,20)|_bn(inp,27,19)|_bn(inp,19,18)|_bn(inp,11,17)|_bn(inp,3,16)|_bn(inp,61,15)|_bn(inp,53,14)|_bn(inp,45,13)|_bn(inp,37,12)|_bn(inp,29,11)|_bn(inp,21,10)|_bn(inp,13,9)|_bn(inp,5,8)|_bn(inp,63,7)|_bn(inp,55,6)|_bn(inp,47,5)|_bn(inp,39,4)|_bn(inp,31,3)|_bn(inp,23,2)|_bn(inp,15,1)|_bn(inp,7,0)))>>>0
|
||
const s1=((_bn(inp,56,31)|_bn(inp,48,30)|_bn(inp,40,29)|_bn(inp,32,28)|_bn(inp,24,27)|_bn(inp,16,26)|_bn(inp,8,25)|_bn(inp,0,24)|_bn(inp,58,23)|_bn(inp,50,22)|_bn(inp,42,21)|_bn(inp,34,20)|_bn(inp,26,19)|_bn(inp,18,18)|_bn(inp,10,17)|_bn(inp,2,16)|_bn(inp,60,15)|_bn(inp,52,14)|_bn(inp,44,13)|_bn(inp,36,12)|_bn(inp,28,11)|_bn(inp,20,10)|_bn(inp,12,9)|_bn(inp,4,8)|_bn(inp,62,7)|_bn(inp,54,6)|_bn(inp,46,5)|_bn(inp,38,4)|_bn(inp,30,3)|_bn(inp,22,2)|_bn(inp,14,1)|_bn(inp,6,0)))>>>0
|
||
return [s0,s1]
|
||
}
|
||
|
||
const _fp = (s0: number, s1: number): Buffer => {
|
||
const d=Buffer.alloc(8)
|
||
d[3]=(_bni(s1,7,7)|_bni(s0,7,6)|_bni(s1,15,5)|_bni(s0,15,4)|_bni(s1,23,3)|_bni(s0,23,2)|_bni(s1,31,1)|_bni(s0,31,0))
|
||
d[2]=(_bni(s1,6,7)|_bni(s0,6,6)|_bni(s1,14,5)|_bni(s0,14,4)|_bni(s1,22,3)|_bni(s0,22,2)|_bni(s1,30,1)|_bni(s0,30,0))
|
||
d[1]=(_bni(s1,5,7)|_bni(s0,5,6)|_bni(s1,13,5)|_bni(s0,13,4)|_bni(s1,21,3)|_bni(s0,21,2)|_bni(s1,29,1)|_bni(s0,29,0))
|
||
d[0]=(_bni(s1,4,7)|_bni(s0,4,6)|_bni(s1,12,5)|_bni(s0,12,4)|_bni(s1,20,3)|_bni(s0,20,2)|_bni(s1,28,1)|_bni(s0,28,0))
|
||
d[7]=(_bni(s1,3,7)|_bni(s0,3,6)|_bni(s1,11,5)|_bni(s0,11,4)|_bni(s1,19,3)|_bni(s0,19,2)|_bni(s1,27,1)|_bni(s0,27,0))
|
||
d[6]=(_bni(s1,2,7)|_bni(s0,2,6)|_bni(s1,10,5)|_bni(s0,10,4)|_bni(s1,18,3)|_bni(s0,18,2)|_bni(s1,26,1)|_bni(s0,26,0))
|
||
d[5]=(_bni(s1,1,7)|_bni(s0,1,6)|_bni(s1,9,5)|_bni(s0,9,4)|_bni(s1,17,3)|_bni(s0,17,2)|_bni(s1,25,1)|_bni(s0,25,0))
|
||
d[4]=(_bni(s1,0,7)|_bni(s0,0,6)|_bni(s1,8,5)|_bni(s0,8,4)|_bni(s1,16,3)|_bni(s0,16,2)|_bni(s1,24,1)|_bni(s0,24,0))
|
||
return d
|
||
}
|
||
|
||
const _f = (state: number, key: number[]): number => {
|
||
const t1=(_bnl(state,31,0)|((state&0xf0000000)>>>1)|_bnl(state,4,5)|_bnl(state,3,6)|((state&0x0f000000)>>>3)|_bnl(state,8,11)|_bnl(state,7,12)|((state&0x00f00000)>>>5)|_bnl(state,12,17)|_bnl(state,11,18)|((state&0x000f0000)>>>7)|_bnl(state,16,23))>>>0
|
||
const t2=(_bnl(state,15,0)|((state&0x0000f000)<<15)|_bnl(state,20,5)|_bnl(state,19,6)|((state&0x00000f00)<<13)|_bnl(state,24,11)|_bnl(state,23,12)|((state&0x000000f0)<<11)|_bnl(state,28,17)|_bnl(state,27,18)|((state&0x0000000f)<<9)|_bnl(state,0,23))>>>0
|
||
const lg=[((t1>>>24)&0xff)^key[0],((t1>>>16)&0xff)^key[1],((t1>>>8)&0xff)^key[2],((t2>>>24)&0xff)^key[3],((t2>>>16)&0xff)^key[4],((t2>>>8)&0xff)^key[5]]
|
||
state=((SBOX[0][_sb(lg[0]>>>2)]<<28)|(SBOX[1][_sb(((lg[0]&3)<<4)|(lg[1]>>>4))]<<24)|(SBOX[2][_sb(((lg[1]&15)<<2)|(lg[2]>>>6))]<<20)|(SBOX[3][_sb(lg[2]&63)]<<16)|(SBOX[4][_sb(lg[3]>>>2)]<<12)|(SBOX[5][_sb(((lg[3]&3)<<4)|(lg[4]>>>4))]<<8)|(SBOX[6][_sb(((lg[4]&15)<<2)|(lg[5]>>>6))]<<4)|SBOX[7][_sb(lg[5]&63)])>>>0
|
||
return (_bnl(state,15,0)|_bnl(state,6,1)|_bnl(state,19,2)|_bnl(state,20,3)|_bnl(state,28,4)|_bnl(state,11,5)|_bnl(state,27,6)|_bnl(state,16,7)|_bnl(state,0,8)|_bnl(state,14,9)|_bnl(state,22,10)|_bnl(state,25,11)|_bnl(state,4,12)|_bnl(state,17,13)|_bnl(state,30,14)|_bnl(state,9,15)|_bnl(state,1,16)|_bnl(state,7,17)|_bnl(state,23,18)|_bnl(state,13,19)|_bnl(state,31,20)|_bnl(state,26,21)|_bnl(state,2,22)|_bnl(state,8,23)|_bnl(state,18,24)|_bnl(state,12,25)|_bnl(state,29,26)|_bnl(state,5,27)|_bnl(state,21,28)|_bnl(state,10,29)|_bnl(state,3,30)|_bnl(state,24,31))>>>0
|
||
}
|
||
|
||
const _ks = (key: Buffer, mode: number): number[][] => {
|
||
const sch: number[][]=Array.from({length:16},()=>Array(6).fill(0))
|
||
const sh=[1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1]
|
||
const pC=[56,48,40,32,24,16,8,0,57,49,41,33,25,17,9,1,58,50,42,34,26,18,10,2,59,51,43,35]
|
||
const pD=[62,54,46,38,30,22,14,6,61,53,45,37,29,21,13,5,60,52,44,36,28,20,12,4,27,19,11,3]
|
||
const cp=[13,16,10,23,0,4,2,27,14,5,20,9,22,18,11,3,25,7,15,6,26,19,12,1,40,51,30,36,46,54,29,39,50,44,32,47,43,48,38,55,33,52,45,41,49,35,28,31]
|
||
let c=0,d=0
|
||
for(let i=0;i<28;i++){c|=_bn(key,pC[i],31-i);d|=_bn(key,pD[i],31-i)}
|
||
for(let i=0;i<16;i++){
|
||
c=(((c<<sh[i])|(c>>>(28-sh[i])))&0xfffffff0)>>>0
|
||
d=(((d<<sh[i])|(d>>>(28-sh[i])))&0xfffffff0)>>>0
|
||
const t=mode===0?15-i:i
|
||
for(let j=0;j<24;j++)sch[t][Math.floor(j/8)]|=_bni(c,cp[j],7-(j%8))
|
||
for(let j=24;j<48;j++)sch[t][Math.floor(j/8)]|=_bni(d,cp[j]-27,7-(j%8))
|
||
}
|
||
return sch
|
||
}
|
||
|
||
const _desBlock = (inp: Buffer, key: number[][]): Buffer => {
|
||
let [s0,s1]=_ip(inp)
|
||
for(let i=0;i<15;i++){const p=s1;s1=(_f(s1,key[i])^s0)>>>0;s0=p}
|
||
s0=(_f(s1,key[15])^s0)>>>0
|
||
return _fp(s0,s1)
|
||
}
|
||
|
||
// 预计算 QRC_KEY 的密钥调度,避免每次解密重复计算
|
||
const QRC_DES_KEYS = {
|
||
k1: _ks(QRC_KEY.slice(16, 24), 0),
|
||
k2: _ks(QRC_KEY.slice(8, 16), 1),
|
||
k3: _ks(QRC_KEY.slice(0, 8), 0)
|
||
}
|
||
|
||
const tripleDesDecrypt = (data: Buffer): Buffer => {
|
||
const { k1, k2, k3 } = QRC_DES_KEYS
|
||
const result = Buffer.alloc(data.length)
|
||
for (let i = 0; i < data.length; i += 8) {
|
||
const b = data.slice(i, i + 8)
|
||
_desBlock(_desBlock(_desBlock(b, k1), k2), k3).copy(result, i)
|
||
}
|
||
return result
|
||
}
|
||
|
||
const decryptQrc = (encryptedHex: string): string => {
|
||
if (!encryptedHex?.trim()) throw new Error('QRC 密文为空')
|
||
const decrypted = tripleDesDecrypt(Buffer.from(encryptedHex, 'hex'))
|
||
for (const decompress of [inflateSync, inflateRawSync, unzipSync]) {
|
||
try { return decompress(decrypted).toString('utf8') } catch { /* 继续 */ }
|
||
}
|
||
const raw = decrypted.toString('utf8')
|
||
if (raw.includes('[') || raw.includes('<')) return raw
|
||
throw new Error('QRC 解压失败')
|
||
}
|
||
|
||
const tryDecryptQrc = (hex: string | undefined): string | undefined => {
|
||
if (!hex) return undefined
|
||
try { return decryptQrc(hex) } catch { return undefined }
|
||
}
|
||
|
||
// ─── QQ 音乐原生歌词接口(支持 QRC 逐字)────────────────────────────────────
|
||
|
||
/**
|
||
* 调用 music.musichallSong.PlayLyricInfo/GetPlayLyricInfo 获取 QRC 歌词
|
||
*/
|
||
export const resolveQqNativeLyric = async ({
|
||
songId,
|
||
name = '',
|
||
artist = '',
|
||
album = '',
|
||
duration = 0,
|
||
cookie
|
||
}: {
|
||
songId: string | number
|
||
name?: string
|
||
artist?: string
|
||
album?: string
|
||
duration?: number
|
||
cookie?: string
|
||
}): Promise<{ lrc?: string; qrc?: string; trans?: string; roma?: string }> => {
|
||
const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64')
|
||
|
||
const baseParam = {
|
||
albumName: b64(album),
|
||
crypt: 1,
|
||
ct: 19,
|
||
cv: 2111,
|
||
interval: duration,
|
||
lrc_t: 0,
|
||
qrc: 1,
|
||
qrc_t: 0,
|
||
roma: 1,
|
||
roma_t: 0,
|
||
singerName: b64(artist),
|
||
songID: Number(songId),
|
||
songName: b64(name),
|
||
trans: 1,
|
||
trans_t: 0,
|
||
type: 0
|
||
}
|
||
|
||
const buildBody = (param: Record<string, unknown>) => ({
|
||
comm: { ct: '19', cv: '1859', uin: '0' },
|
||
req: {
|
||
module: 'music.musichallSong.PlayLyricInfo',
|
||
method: 'GetPlayLyricInfo',
|
||
param
|
||
}
|
||
})
|
||
|
||
const normalizedCookie = cookie ? normalizeQqCookie(cookie) : ''
|
||
|
||
const doRequest = async (param: Record<string, unknown>) => {
|
||
const body = buildBody(param)
|
||
const headers: Record<string, string> = { ...txHeaders }
|
||
if (normalizedCookie) headers['Cookie'] = normalizedCookie
|
||
|
||
// 优先签名请求,稳定性更好;失败回退普通请求
|
||
try {
|
||
const sign = await zzcSign(JSON.stringify(body))
|
||
return await $fetch<any>(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
|
||
method: 'POST', headers, body, responseType: 'json',
|
||
signal: AbortSignal.timeout(8000)
|
||
})
|
||
} catch {
|
||
return await $fetch<any>('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||
method: 'POST', headers, body, responseType: 'json',
|
||
signal: AbortSignal.timeout(8000)
|
||
})
|
||
}
|
||
}
|
||
|
||
const resp = await doRequest(baseParam)
|
||
const data = resp?.req?.data ?? resp?.request?.data ?? {}
|
||
const result: { lrc?: string; qrc?: string; trans?: string; roma?: string } = {}
|
||
|
||
// qrc_t 为非零数字时 lyric 字段是 QRC,否则是 LRC
|
||
const mainDecrypted = tryDecryptQrc(data.lyric)
|
||
if (mainDecrypted) {
|
||
if (typeof data.qrc_t === 'number' && data.qrc_t !== 0) {
|
||
result.qrc = mainDecrypted
|
||
} else {
|
||
result.lrc = mainDecrypted
|
||
}
|
||
}
|
||
|
||
// 有 QRC 但缺 LRC 时补一次请求(翻译对齐需要 LRC 时间戳)
|
||
if (result.qrc && !result.lrc) {
|
||
try {
|
||
const lrcResp = await doRequest({ ...baseParam, qrc: 0, qrc_t: 0 })
|
||
const lrcData = lrcResp?.req?.data ?? lrcResp?.request?.data ?? {}
|
||
const lrcText = tryDecryptQrc(lrcData.lyric)
|
||
if (lrcText) result.lrc = lrcText
|
||
} catch { /* 次级失败不影响主结果 */ }
|
||
}
|
||
|
||
result.trans = tryDecryptQrc(data.trans) || undefined
|
||
result.roma = tryDecryptQrc(data.roma) || undefined
|
||
|
||
return result
|
||
}
|
||
|
||
// ─── QQ 音乐用户资料库(歌单 / 我喜欢歌曲)────────────────────────────────────
|
||
|
||
export type QqLibraryPlaylist = {
|
||
id: string
|
||
name: string
|
||
count: number
|
||
cover: string
|
||
origin: 'created' | 'collected'
|
||
}
|
||
|
||
const normalizeQqPlaylistSummary = (
|
||
raw: Record<string, any>,
|
||
origin: QqLibraryPlaylist['origin']
|
||
): QqLibraryPlaylist | undefined => {
|
||
const basic = raw?.basic && typeof raw.basic === 'object' ? raw.basic : undefined
|
||
const source = basic || raw
|
||
const cover =
|
||
source.picurl ??
|
||
source.logo ??
|
||
source.imgurl ??
|
||
source.photo ??
|
||
source.picUrl ??
|
||
source.bigpicUrl ??
|
||
source.cover_url_medium ??
|
||
source.cover?.medium_url ??
|
||
source.cover?.default_url
|
||
|
||
const id = String(source.dissid ?? source.disstid ?? source.tid ?? source.id ?? '').trim()
|
||
const name = String(
|
||
source.dissname ?? source.dirName ?? source.title ?? source.dirname ?? source.name ?? ''
|
||
).trim()
|
||
if (!id || !name) return undefined
|
||
|
||
return {
|
||
id,
|
||
name,
|
||
count:
|
||
Number(
|
||
source.song_count ?? source.songcount ?? source.songNum ?? source.songnum ?? source.song_cnt ?? source.num
|
||
) || 0,
|
||
cover: String(cover || ''),
|
||
origin
|
||
}
|
||
}
|
||
|
||
// QQ 标准 g_tk 算法;无 p_skey 时哈希空串得 5381
|
||
const getQqGtk = (cookieObject: Record<string, string>) => {
|
||
const seed = cookieObject.p_skey || ''
|
||
let hash = 5381
|
||
for (let i = 0; i < seed.length; i += 1) {
|
||
hash += (hash << 5) + seed.charCodeAt(i)
|
||
}
|
||
return hash & 0x7fffffff
|
||
}
|
||
|
||
// 主页直连请求。uin 必须保持字符串原样:微信区 uin 超过 Number.MAX_SAFE_INTEGER,
|
||
// 经数值转换会精度截断导致上游返回 1000 查无此人。
|
||
const requestQqProfileHomepage = async ({
|
||
uin,
|
||
cookie,
|
||
gtk
|
||
}: {
|
||
uin: string
|
||
cookie: string
|
||
gtk: number
|
||
}) => {
|
||
return await $fetch<Record<string, any>>(
|
||
'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg',
|
||
{
|
||
params: {
|
||
_: getServerTimestamp(),
|
||
cv: '4747474',
|
||
ct: '24',
|
||
format: 'json',
|
||
inCharset: 'utf-8',
|
||
outCharset: 'utf-8',
|
||
notice: '0',
|
||
platform: 'yqq.json',
|
||
needNewCode: '0',
|
||
uin,
|
||
g_tk_new_20200303: String(gtk),
|
||
g_tk: String(gtk),
|
||
cid: '205360838',
|
||
userid: uin,
|
||
reqfrom: '1',
|
||
reqtype: '0',
|
||
hostUin: '0',
|
||
loginUin: uin
|
||
},
|
||
headers: {
|
||
// 浏览器 UA 更贴近真实官网请求形态
|
||
'User-Agent':
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
Referer: `https://y.qq.com/portal/profile.html?uin=${uin}`,
|
||
Cookie: cookie
|
||
},
|
||
signal: AbortSignal.timeout(8000)
|
||
}
|
||
)
|
||
}
|
||
|
||
const buildProfileFromNode = (
|
||
node: Record<string, any>,
|
||
userId: string
|
||
): { nickname?: string; avatarUrl?: string; userId?: string } | undefined => {
|
||
const nickname =
|
||
typeof node?.nick === 'string'
|
||
? node.nick.trim()
|
||
: typeof node?.nickname === 'string'
|
||
? node.nickname.trim()
|
||
: ''
|
||
const avatarUrl =
|
||
typeof node?.headpic === 'string' && node.headpic.startsWith('http')
|
||
? node.headpic
|
||
: typeof node?.avatarUrl === 'string' && node.avatarUrl.startsWith('http')
|
||
? node.avatarUrl
|
||
: undefined
|
||
|
||
if (!nickname && !avatarUrl) return undefined
|
||
return { nickname: nickname || undefined, avatarUrl, userId }
|
||
}
|
||
|
||
// 合并用户创建的与收藏的歌单,id 去重时保留创建标记;收藏接口要求加密 euin
|
||
export const getQqUserPlaylists = async ({ cookie }: { cookie?: string }) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
// uin 可能带 o 前缀(openid 形态),主页接口要求纯数字 uin
|
||
const uin = String(cookieObject.uin || '').replace(/^o/i, '')
|
||
if (!/^\d+$/.test(uin)) {
|
||
throw new Error('Cookie 中缺少有效 uin,请重新登录')
|
||
}
|
||
|
||
const createdResp = await callQqMusicu({
|
||
module: 'music.musicasset.PlaylistBaseRead',
|
||
method: 'GetPlaylistByUin',
|
||
param: { uin },
|
||
cookie: normalizedCookie
|
||
})
|
||
if (Number(createdResp?.req_1?.code) !== 0) {
|
||
throw new Error('获取用户歌单失败')
|
||
}
|
||
const createdData = createdResp.req_1.data || {}
|
||
const createdRawList: Record<string, any>[] = Array.isArray(createdData.v_playlist)
|
||
? createdData.v_playlist
|
||
: []
|
||
|
||
let collectedRawList: Record<string, any>[] = []
|
||
const euin = String(cookieObject.euin || '')
|
||
if (euin) {
|
||
const collectedResp = await callQqMusicu({
|
||
module: 'music.musicasset.PlaylistFavRead',
|
||
method: 'CgiGetPlaylistFavInfo',
|
||
param: { uin: euin, offset: 0, size: 100 },
|
||
cookie: normalizedCookie
|
||
})
|
||
if (Number(collectedResp?.req_1?.code) === 0) {
|
||
collectedRawList = Array.isArray(collectedResp?.req_1?.data?.v_list)
|
||
? collectedResp.req_1.data.v_list
|
||
: []
|
||
}
|
||
}
|
||
|
||
const seen = new Set<string>()
|
||
const playlists: QqLibraryPlaylist[] = []
|
||
for (const [raw, origin] of [
|
||
...createdRawList.map((item) => [item, 'created'] as const),
|
||
...collectedRawList.map((item) => [item, 'collected'] as const)
|
||
]) {
|
||
// "我喜欢"由前端固定入口承载,避免重复
|
||
if (origin === 'created' && Number(raw?.dirId) === 201) continue
|
||
const summary = normalizeQqPlaylistSummary(raw, origin)
|
||
if (!summary || seen.has(summary.id)) continue
|
||
seen.add(summary.id)
|
||
playlists.push(summary)
|
||
}
|
||
|
||
return { playlists }
|
||
}
|
||
|
||
const normalizeQqLibrarySong = (raw: Record<string, any>) => {
|
||
const singerList = Array.isArray(raw?.singer)
|
||
? raw.singer
|
||
: Array.isArray(raw?.singers)
|
||
? raw.singers
|
||
: []
|
||
const album = raw?.album && typeof raw.album === 'object' ? raw.album : {}
|
||
|
||
return {
|
||
mid: String(raw?.songmid ?? raw?.mid ?? ''),
|
||
id: typeof raw?.songid === 'number' ? raw.songid : Number(raw?.songid ?? raw?.id) || undefined,
|
||
name: String(raw?.songname ?? raw?.name ?? raw?.title ?? '').trim(),
|
||
singers: singerList.map((singer) => String(singer?.name || '')).filter(Boolean),
|
||
albumName: String(raw?.albumname ?? album.name ?? album.title ?? '').trim(),
|
||
albumMid: String(raw?.albummid ?? album.mid ?? ''),
|
||
interval: Number(raw?.interval) || 0
|
||
}
|
||
}
|
||
|
||
// 通用 musicu 透传;comm 组装对齐 QQ 音乐 web 端,签名失败回退普通接口
|
||
const callQqMusicu = async ({
|
||
module,
|
||
method,
|
||
param,
|
||
cookie,
|
||
extraComm
|
||
}: {
|
||
module: string
|
||
method: string
|
||
param: Record<string, unknown>
|
||
cookie?: string
|
||
extraComm?: Record<string, unknown>
|
||
}) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
const uin = String(cookieObject.uin || '').replace(/^o/i, '')
|
||
const authst = cookieObject.qqmusic_key || cookieObject.qm_keyst || cookieObject.music_key || ''
|
||
|
||
const body = {
|
||
comm: {
|
||
uin,
|
||
loginUin: uin,
|
||
format: 'json',
|
||
ct: 24,
|
||
cv: 4747474,
|
||
platform: 'yqq.json',
|
||
...(authst ? { authst } : {}),
|
||
g_tk: getQqGtk(cookieObject),
|
||
...extraComm
|
||
},
|
||
req_1: { module, method, param }
|
||
}
|
||
|
||
const headers: Record<string, string> = {
|
||
...txHeaders,
|
||
'Content-Type': 'application/json',
|
||
Referer: 'https://y.qq.com/'
|
||
}
|
||
if (normalizedCookie) headers['Cookie'] = normalizedCookie
|
||
|
||
try {
|
||
const sign = await zzcSign(JSON.stringify(body))
|
||
return await $fetch<any>(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
|
||
method: 'POST',
|
||
headers,
|
||
body,
|
||
responseType: 'json',
|
||
signal: AbortSignal.timeout(8000)
|
||
})
|
||
} catch {
|
||
return await $fetch<any>('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||
method: 'POST',
|
||
headers,
|
||
body,
|
||
responseType: 'json',
|
||
signal: AbortSignal.timeout(8000)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 获取"我喜欢"歌单歌曲(dirid 固定 201),按 hasmore 分页拉全
|
||
export const getQqLikedSongs = async ({ cookie }: { cookie?: string }) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
const euin = String(cookieObject.euin || '')
|
||
if (!euin) {
|
||
throw new Error('Cookie 中缺少 euin,请重新登录')
|
||
}
|
||
|
||
const rawList: Record<string, any>[] = []
|
||
let begin = 0
|
||
for (let page = 0; page < 5; page += 1) {
|
||
const resp = await callQqMusicu({
|
||
module: 'music.srfDissInfo.DissInfo',
|
||
method: 'CgiGetDiss',
|
||
param: {
|
||
disstid: 0,
|
||
dirid: 201,
|
||
tag: true,
|
||
song_begin: begin,
|
||
song_num: 300,
|
||
userinfo: true,
|
||
orderlist: true,
|
||
enc_host_uin: euin
|
||
},
|
||
cookie: normalizedCookie
|
||
})
|
||
if (Number(resp?.req_1?.code) !== 0) {
|
||
throw new Error('获取我喜欢歌曲失败')
|
||
}
|
||
const data = resp.req_1.data || {}
|
||
const pageList = Array.isArray(data.songlist) ? data.songlist : []
|
||
rawList.push(...pageList)
|
||
if (!data.hasmore || pageList.length === 0) break
|
||
begin += pageList.length
|
||
}
|
||
|
||
return { songs: rawList.map(normalizeQqLibrarySong), total: rawList.length }
|
||
}
|
||
|
||
// fcg 完整登录形态请求歌单详情;库内 songListDetail 封装为匿名参数形态,收藏歌单会返回空 songlist
|
||
const requestQqPlaylistFcg = async ({
|
||
disstid,
|
||
cookie,
|
||
cookieObject
|
||
}: {
|
||
disstid: string | number
|
||
cookie: string
|
||
cookieObject: Record<string, string>
|
||
}) => {
|
||
const uin = String(cookieObject.uin || '').replace(/^o/i, '')
|
||
const gtk = getQqGtk(cookieObject)
|
||
|
||
return await $fetch<Record<string, any>>(
|
||
'https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg',
|
||
{
|
||
params: {
|
||
type: 1,
|
||
json: 1,
|
||
utf8: 1,
|
||
onlysong: 0,
|
||
new_format: 1,
|
||
disstid,
|
||
loginUin: uin || 0,
|
||
hostUin: 0,
|
||
format: 'json',
|
||
inCharset: 'utf8',
|
||
outCharset: 'utf-8',
|
||
notice: 0,
|
||
platform: 'yqq.json',
|
||
needNewCode: 0,
|
||
g_tk: gtk,
|
||
_: getServerTimestamp()
|
||
},
|
||
headers: {
|
||
Referer: `https://y.qq.com/n/yqq/playsquare/${disstid}.html`,
|
||
Cookie: cookie
|
||
},
|
||
signal: AbortSignal.timeout(8000)
|
||
}
|
||
)
|
||
}
|
||
|
||
export const getQqPlaylistSongs = async ({
|
||
disstid,
|
||
favSongs = false,
|
||
cookie,
|
||
limit = 100,
|
||
offset = 0
|
||
}: {
|
||
disstid: string | number
|
||
favSongs?: boolean
|
||
cookie?: string
|
||
limit?: number
|
||
offset?: number
|
||
}) => {
|
||
const normalizedCookie = normalizeQqCookie(cookie)
|
||
const cookieObject = parseCookieObject(normalizedCookie)
|
||
|
||
// "我喜欢"虚拟歌单
|
||
if (favSongs) {
|
||
const liked = await getQqLikedSongs({ cookie: normalizedCookie })
|
||
return {
|
||
playlist: undefined,
|
||
songs: liked.songs.slice(offset, offset + limit),
|
||
total: liked.total
|
||
}
|
||
}
|
||
|
||
let songlistRaw: Record<string, any>[]
|
||
let playlistSummary: QqLibraryPlaylist | undefined
|
||
|
||
try {
|
||
const body = await requestQqPlaylistFcg({
|
||
disstid,
|
||
cookie: normalizedCookie,
|
||
cookieObject
|
||
})
|
||
if (Number(body?.code) !== 0) {
|
||
throw new Error(`fcg code=${body?.code}`)
|
||
}
|
||
const cdInfo = body?.cdlist?.[0] || {}
|
||
if (!Array.isArray(cdInfo.songlist) || cdInfo.songlist.length === 0) {
|
||
throw new Error('fcg 歌单歌曲为空')
|
||
}
|
||
songlistRaw = cdInfo.songlist
|
||
playlistSummary = normalizeQqPlaylistSummary(cdInfo, 'created')
|
||
} catch {
|
||
const resp = await callQqMusicu({
|
||
module: 'music.srfDissInfo.aiDissInfo',
|
||
method: 'uniform_get_Dissinfo',
|
||
param: {
|
||
disstid: Number(disstid),
|
||
userinfo: 1,
|
||
tag: 1,
|
||
orderlist: 1,
|
||
song_begin: 0,
|
||
song_num: 100000,
|
||
onlysonglist: 0,
|
||
enc_host_uin: ''
|
||
},
|
||
cookie: normalizedCookie
|
||
})
|
||
const reqResult = resp?.req_1 || {}
|
||
if (Number(reqResult.code) !== 0) {
|
||
throw new Error('获取歌单歌曲失败')
|
||
}
|
||
songlistRaw = Array.isArray(reqResult.data?.songlist) ? reqResult.data.songlist : []
|
||
playlistSummary = normalizeQqPlaylistSummary(
|
||
{ ...(reqResult.data?.dirinfo || {}), id: disstid },
|
||
'created'
|
||
)
|
||
}
|
||
|
||
const songs = songlistRaw.map(normalizeQqLibrarySong)
|
||
|
||
return {
|
||
playlist: playlistSummary,
|
||
songs: songs.slice(offset, offset + limit),
|
||
total: songs.length
|
||
}
|
||
}
|