| import QRCode from 'qrcode' |
| import type { Playlist } from '@music-together/shared' |
| import type { GetUserInfoResult } from './authProvider.js' |
| import { logger } from '../utils/logger.js' |
| import { getCookieValue, parseCookieString } from '../utils/cookieUtils.js' |
| import { parseTencentRadarRecommendationPage, parseTencentRecommendedPlaylistPage } from './recommendationParsers.js' |
| import * as crypto from 'node:crypto' |
|
|
| |
| |
| |
|
|
| interface TencentApiResponse { |
| code?: number |
| req?: { |
| code: number |
| data?: Record<string, unknown> |
| } |
| } |
|
|
| interface VipLoginData { |
| vip?: number |
| svip?: number |
| identity?: { |
| vip?: number |
| svip?: number |
| HugeVip?: number |
| LMFlag?: number |
| level?: number |
| } |
| } |
|
|
| export function formatTencentVipLabel(vipType: number, vipLevel?: number): string | undefined { |
| if (vipType <= 0) return undefined |
| const tier = vipType >= 2 ? '超级会员' : '绿钻VIP' |
| return vipLevel ? `${tier}·Lv${vipLevel}` : tier |
| } |
|
|
| export function parseTencentMembership(vipData: VipLoginData | undefined): { |
| vipType: 0 | 1 | 2 |
| vipLabel?: string |
| vipLevel?: number |
| } { |
| const identity = vipData?.identity |
| const isEnabled = (...values: unknown[]) => values.some((value) => value === true || Number(value) > 0) |
| const isSuperVip = isEnabled(identity?.svip, identity?.HugeVip) |
| const isGreenDiamondVip = isEnabled(identity?.vip, identity?.LMFlag) |
| const vipType = isSuperVip ? 2 : isGreenDiamondVip ? 1 : 0 |
| const rawVipLevel = Number(identity?.level ?? 0) |
| const vipLevel = vipType > 0 && Number.isInteger(rawVipLevel) && rawVipLevel > 0 ? rawVipLevel : undefined |
| return { vipType, vipLabel: formatTencentVipLabel(vipType, vipLevel), vipLevel } |
| } |
|
|
| interface ProfileData { |
| creator?: { |
| nick?: string |
| name?: string |
| encrypt_uin?: string |
| } |
| } |
|
|
| interface MusicKeyData { |
| code?: number |
| openid?: string |
| refresh_token?: string |
| refreshToken?: string |
| access_token?: string |
| accessToken?: string |
| expired_at?: number |
| expired_in?: number |
| musicid?: number | string |
| musickey?: string |
| unionid?: string |
| str_musicid?: string |
| refresh_key?: string |
| refreshKey?: string |
| musickeyCreateTime?: number |
| keyExpiresIn?: number |
| firstLogin?: number |
| bindAccountType?: number |
| needRefreshKeyIn?: number |
| encryptUin?: string |
| loginType?: number |
| } |
|
|
| export interface TencentCredential { |
| musicid: number |
| musickey: string |
| openid: string |
| refreshToken: string |
| accessToken: string |
| expiredAt: number |
| unionid: string |
| strMusicid: string |
| refreshKey: string |
| musickeyCreateTime: number |
| keyExpiresIn: number |
| loginType: number |
| refreshVersion: number |
| } |
|
|
| function nonEmptyString(value: unknown): string { |
| return typeof value === 'string' ? value.trim() : '' |
| } |
|
|
| function numberValue(value: unknown): number { |
| const parsed = Number(value) |
| return Number.isFinite(parsed) ? parsed : 0 |
| } |
|
|
| |
| export function parseTencentCredential(cookie: string): TencentCredential | null { |
| const values = parseCookieString(cookie) |
| const rawMusicid = values.musicid || values.uin || values.qqmusic_uin || values.o_cookie |
| const musicid = Number.parseInt(nonEmptyString(rawMusicid).replace(/^o0*/, ''), 10) |
| const musickey = values.musickey || values.qm_keyst || values.qqmusic_key || '' |
| if (!Number.isFinite(musicid) || musicid <= 0 || !musickey) return null |
|
|
| const loginType = numberValue(values.loginType || values.login_type) || (musickey.startsWith('W_X') ? 1 : 2) |
| return { |
| musicid, |
| musickey, |
| openid: values.openid || '', |
| refreshToken: values.refresh_token || values.o_refresh_token || '', |
| accessToken: values.access_token || '', |
| expiredAt: numberValue(values.expired_at || values.expired_in), |
| unionid: values.unionid || '', |
| strMusicid: values.str_musicid || String(musicid), |
| refreshKey: values.refresh_key || values.o_refresh_key || '', |
| musickeyCreateTime: numberValue(values.musickeyCreateTime || values.musickey_create_time), |
| keyExpiresIn: numberValue(values.keyExpiresIn || values.key_expires_in), |
| loginType, |
| refreshVersion: numberValue(values.qqmusic_refresh_v), |
| } |
| } |
|
|
| export function isRefreshableCredential(cookie: string): boolean { |
| const credential = parseTencentCredential(cookie) |
| return Boolean(credential && credential.refreshVersion >= 1 && (credential.refreshToken || credential.refreshKey)) |
| } |
|
|
| function credentialField(data: MusicKeyData, ...names: string[]): unknown { |
| for (const name of names) { |
| const value = data[name as keyof MusicKeyData] |
| if (typeof value === 'string' && value.trim()) return value.trim() |
| if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value |
| } |
| return undefined |
| } |
|
|
| |
| export function buildTencentCredentialCookie(previousCookie: string, data: MusicKeyData): string { |
| const previous = parseCookieString(previousCookie) |
| const parsedPrevious = parseTencentCredential(previousCookie) |
| const musicid = numberValue(credentialField(data, 'musicid')) || parsedPrevious?.musicid || 0 |
| const musickey = nonEmptyString(credentialField(data, 'musickey')) || parsedPrevious?.musickey || '' |
| if (musicid <= 0 || !musickey) throw new Error('QQ 音乐凭证响应缺少 musicid 或 musickey') |
|
|
| const set = (key: string, value: unknown, fallback = '') => { |
| const resolved = typeof value === 'number' ? (value > 0 ? String(value) : '') : nonEmptyString(value) |
| if (resolved) previous[key] = resolved |
| else if (fallback) previous[key] = fallback |
| } |
|
|
| set('uin', musicid) |
| set('qqmusic_uin', musicid) |
| set('musicid', musicid) |
| set('o_cookie', musicid) |
| set('qm_keyst', musickey) |
| set('qqmusic_key', musickey) |
| set('musickey', musickey) |
| set('openid', credentialField(data, 'openid'), parsedPrevious?.openid) |
| set('refresh_token', credentialField(data, 'refresh_token', 'refreshToken'), parsedPrevious?.refreshToken) |
| set('o_refresh_token', credentialField(data, 'refresh_token', 'refreshToken'), parsedPrevious?.refreshToken) |
| set('access_token', credentialField(data, 'access_token', 'accessToken'), parsedPrevious?.accessToken) |
| set( |
| 'expired_at', |
| credentialField(data, 'expired_at', 'expired_in'), |
| parsedPrevious?.expiredAt ? String(parsedPrevious.expiredAt) : '', |
| ) |
| set('unionid', credentialField(data, 'unionid'), parsedPrevious?.unionid) |
| set('str_musicid', credentialField(data, 'str_musicid'), parsedPrevious?.strMusicid) |
| set('refresh_key', credentialField(data, 'refresh_key', 'refreshKey'), parsedPrevious?.refreshKey) |
| set( |
| 'musickeyCreateTime', |
| credentialField(data, 'musickeyCreateTime'), |
| parsedPrevious?.musickeyCreateTime ? String(parsedPrevious.musickeyCreateTime) : '', |
| ) |
| set( |
| 'keyExpiresIn', |
| credentialField(data, 'keyExpiresIn'), |
| parsedPrevious?.keyExpiresIn ? String(parsedPrevious.keyExpiresIn) : '', |
| ) |
| set( |
| 'loginType', |
| credentialField(data, 'loginType'), |
| parsedPrevious?.loginType ? String(parsedPrevious.loginType) : '2', |
| ) |
| set('qqmusic_refresh_v', 1) |
|
|
| return Object.entries(previous) |
| .filter(([, value]) => value.length > 0) |
| .map(([key, value]) => `${key}=${value}`) |
| .join('; ') |
| } |
|
|
| |
| export async function refreshCredential(cookie: string): Promise<string> { |
| const credential = parseTencentCredential(cookie) |
| if (!credential) throw new Error('QQ 音乐凭证缺少 musicid 或 musickey') |
| if (!isRefreshableCredential(cookie)) { |
| throw new Error('QQ 音乐凭证缺少 refresh_token / refresh_key,请重新扫码登录') |
| } |
|
|
| const common = { |
| openid: credential.openid, |
| access_token: credential.accessToken, |
| refresh_token: credential.refreshToken, |
| expired_in: credential.expiredAt, |
| musicid: credential.musicid, |
| musickey: credential.musickey, |
| refresh_key: credential.refreshKey, |
| loginMode: 2, |
| } |
| const param = |
| credential.loginType === 1 |
| ? { |
| openid: credential.openid, |
| refresh_token: credential.refreshToken, |
| str_musicid: credential.strMusicid || String(credential.musicid), |
| musickey: credential.musickey, |
| unionid: credential.unionid, |
| refresh_key: credential.refreshKey, |
| loginMode: 2, |
| } |
| : common |
|
|
| const data = await tencentApiRequest<MusicKeyData>({ |
| module: 'music.login.LoginServer', |
| method: 'Login', |
| param, |
| cookie, |
| sign: false, |
| commExtras: { |
| uin: credential.musicid, |
| g_tk: hash33(credential.musickey, 5381), |
| g_tk_new_20200303: hash33(credential.musickey, 5381), |
| platform: 'yqq.json', |
| need_new_code: 1, |
| tmeLoginType: credential.loginType, |
| }, |
| }) |
| const responseCode = numberValue(data.code) |
| if (responseCode !== 0) throw new Error(`[CredentialRefreshError] QQ 音乐凭证刷新失败 (${responseCode})`) |
| return buildTencentCredentialCookie(cookie, data) |
| } |
|
|
| interface PlaylistItem { |
| tid?: string | number |
| dissid?: string | number |
| dirid?: string | number |
| dirId?: string | number |
| title?: string |
| dissname?: string |
| dirName?: string |
| coverurl?: string |
| picurl?: string |
| picUrl?: string |
| songnum?: number |
| dissts?: number |
| songNum?: number |
| } |
|
|
| interface DissInfoData { |
| songlist?: Record<string, unknown>[] |
| total_song_num?: number |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| const APPID = '716027609' |
| |
| const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19].filter((x) => x < 40) |
| 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 hash33(str: string, initialHash = 0): number { |
| let hash = initialHash |
| for (let i = 0; i < str.length; i++) { |
| hash += (hash << 5) + str.charCodeAt(i) |
| } |
| return hash & 0x7fffffff |
| } |
|
|
| |
| |
| |
| |
| function createTencentSign(requestData: unknown): string { |
| const jsonStr = JSON.stringify(requestData) |
| const hashHex = crypto.createHash('sha1').update(jsonStr).digest('hex').toUpperCase() |
|
|
| const part1 = PART_1_INDEXES.map((i) => hashHex[i]).join('') |
| const part2 = PART_2_INDEXES.map((i) => hashHex[i]).join('') |
|
|
| const part3 = Buffer.alloc(20) |
| for (let i = 0; i < SCRAMBLE_VALUES.length; i++) { |
| const v = SCRAMBLE_VALUES[i] |
| const hexByteStr = hashHex.substring(i * 2, i * 2 + 2) |
| const hashByte = parseInt(hexByteStr, 16) |
| part3[i] = v ^ hashByte |
| } |
|
|
| let b64Part = part3.toString('base64') |
| b64Part = b64Part.replace(/[\\/+=]/g, '') |
|
|
| return `zzc${part1}${b64Part}${part2}`.toLowerCase() |
| } |
|
|
| |
| |
| |
|
|
| interface TencentApiConfig { |
| module: string |
| method: string |
| param?: Record<string, unknown> |
| cookie?: string |
| commExtras?: Record<string, unknown> |
| sign?: boolean |
| } |
|
|
| |
| async function tencentApiRequest<T = Record<string, unknown>>(reqConfig: TencentApiConfig): Promise<T> { |
| const data = { |
| comm: { |
| cv: 4747474, |
| ct: 24, |
| format: 'json', |
| inCharset: 'utf-8', |
| outCharset: 'utf-8', |
| notice: 0, |
| ...reqConfig.commExtras, |
| }, |
| req: { |
| module: reqConfig.module, |
| method: reqConfig.method, |
| param: reqConfig.param || {}, |
| }, |
| } |
|
|
| const shouldSign = reqConfig.sign !== false |
| const url = shouldSign |
| ? `https://u.y.qq.com/cgi-bin/musics.fcg?sign=${createTencentSign(data)}` |
| : 'https://u.y.qq.com/cgi-bin/musicu.fcg' |
|
|
| const response = await fetch(url, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'User-Agent': |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
| Cookie: reqConfig.cookie || '', |
| Referer: 'https://y.qq.com/', |
| }, |
| body: JSON.stringify(data), |
| signal: AbortSignal.timeout(10_000), |
| }) |
|
|
| |
| if (!response.ok) { |
| throw new Error(`Tencent API ${reqConfig.module}.${reqConfig.method} HTTP ${response.status}`) |
| } |
|
|
| const body = (await response.json()) as TencentApiResponse |
| const reqRes = body?.req |
|
|
| if (body.code !== 0 && body.code !== undefined) { |
| throw new Error(`[ResponseCodeError] API ${reqConfig.method} returned global code ${body.code}`) |
| } |
|
|
| if (reqRes?.code !== 0 && reqRes?.code !== undefined) { |
| if (reqRes.code === 2000) throw new Error(`[SignInvalidError] API ${reqConfig.method} 签名无效 (2000)`) |
| if (reqRes.code === 1000) throw new Error(`[CredentialExpiredError] API ${reqConfig.method} 会话过期 (1000)`) |
| throw new Error(`[ResponseCodeError] API ${reqConfig.method} 报错,code:${reqRes?.code}`) |
| } |
|
|
| return (reqRes?.data || body) as T |
| } |
|
|
| function getTencentRecommendationComm(cookie: string): Record<string, unknown> { |
| const rawUin = |
| getCookieValue(cookie, 'uin') || getCookieValue(cookie, 'qqmusic_uin') || getCookieValue(cookie, 'o_cookie') || '' |
| const uin = Number.parseInt(rawUin.replace(/^o/i, ''), 10) || 0 |
| const musicKey = getCookieValue(cookie, 'qm_keyst') || getCookieValue(cookie, 'qqmusic_key') || '' |
| const gtk = musicKey ? hash33(musicKey, 5381) : 5381 |
|
|
| return { |
| uin, |
| g_tk: gtk, |
| g_tk_new_20200303: gtk, |
| platform: 'yqq.json', |
| need_new_code: 1, |
| } |
| } |
|
|
| export interface TencentRecommendedPlaylistResult { |
| playlists: Playlist[] |
| hasMore: boolean |
| nextOffset: number |
| } |
|
|
| export interface TencentRadarRecommendationResult { |
| songs: Record<string, unknown>[] |
| hasMore: boolean |
| nextPage: number |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function getRecommendedPlaylistPage( |
| cookie: string, |
| limit = 25, |
| offset = 0, |
| ): Promise<TencentRecommendedPlaylistResult> { |
| const size = Math.max(1, Math.min(50, Math.floor(limit))) |
| const from = Math.max(0, Math.floor(offset)) |
|
|
| const data = await tencentApiRequest({ |
| module: 'music.playlist.PlaylistSquare', |
| method: 'GetRecommendFeed', |
| cookie, |
| sign: false, |
| commExtras: getTencentRecommendationComm(cookie), |
| param: { From: from, Size: size }, |
| }) |
|
|
| const page = parseTencentRecommendedPlaylistPage(data) |
| const fallbackOffset = from + size |
| return { |
| playlists: page.playlists.slice(0, size), |
| hasMore: page.hasMore, |
| nextOffset: page.hasMore && page.fromLimit > from ? page.fromLimit : page.hasMore ? fallbackOffset : 0, |
| } |
| } |
|
|
| export async function getRecommendedPlaylists(cookie: string, limit = 25, page = 1): Promise<Playlist[]> { |
| const size = Math.max(1, Math.min(50, Math.floor(limit))) |
| const currentPage = Math.max(1, Math.floor(page)) |
| const result = await getRecommendedPlaylistPage(cookie, size, size * (currentPage - 1)) |
| return result.playlists |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function getRadarRecommendations(cookie: string, page = 1): Promise<TencentRadarRecommendationResult> { |
| const currentPage = Math.max(1, Math.floor(page)) |
| const data = await tencentApiRequest({ |
| module: 'music.recommend.TrackRelationServer', |
| method: 'GetRadarSong', |
| cookie, |
| sign: false, |
| commExtras: getTencentRecommendationComm(cookie), |
| param: { |
| Page: currentPage, |
| ReqType: 0, |
| FavSongs: [], |
| EntranceSongs: [], |
| }, |
| }) |
|
|
| const result = parseTencentRadarRecommendationPage(data) |
| return { |
| songs: result.songs, |
| hasMore: result.hasMore, |
| nextPage: result.hasMore ? currentPage + 1 : 1, |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| const qrSessionMap = new Map<string, string>() |
| |
| const qrProcessingSet = new Set<string>() |
|
|
| |
| |
| |
| |
| export async function generateQrCode(): Promise<{ key: string; qrimg: string } | null> { |
| try { |
| const params = new URLSearchParams({ |
| appid: APPID, |
| e: '2', |
| l: 'M', |
| s: '3', |
| d: '72', |
| v: '4', |
| t: '0.1', |
| daid: '383', |
| pt_3rd_aid: '100497308', |
| u1: 'https://graph.qq.com/oauth2.0/login_jump', |
| }) |
|
|
| const url = `https://ssl.ptlogin2.qq.com/ptqrshow?${params.toString()}` |
|
|
| const response = await fetch(url, { |
| headers: { |
| 'User-Agent': |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
| Referer: 'https://xui.ptlogin2.qq.com/', |
| }, |
| signal: AbortSignal.timeout(10_000), |
| }) |
|
|
| if (!response.ok) { |
| logger.error(`QQ QR ptqrshow failed with status ${response.status}`) |
| return null |
| } |
|
|
| |
| const setCookies = response.headers.getSetCookie?.() ?? [] |
| const cookieParts: string[] = [] |
| let qrsig = '' |
|
|
| for (const c of setCookies) { |
| const kv = c.split(';')[0] |
| if (kv) { |
| cookieParts.push(kv) |
| const sigMatch = kv.match(/qrsig=(.+)/) |
| if (sigMatch) { |
| qrsig = sigMatch[1] |
| } |
| } |
| } |
|
|
| if (!qrsig) { |
| |
| const rawCookie = response.headers.get('set-cookie') ?? '' |
| const sigMatch = rawCookie.match(/qrsig=([^;]+)/) |
| if (sigMatch) { |
| qrsig = sigMatch[1] |
| cookieParts.push(`qrsig=${qrsig}`) |
| } |
| } |
|
|
| if (!qrsig) { |
| logger.error('QQ QR: qrsig not found in response headers', { |
| setCookieCount: setCookies.length, |
| rawSetCookie: response.headers.get('set-cookie')?.slice(0, 200), |
| }) |
| return null |
| } |
|
|
| |
| const fullCookie = cookieParts.join('; ') |
| qrSessionMap.set(qrsig, fullCookie) |
| logger.debug('QQ 音乐扫码登录会话已缓存', { cookieParts: cookieParts.length }) |
|
|
| |
| const imageBuffer = Buffer.from(await response.arrayBuffer()) |
| const qrimg = `data:image/png;base64,${imageBuffer.toString('base64')}` |
|
|
| logger.info('QQ 音乐登录二维码已生成') |
| return { key: qrsig, qrimg } |
| } catch (err) { |
| logger.error('QQ QR generation failed', err) |
| return null |
| } |
| } |
|
|
| |
| |
| |
|
|
| const STATUS_MESSAGES: Record<string, { status: number; message: string }> = { |
| '66': { status: 801, message: '等待扫码' }, |
| '67': { status: 802, message: '已扫码,请在手机上确认' }, |
| '0': { status: 803, message: '登录成功' }, |
| '65': { status: 800, message: '二维码已过期,请重新获取' }, |
| } |
|
|
| |
| |
| |
| |
| export async function checkQrStatus(qrsig: string): Promise<{ |
| status: number |
| message: string |
| cookie?: string |
| }> { |
| try { |
| |
| if (qrProcessingSet.has(qrsig)) { |
| return { status: 803, message: '登录成功,正在获取用户信息...' } |
| } |
|
|
| const ptqrtoken = hash33(qrsig) |
|
|
| |
| const sessionCookie = qrSessionMap.get(qrsig) ?? `qrsig=${qrsig}` |
|
|
| const action = `0-0-${Date.now()}` |
|
|
| const params = new URLSearchParams({ |
| u1: 'https://graph.qq.com/oauth2.0/login_jump', |
| ptqrtoken: String(ptqrtoken), |
| ptredirect: '1', |
| h: '1', |
| t: '1', |
| g: '1', |
| from_ui: '1', |
| ptlang: '2052', |
| action, |
| js_ver: '24112817', |
| js_type: '1', |
| pt_uistyle: '40', |
| aid: APPID, |
| daid: '383', |
| pt_3rd_aid: '100497308', |
| has_resolve: '1', |
| }) |
|
|
| const url = `https://ssl.ptlogin2.qq.com/ptqrlogin?${params.toString()}` |
|
|
| const response = await fetch(url, { |
| headers: { |
| 'User-Agent': |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', |
| Referer: 'https://xui.ptlogin2.qq.com/', |
| Cookie: sessionCookie, |
| }, |
| redirect: 'manual', |
| signal: AbortSignal.timeout(10_000), |
| }) |
|
|
| const text = await response.text() |
|
|
| |
| const setCookies = response.headers.getSetCookie?.() ?? [] |
| const cookieMap = new Map<string, string>() |
| sessionCookie.split(';').forEach((c) => { |
| const parts = c.split('=') |
| if (parts.length >= 2) cookieMap.set(parts[0].trim(), parts.slice(1).join('=').trim()) |
| }) |
| setCookies.forEach((c) => { |
| const kv = c.split(';')[0] |
| if (kv) { |
| const parts = kv.split('=') |
| if (parts.length >= 2) cookieMap.set(parts[0].trim(), parts.slice(1).join('=').trim()) |
| } |
| }) |
| const mergedCookie = Array.from(cookieMap.entries()) |
| .map(([k, v]) => `${k}=${v}`) |
| .join('; ') |
|
|
| |
| if (text.startsWith("ptuiCB('0'")) { |
| logger.debug('QQ 音乐扫码状态原始响应', { response: text.slice(0, 500) }) |
| } |
|
|
| |
| const match = text.match(/ptuiCB\('(\d+)','([^']*)','([^']*)','([^']*)','([^']*)'(?:,'([^']*)')?/) |
| if (!match) { |
| logger.warn('QQ QR: unexpected ptqrlogin response format', { text: text.slice(0, 300) }) |
| return { status: 800, message: '检查状态失败(响应格式异常)' } |
| } |
|
|
| const [, code, , checkSigUrl, , msg, nickname] = match |
| const mapped = STATUS_MESSAGES[code] ?? { status: 800, message: `未知状态 (${code})` } |
|
|
| logger.debug('QQ 音乐扫码状态轮询完成', { |
| code, |
| hasCheckSigUrl: Boolean(checkSigUrl), |
| nickname: nickname || undefined, |
| }) |
|
|
| |
| if (code === '0') { |
| qrProcessingSet.add(qrsig) |
|
|
| try { |
| const uinStr = checkSigUrl?.match(/uin=([^&]+)/)?.[1] |
| const ptsigx = checkSigUrl?.match(/ptsigx=([^&]+)/)?.[1] |
|
|
| if (!uinStr || !ptsigx) { |
| logger.warn('QQ QR: Missing uin or ptsigx in check_sig URL') |
| return { status: 803, message: mapped.message } |
| } |
|
|
| |
| logger.debug('QQ 音乐扫码登录:正在获取 p_skey') |
| const fullCookie = await doCheckSig(uinStr, ptsigx, mergedCookie) |
|
|
| if (!fullCookie) { |
| logger.warn('QQ QR: Failed to pass check_sig') |
| return { status: 803, message: mapped.message, cookie: mergedCookie } |
| } |
|
|
| |
| const pSkeyMatch = fullCookie.match(/p_skey=([^;]+)/) |
| const pSkey = pSkeyMatch ? pSkeyMatch[1] : '' |
| if (!pSkey) { |
| logger.warn('QQ QR: check_sig succeeded but missing p_skey in parsed fullCookie') |
| } |
|
|
| |
| logger.debug('QQ 音乐扫码登录:正在获取 OAuth 授权码') |
| const authCode = await fetchOAuthCode(pSkey, fullCookie) |
|
|
| if (!authCode) { |
| logger.warn('QQ QR: Failed to get OAuth code') |
| |
| const uinInt = Number(uinStr.replace(/^o0*/, '')) |
| return { |
| status: 803, |
| message: mapped.message, |
| cookie: `uin=${uinInt}; p_skey=${pSkey}; skey=${pSkey}; qqmusic_key=${pSkey}; qm_keyst=${pSkey}`, |
| } |
| } |
|
|
| |
| logger.debug('QQ 音乐扫码登录:正在换取 musickey') |
| const finalCookie = await fetchMusicKeySession(authCode) |
|
|
| if (finalCookie) { |
| logger.info(`QQ 音乐扫码登录成功:${nickname || uinStr}`, { |
| event: 'auth.qq_qr_login_succeeded', |
| nickname: nickname || undefined, |
| }) |
| return { status: 803, message: mapped.message, cookie: finalCookie } |
| } |
|
|
| |
| const fallbackUin = Number(uinStr.replace(/^o0*/, '')) |
| return { |
| status: 803, |
| message: mapped.message, |
| cookie: `uin=${fallbackUin}; p_skey=${pSkey}; skey=${pSkey}; qqmusic_key=${pSkey}; qm_keyst=${pSkey}`, |
| } |
| } finally { |
| qrProcessingSet.delete(qrsig) |
| qrSessionMap.delete(qrsig) |
| } |
| } |
|
|
| |
| if (code === '65') { |
| qrSessionMap.delete(qrsig) |
| } |
|
|
| return mapped |
| } catch (err) { |
| logger.error('QQ QR check failed', err) |
| return { status: 800, message: '检查状态失败' } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
|
|
| export async function getUserInfo(cookie: string): Promise<GetUserInfoResult> { |
| try { |
| const uin = getCookieValue(cookie, 'uin') |
| if (!uin) { |
| return { ok: false, reason: 'expired' } |
| } |
|
|
| |
| |
| |
| const url = `https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg?ct=20&cv=4747474&cid=205360838&userid=${uin}` |
| const response = await fetch(url, { |
| headers: { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', |
| Cookie: cookie, |
| Referer: 'https://y.qq.com/', |
| }, |
| signal: AbortSignal.timeout(10_000), |
| }) |
|
|
| const body = (await response.json()) as { data?: ProfileData } |
| const creator = body?.data?.creator || {} |
|
|
| |
| let rawNick = creator.nick || creator.name || `QQ用户${uin}` |
|
|
| |
| if (/^[a-zA-Z0-9+/]+={0,2}$/.test(rawNick) && rawNick.length % 4 === 0) { |
| try { |
| rawNick = Buffer.from(rawNick, 'base64').toString('utf8') |
| } catch { |
| |
| } |
| } |
| const nickname = rawNick |
|
|
| |
| let membership = parseTencentMembership(undefined) |
| try { |
| const vipData = await tencentApiRequest<VipLoginData>({ |
| module: 'VipLogin.VipLoginInter', |
| method: 'vip_login_base', |
| cookie, |
| param: {}, |
| }) |
| logger.debug('QQ 音乐 VIP 信息响应', { |
| vip: vipData.vip, |
| svip: vipData.svip, |
| identityVip: vipData.identity?.vip, |
| identitySvip: vipData.identity?.svip, |
| hugeVip: vipData.identity?.HugeVip, |
| lmFlag: vipData.identity?.LMFlag, |
| level: vipData.identity?.level, |
| }) |
| membership = parseTencentMembership(vipData) |
| } catch (err: unknown) { |
| logger.error('QQ VIP Check failed:', err instanceof Error ? err.message : String(err)) |
| } |
|
|
| return { |
| ok: true, |
| data: { |
| nickname, |
| ...membership, |
| userId: Number(uin), |
| }, |
| } |
| } catch (err) { |
| logger.error('QQ getUserInfo failed', err) |
| return { ok: false, reason: 'error' } |
| } |
| } |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| async function doCheckSig(uinStr: string, sigx: string, baseCookie: string): Promise<string | null> { |
| const url = |
| `https://ssl.ptlogin2.graph.qq.com/check_sig?` + |
| new URLSearchParams({ |
| uin: uinStr, |
| pttype: '1', |
| service: 'ptqrlogin', |
| nodirect: '0', |
| ptsigx: sigx, |
| s_url: 'https://graph.qq.com/oauth2.0/login_jump', |
| ptlang: '2052', |
| ptredirect: '100', |
| aid: APPID, |
| daid: '383', |
| j_later: '0', |
| low_login_hour: '0', |
| regmaster: '0', |
| pt_login_type: '3', |
| pt_aid: '0', |
| pt_aaid: '16', |
| pt_light: '0', |
| pt_3rd_aid: '100497308', |
| }).toString() |
|
|
| const res = await fetch(url, { |
| headers: { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', |
| Referer: 'https://xui.ptlogin2.qq.com/', |
| Cookie: baseCookie, |
| }, |
| redirect: 'manual', |
| }) |
|
|
| const setCookies = res.headers.getSetCookie?.() ?? [] |
| if (!setCookies.length) return baseCookie |
|
|
| |
| const cookieMap = new Map<string, string>() |
| baseCookie.split(';').forEach((c) => { |
| const parts = c.split('=') |
| if (parts.length >= 2) cookieMap.set(parts[0].trim(), parts.slice(1).join('=').trim()) |
| }) |
|
|
| setCookies.forEach((c) => { |
| const kv = c.split(';')[0] |
| const parts = kv.split('=') |
| if (parts.length >= 2) { |
| const key = parts[0].trim() |
| const val = parts.slice(1).join('=').trim() |
| if (val) { |
| cookieMap.set(key, val) |
| } |
| } |
| }) |
|
|
| return Array.from(cookieMap.entries()) |
| .map(([k, v]) => `${k}=${v}`) |
| .join('; ') |
| } |
|
|
| |
| |
| |
| async function fetchOAuthCode(pSkey: string, fullCookie: string): Promise<string | null> { |
| const gtk = hash33(pSkey, 5381) |
| const bodyParams = new URLSearchParams({ |
| response_type: 'code', |
| client_id: '100497308', |
| redirect_uri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/', |
| scope: 'get_user_info,get_app_friends', |
| state: 'state', |
| switch: '', |
| from_ptlogin: '1', |
| src: '1', |
| update_auth: '1', |
| openapi: '1010_1030', |
| g_tk: String(gtk), |
| auth_time: String(Date.now()), |
| ui: crypto.randomUUID(), |
| }) |
|
|
| |
| const res = await fetch('https://graph.qq.com/oauth2.0/authorize', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', |
| Cookie: fullCookie, |
| }, |
| body: bodyParams.toString(), |
| redirect: 'manual', |
| }) |
|
|
| const location = res.headers.get('location') |
| if (!location) return null |
|
|
| const codeMatch = location.match(/code=([^&]+)/) |
| return codeMatch ? codeMatch[1] : null |
| } |
|
|
| |
| |
| |
| async function fetchMusicKeySession(code: string): Promise<string | null> { |
| try { |
| const data = await tencentApiRequest({ |
| module: 'QQConnectLogin.LoginServer', |
| method: 'QQLogin', |
| commExtras: { tmeLoginType: '2' }, |
| param: { code }, |
| }) |
|
|
| if (data?.musickey && data?.musicid) { |
| logger.info('QQ 音乐登录刷新字段已获取', { |
| event: 'auth.qq_refresh_fields_received', |
| hasRefreshToken: Boolean(data.refresh_token || data.refreshToken), |
| hasRefreshKey: Boolean(data.refresh_key || data.refreshKey), |
| loginType: data.loginType, |
| }) |
| |
| |
| return buildTencentCredentialCookie('', data) |
| } |
| return null |
| } catch (err) { |
| logger.error('QQ QR: MusicKey Session Exchange Failed', err) |
| return null |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| async function getEncryptUin(musicid: string | number): Promise<string> { |
| const url = `https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg?ct=20&cv=4747474&cid=205360838&userid=${musicid}` |
| const response = await fetch(url, { |
| headers: { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', |
| Referer: 'https://y.qq.com/', |
| }, |
| }) |
| const data = (await response.json()) as { data?: ProfileData } |
| return data?.data?.creator?.encrypt_uin || '' |
| } |
|
|
| |
| |
| |
| export async function getUserPlaylists(cookie: string): Promise<Playlist[]> { |
| try { |
| const uin = getCookieValue(cookie, 'uin') |
|
|
| if (!uin) { |
| logger.warn('QQ getUserPlaylists: missing uin in cookie') |
| return [] |
| } |
|
|
| const playlists: Playlist[] = [] |
|
|
| |
| try { |
| const createdData = await tencentApiRequest<{ v_playlist?: PlaylistItem[] }>({ |
| module: 'music.musicasset.PlaylistBaseRead', |
| method: 'GetPlaylistByUin', |
| cookie, |
| param: { uin }, |
| }) |
|
|
| const vPlaylist = createdData?.v_playlist || [] |
|
|
| logger.debug('QQ 音乐创建歌单响应样例', { playlist: vPlaylist[0] }) |
|
|
| for (const p of vPlaylist) { |
| playlists.push({ |
| id: String(p.tid || p.dissid || p.dirid || p.dirId), |
| name: p.title || p.dissname || p.dirName || '未命名歌单', |
| cover: p.coverurl || p.picurl || p.picUrl || '', |
| trackCount: p.songnum || p.dissts || p.songNum || 0, |
| source: 'tencent', |
| }) |
| } |
| } catch (err) { |
| logger.error('QQ getUserPlaylists (Created) failed', err) |
| } |
|
|
| |
| try { |
| const euin = await getEncryptUin(uin) |
| if (euin) { |
| const favData = await tencentApiRequest<{ v_playlist?: PlaylistItem[] }>({ |
| module: 'music.musicasset.PlaylistFavRead', |
| method: 'CgiGetPlaylistFavInfo', |
| cookie, |
| param: { |
| uin: euin, |
| offset: 0, |
| size: 100, |
| }, |
| }) |
|
|
| const vFavList = favData?.v_playlist || [] |
|
|
| logger.debug('QQ 音乐收藏歌单响应', { favData }) |
|
|
| for (const p of vFavList) { |
| playlists.push({ |
| id: String(p.tid || p.dissid || p.dirid || p.dirId), |
| name: p.title || p.dissname || p.dirName || '收藏歌单', |
| cover: p.coverurl || p.picurl || p.picUrl || '', |
| trackCount: p.songnum || p.dissts || p.songNum || 0, |
| source: 'tencent', |
| }) |
| } |
| } |
| } catch (err) { |
| logger.error('QQ getUserPlaylists (Favored) failed', err) |
| } |
|
|
| logger.info(`已获取 QQ 音乐用户的 ${playlists.length} 个歌单`, { |
| platform: 'tencent', |
| userId: uin, |
| count: playlists.length, |
| }) |
| return playlists |
| } catch (err) { |
| logger.error('QQ getUserPlaylists failed catastrophically', err) |
| return [] |
| } |
| } |
|
|
| |
| |
| |
| export async function getPlaylistTracks( |
| playlistId: string, |
| page: number, |
| limit: number, |
| cookie?: string | null, |
| ): Promise<{ songs: Record<string, unknown>[]; total: number }> { |
| try { |
| const isFav = playlistId === '201' |
| let euin = '' |
| if (isFav && cookie) { |
| const uin = getCookieValue(cookie, 'uin') |
| if (uin) { |
| euin = await getEncryptUin(uin) |
| } |
| } |
|
|
| const data = await tencentApiRequest<DissInfoData>({ |
| module: 'music.srfDissInfo.DissInfo', |
| method: 'CgiGetDiss', |
| cookie: cookie || '', |
| param: { |
| disstid: isFav ? 0 : Number(playlistId), |
| dirid: isFav ? 201 : 0, |
| tag: true, |
| song_begin: limit * (page - 1), |
| song_num: limit, |
| userinfo: true, |
| orderlist: true, |
| onlysonglist: false, |
| ...(isFav && euin ? { enc_host_uin: euin } : {}), |
| }, |
| }) |
|
|
| const songlist = data?.songlist || [] |
| const total = data?.total_song_num || songlist.length || 0 |
|
|
| return { songs: songlist, total } |
| } catch (err) { |
| logger.error(`QQ getPlaylistTracks failed for ${playlistId}`, err) |
| return { songs: [], total: 0 } |
| } |
| } |
|
|