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 } const QQ_SDK_QUALITY_MAP: Record = { '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 = { 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 = { ...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 = {} 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) => { 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 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 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 | 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) => { // 兼容 uin 仅存于 qqmusic_uin 的会话;与资料链路一致,剥离 openid 的 o 前缀 const raw = cookieObject.uin || cookieObject.qqmusic_uin || '' return raw ? raw.replace(/^o/i, '') : '0' } const requestQqOfficialVkey = async ( payload: Record, cookie: string, useSignedRequest: boolean ) => { const headers: Record = useSignedRequest ? { ...txHeaders } : { ...QQ_OFFICIAL_HEADERS } if (cookie) headers.Cookie = cookie if (useSignedRequest) { return await $fetch(`${QQ_MUSICS_URL}?sign=${await zzcSign(JSON.stringify(payload))}`, { method: 'POST', headers, body: payload, responseType: 'json', signal: AbortSignal.timeout(6000) }) } return await $fetch(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 | 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) => item?.filename === filename)) .find((item) => item && (item.purl || item.vkey)) info = info || midurlinfo.find((item: Record) => item?.purl || item?.vkey) // 未命中可播放条目时回退首个请求档位,保留拒绝码用于诊断 info = info || midurlinfo.find((item: Record) => item?.filename === filenames[0]) const url = buildQqOfficialPlayUrl(domain, info, guid) return { url, info } } // 登录态注入 comm 的 qq/authst/tmeLoginType,已登录账号才能解析 VIP/高音质直链 const resolveQqLoginType = (cookieObject: Record, 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 => { 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( `${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 | 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 = { '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(item?.result)) const semanticHint = [...new Set(resultCodes)] .map(describeVkeyResult) .filter(Boolean) .join(';') const upstreamHint = midurlinfo.length ? `;上游: ${midurlinfo .map((item: Record) => [ `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 | 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 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 const identity = ( record.identity && typeof record.identity === 'object' ? record.identity : {} ) as Record 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)< ((a>>(31-b))&1)< (((a<>>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<>>(28-sh[i])))&0xfffffff0)>>>0 d=(((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) => ({ 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) => { const body = buildBody(param) const headers: Record = { ...txHeaders } if (normalizedCookie) headers['Cookie'] = normalizedCookie // 优先签名请求,稳定性更好;失败回退普通请求 try { const sign = await zzcSign(JSON.stringify(body)) return await $fetch(`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('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, 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) => { 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>( '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, 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[] = Array.isArray(createdData.v_playlist) ? createdData.v_playlist : [] let collectedRawList: Record[] = [] 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() 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) => { 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 cookie?: string extraComm?: Record }) => { 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 = { ...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(`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('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[] = [] 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 }) => { const uin = String(cookieObject.uin || '').replace(/^o/i, '') const gtk = getQqGtk(cookieObject) return await $fetch>( '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[] 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 } }