feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
import {
getTxSongPlayableInfo,
TX_MUSICU_URL,
txRequest,
txSignedRequest
} from '~~/server/utils/native_tx'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { createApiError } from '~~/server/utils/apiError'
import {
buildQqCommentRequestParam,
normalizeQqCommentPage
} from '~~/server/utils/qqComment'
interface QqCommentListData {
Comments?: unknown
comments?: unknown
commentlist?: unknown
list?: unknown
Total?: unknown
total?: unknown
HasMore?: unknown
hasMore?: unknown
hasmore?: unknown
}
interface QqCommentModuleData extends QqCommentListData {
CommentList?: QqCommentListData
comment?: QqCommentListData
}
interface QqCommentResponse {
code?: unknown
request?: {
code?: unknown
data?: QqCommentModuleData
}
comment?: {
code?: unknown
data?: QqCommentModuleData
}
}
const getCommentListData = (response: QqCommentResponse) => {
const data = response.request?.data || response.comment?.data || {}
const list = data.CommentList || data.comment || data
return {
list,
comments: list.Comments || list.comments || list.commentlist || list.list || []
}
}
const isSuccessfulResponse = (response: QqCommentResponse | undefined) => {
if (!response || Number(response.code) !== 0) return false
const moduleCode = response.request?.code ?? response.comment?.code
return Number(moduleCode) === 0
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const musicId = String(query.musicId || '').trim()
const originalSongId = String(query.songId || '').trim()
const cursor = String(query.cursor || '').trim()
const rawPage = Number(query.page)
const rawPageSize = Number(query.pageSize)
const page = Number.isFinite(rawPage) ? Math.max(0, Math.floor(rawPage)) : 0
const pageSize = Number.isFinite(rawPageSize)
? Math.min(50, Math.max(1, Math.floor(rawPageSize)))
: 20
const type = query.type === 'latest' ? 'latest' : 'hot'
if (!musicId) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'缺少 musicId 参数'
)
}
let topid = /^\d+$/.test(originalSongId) ? originalSongId : ''
if (!topid && /^\d+$/.test(musicId)) topid = musicId
if (!topid) {
try {
const info = await getTxSongPlayableInfo(musicId)
topid = String(info.songId || '').trim()
} catch (error) {
console.error('[QQ评论] 解析歌曲 ID 失败:', error instanceof Error ? error.message : error)
throw createApiError(
502,
SERVER_ERROR_CODES.QQ_COMMENT_FETCH_FAILED,
'QQ 音乐评论获取失败'
)
}
}
if (!topid) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'QQ 音乐歌曲 ID 无效'
)
}
const requestBody = {
comm: {
ct: 11,
cv: '1003006',
v: '1003006',
os_ver: '15',
phonetype: '24122RKC7C',
tmeAppID: 'qqmusiclight',
nettype: 'NETWORK_WIFI',
udid: '0',
OpenUDID: '0',
QIMEI36: '0',
uin: '0'
},
request: {
module: 'music.globalComment.CommentRead',
method: type === 'hot' ? 'GetHotCommentList' : 'GetNewCommentList',
param: buildQqCommentRequestParam({ topid, cursor, page, pageSize, type })
}
}
let response: QqCommentResponse | undefined
try {
response = (await txRequest(TX_MUSICU_URL, requestBody, {
signal: AbortSignal.timeout(8000)
})) as QqCommentResponse
} catch (error) {
console.warn('[QQ评论] 直连接口请求失败,尝试签名接口:', error)
}
if (!isSuccessfulResponse(response)) {
try {
response = (await txSignedRequest(requestBody, {
signal: AbortSignal.timeout(8000)
})) as QqCommentResponse
} catch (error) {
console.error('[QQ评论] 签名接口请求失败:', error instanceof Error ? error.message : error)
throw createApiError(
502,
SERVER_ERROR_CODES.QQ_COMMENT_FETCH_FAILED,
'QQ 音乐评论获取失败'
)
}
}
if (!isSuccessfulResponse(response)) {
throw createApiError(
502,
SERVER_ERROR_CODES.QQ_COMMENT_FETCH_FAILED,
'QQ 音乐评论获取失败'
)
}
const { list, comments: responseComments } = getCommentListData(response)
const rawComments = Array.isArray(responseComments) ? responseComments : []
const normalizedPage = normalizeQqCommentPage(rawComments)
const commentItems = normalizedPage.comments
return {
code: 200,
data: {
comments: commentItems,
orphanReplies: normalizedPage.orphanReplies,
total: Number(list.Total ?? list.total) || commentItems.length,
more: Number(list.HasMore ?? list.hasMore ?? list.hasmore) === 1,
nextCursor:
Number(list.HasMore ?? list.hasMore ?? list.hasmore) === 1
? String(rawComments.at(-1)?.SeqNo || rawComments.at(-1)?.seqNo || '')
: ''
}
}
})
+257
View File
@@ -0,0 +1,257 @@
// 咪咕 mrc 逐字歌词解密(TEA 算法)
const DELTA = BigInt('2654435769')
const MIN_LENGTH = 32
const keyArr = [
BigInt('27303562373562475'),
BigInt('18014862372307051'),
BigInt('22799692160172081'),
BigInt('34058940340699235'),
BigInt('30962724186095721'),
BigInt('27303523720101991'),
BigInt('27303523720101998'),
BigInt('31244139033526382'),
BigInt('28992395054481524')
]
const toLong = (value: bigint | string): bigint => {
const num = typeof value === 'string' ? BigInt('0x' + value) : value
const MAX = BigInt('9223372036854775807')
const MIN = BigInt('-9223372036854775808')
if (num > MAX) return toLong(num - (BigInt(1) << BigInt(64)))
if (num < MIN) return toLong(num + (BigInt(1) << BigInt(64)))
return num
}
const longToBytes = (l: bigint): Buffer => {
const result = Buffer.alloc(8)
for (let i = 0; i < 8; i++) {
result[i] = parseInt((l & BigInt('0xff')).toString())
l >>= BigInt(8)
}
return result
}
const toBigintArray = (data: string): bigint[] => {
const length = Math.floor(data.length / 16)
const arr: bigint[] = []
for (let i = 0; i < length; i++) {
arr.push(toLong(data.substring(i * 16, i * 16 + 16)))
}
return arr
}
const teaDecrypt = (data: bigint[], key: bigint[]): bigint[] => {
const length = data.length
const lengthBigint = BigInt(length)
if (length < 1) return data
let j2 = data[0]!
let j3 = toLong((BigInt(6) + BigInt(52) / lengthBigint) * DELTA)
while (true) {
const j4 = j3
if (j4 === BigInt(0)) break
const j5 = toLong(BigInt(3) & (j4 >> BigInt(2)))
let j6 = lengthBigint
while (true) {
j6--
if (j6 > BigInt(0)) {
const j7 = data[Number(j6 - BigInt(1))]!
const i = Number(j6)
j2 = toLong(
data[i]! -
(toLong(toLong(j2 ^ j4) + toLong(j7 ^ key[Number(toLong((BigInt(3) & j6) ^ j5))]!)) ^
toLong(
toLong(toLong(j7 >> BigInt(5)) ^ toLong(j2 << BigInt(2))) +
toLong(toLong(j2 >> BigInt(3)) ^ toLong(j7 << BigInt(4)))
))
)
data[i] = j2
} else {
break
}
}
const j8 = data[length - 1]!
j2 = toLong(
data[0]! -
toLong(
toLong(toLong(key[Number(toLong((j6 & BigInt(3)) ^ j5))]! ^ j8) + toLong(j2 ^ j4)) ^
toLong(
toLong(toLong(j8 >> BigInt(5)) ^ toLong(j2 << BigInt(2))) +
toLong(toLong(j2 >> BigInt(3)) ^ toLong(j8 << BigInt(4)))
)
)
)
data[0] = j2
j3 = toLong(j4 - DELTA)
}
return data
}
const longArrToString = (data: bigint[]): string => {
return data.map((j) => longToBytes(j).toString('utf16le')).join('')
}
const decryptMrc = (data: string): string => {
if (data == null || data.length < MIN_LENGTH) return data
return longArrToString(teaDecrypt(toBigintArray(data), keyArr))
}
// mrc 逐字格式 `[startMs,durationMs]` 解析为普通逐行歌词 `[mm:ss.mmm]`
const parseMrcToLrc = (str: string): string => {
const lines = str.split('\n')
const lrcLines: string[] = []
for (const line of lines) {
if (line.length < 6) continue
const timeMatch = /^\s*\[(\d+),\d+\]/.exec(line)
const timeStr = timeMatch?.[1]
if (!timeStr) continue
const startTime = parseInt(timeStr)
let time = startTime
const ms = (time % 1000).toString().padStart(3, '0')
time = Math.floor(time / 1000)
const m = Math.floor(time / 60).toString().padStart(2, '0')
const s = (time % 60).toString().padStart(2, '0')
const words = line.replace(/^\s*\[(\d+),\d+\]/, '').replace(/\(\d+,\d+\)/g, '')
lrcLines.push(`[${m}:${s}.${ms}]${words}`)
}
return lrcLines.join('\n')
}
const MIGU_LYRIC_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Mobile Safari/537.36',
Referer: 'https://app.c.nf.migu.cn/',
channel: '0146921'
}
const fetchLyricText = async (url: string) => {
const response = await fetch(url, {
headers: MIGU_LYRIC_HEADERS,
signal: AbortSignal.timeout(8000)
})
if (!response.ok) {
throw createError({ statusCode: 502, message: `咪咕歌词接口返回 ${response.status}` })
}
return response.text()
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const lrcUrl = query.lrcUrl as string
const contentId = query.contentId as string
const mrcUrl = query.mrcUrl as string
// 优先使用 mrc 逐字歌词(解密后解析)
if (mrcUrl) {
try {
const mrcText = await fetchLyricText(mrcUrl)
const lrc = parseMrcToLrc(decryptMrc(mrcText))
if (!lrc || !lrc.trim()) {
throw createError({ statusCode: 404, message: '歌词内容为空' })
}
return {
success: true,
data: { lrc, trans: '', yrc: '', ttml: '' }
}
} catch (err: any) {
console.error('[mg.lyric] 获取 mrc 歌词失败:', err)
throw createError({
statusCode: err.statusCode || 500,
message: err.message || '获取歌词失败'
})
}
}
// 优先使用歌词URL
if (lrcUrl) {
try {
const lrcText = await fetchLyricText(lrcUrl)
if (!lrcText || !lrcText.trim()) {
throw createError({ statusCode: 404, message: '歌词内容为空' })
}
return {
success: true,
data: {
lrc: lrcText,
trans: '',
yrc: '',
ttml: ''
}
}
} catch (err: any) {
console.error('[mg.lyric] 获取歌词失败:', err)
throw createError({
statusCode: err.statusCode || 500,
message: err.message || '获取歌词失败'
})
}
}
// 如果没有提供歌词URL,通过contentId查询歌曲信息获取歌词URL
if (contentId) {
try {
// 通过歌曲信息接口获取 lrcUrl/mrcUrl
const songInfoResponse: any = await $fetch(
`https://app.c.nf.migu.cn/resource/song/by-contentids/v2.0?contentId=${contentId}`,
{ timeout: 10000 }
)
const code = songInfoResponse?.code?.toString() || ''
if (code !== '000000' || !songInfoResponse?.data?.length) {
throw createError({ statusCode: 404, message: '未找到歌曲信息' })
}
const songData = songInfoResponse.data[0]
// 优先使用 mrc 逐字歌词
if (songData?.mrcUrl) {
try {
const mrcText = await fetchLyricText(songData.mrcUrl)
const lrc = parseMrcToLrc(decryptMrc(mrcText))
if (lrc && lrc.trim()) {
return {
success: true,
data: { lrc, trans: '', yrc: '', ttml: '' }
}
}
} catch (mrcErr) {
console.warn('[mg.lyric] mrc 歌词解析失败,回退 lrc:', mrcErr)
}
}
const songLrcUrl = songData?.lrcUrl
if (!songLrcUrl) {
throw createError({ statusCode: 404, message: '未找到歌词链接' })
}
// 获取歌词内容
const lrcText = await fetchLyricText(songLrcUrl)
return {
success: true,
data: {
lrc: lrcText,
trans: '',
yrc: '',
ttml: ''
}
}
} catch (err: any) {
console.error('[mg.lyric] 通过contentId获取歌词失败:', err)
throw createError({
statusCode: err.statusCode || 500,
message: err.message || '获取歌词失败'
})
}
}
// 既没有歌词URL也没有contentId
throw createError({
statusCode: 400,
message: '缺少 lrcUrl、mrcUrl 或 contentId 参数'
})
})
+123
View File
@@ -0,0 +1,123 @@
import { resolveQqNativeLyric, resolveQqSdkLyric } from '~~/server/utils/qq_music_sdk'
import { getTxSongPlayableInfo } from '~~/server/utils/native_tx'
import { getServerTimestamp } from '~~/server/utils/serverTime'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const songmid = (query.songmid as string) || ''
const songid = (query.songid as string) || ''
const cookie = (query.cookie as string) || ''
const name = (query.name as string) || ''
const artist = (query.artist as string) || ''
const album = (query.album as string) || ''
const duration = Number(query.duration) || 0
if (!songmid && !songid) {
throw createError({ statusCode: 400, message: '缺少 songmid 或 songid 参数' })
}
let resolvedSongId: string = songid
let resolvedSongmid: string = songmid
// mid → 数字 songId(原生 QRC 接口必须用数字 ID)
if (!resolvedSongId && resolvedSongmid) {
try {
const info = await getTxSongPlayableInfo(resolvedSongmid)
resolvedSongId = info.songId || ''
resolvedSongmid = info.songmid || resolvedSongmid
} catch (e) {
console.warn('[tx.lyric] 无法解析 songId,将回退到 SDK 接口:', e)
}
}
// 优先:原生 GetPlayLyricInfo 接口,支持 QRC 逐字歌词
if (resolvedSongId) {
try {
const data = await resolveQqNativeLyric({
songId: resolvedSongId,
name,
artist,
album,
duration,
cookie
})
if (data.qrc || data.lrc) {
return {
success: true,
data: {
lrc: data.lrc || '',
qrc: data.qrc || '',
trans: data.trans || '',
roma: data.roma || ''
}
}
}
} catch (e) {
console.warn('[tx.lyric] 原生 QRC 接口失败,回退到 SDK 接口:', e)
}
}
// 回退:qq-music-api SDK(只返回 LRC)
try {
const data = await resolveQqSdkLyric({ songmid: resolvedSongmid, songid: resolvedSongId, cookie })
return {
success: true,
data: {
lrc: data.lrc || '',
trans: data.trans || ''
}
}
} catch (sdkErr) {
console.warn('[tx.lyric] SDK 接口失败,回退到旧接口:', sdkErr)
}
// 最终回退:旧版 fcg_query_lyric_new 接口
const params = new URLSearchParams({
format: 'json',
outCharset: 'utf-8',
pcachetime: String(getServerTimestamp()),
loginUin: '0',
...(resolvedSongmid ? { songmid: resolvedSongmid } : {}),
...(resolvedSongId ? { songid: resolvedSongId } : {})
})
const response = await fetch(
`https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?${params.toString()}`,
{
headers: {
Referer: 'https://y.qq.com/portal/player.html',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
},
signal: AbortSignal.timeout(8000)
}
)
if (!response.ok) {
throw createError({ statusCode: 502, message: `QQ 歌词接口返回 ${response.status}` })
}
const text = await response.text()
let legacyData: any
try {
legacyData = JSON.parse(text)
} catch {
legacyData = JSON.parse(text.replace(/^\w+\(/, '').replace(/\)\s*$/, ''))
}
if (!legacyData || legacyData.code !== 0) {
throw createError({ statusCode: 502, message: `QQ 歌词接口异常: ${legacyData?.code ?? '未知'}` })
}
const decodeBase64 = (value: unknown): string => {
if (typeof value !== 'string' || !value) return ''
try { return Buffer.from(value, 'base64').toString() || value } catch { return value }
}
return {
success: true,
data: {
lrc: decodeBase64(legacyData.lyric),
trans: decodeBase64(legacyData.trans)
}
}
})
+151
View File
@@ -0,0 +1,151 @@
// 将 PQ 基础链接的路径替换为高音质资源路径
function upgradeUrl(url: string, quality: string) {
let upgraded = url
switch (quality) {
case 'HQ':
upgraded = url.replace('MP3_128_16_Stero', 'MP3_320_16_Stero')
break
case 'SQ':
upgraded = url.replace('标清高清/MP3_128_16_Stero', '歌曲下载/flac').replace('.mp3', '.flac')
break
case 'ZQ':
case 'ZQ24':
upgraded = url.replace('标清高清/MP3_128_16_Stero', '歌曲下载/flac_24bit').replace('.mp3', '.flac')
break
case 'PQ':
default:
return url
}
// 路径片段未命中时替换不生效,实际仍为基础音质,记录日志便于排查
if (upgraded === url) {
console.warn(`[migu/playurl.get] 音质升级未命中(目标 ${quality}),回退为基础音质链接`)
}
return upgraded
}
// 高音质资源可能缺失(404),按降级链逐级回退:ZQ24 → SQ → HQ → PQ
const QUALITY_FALLBACK_CHAIN: Record<string, string[]> = {
ZQ24: ['ZQ24', 'SQ', 'HQ', 'PQ'],
ZQ: ['ZQ24', 'SQ', 'HQ', 'PQ'],
SQ: ['SQ', 'HQ', 'PQ'],
HQ: ['HQ', 'PQ'],
PQ: ['PQ']
}
/**
* HEAD 探测链接可用性,探测失败时保守视为可用,避免误降级
*/
async function isUrlAvailable(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
return res.ok
} catch {
return true
}
}
/**
* 按降级链逐级升级并探测,返回第一个可用的链接与实际音质
*/
async function resolveAvailableUrl(baseUrl: string, toneFlag: string): Promise<{ url: string; quality: string }> {
const chain = QUALITY_FALLBACK_CHAIN[toneFlag] || ['PQ']
for (const level of chain) {
const candidate = upgradeUrl(baseUrl, level)
// PQ 为官方接口直出链接,无需探测
if (level === 'PQ' || (await isUrlAvailable(candidate))) {
if (level !== toneFlag) {
console.warn(`[migu/playurl.get] ${toneFlag} 音质链接不可用,已降级为 ${level}`)
}
return { url: candidate, quality: level }
}
}
return { url: baseUrl, quality: 'PQ' }
}
function strToUtf8Bytes(str: string): Uint8Array {
return new TextEncoder().encode(str)
}
function utf8Bytes2str(bs: Uint8Array): string {
const a: string[] = []
for (const b of bs) {
if (b < 16) {
a.push(String.fromCharCode(b))
} else {
a.push('%'); a.push(b.toString(16));
}
}; return decodeURIComponent(a.join(''))
}
function decode(e: Uint8Array, t: string) {
if (0 == t.length) return null;
var n = e.length;
if (n < 4) return null;
if (171 != e[0] || 205 != e[1]) return null;
if (1 != e[2]) return null;
for (var r = e[3], o = strToUtf8Bytes(t), a = o.length, i = new Uint8Array(n - 4), s = 0, c = 4; c < n; c++, s++)
// @ts-ignore
i[s] = e[c] + r - o[s % a];
return i
}
async function mr(ab: ArrayBuffer): Promise<any> {
const u8 = new Uint8Array(ab), dd = decode(u8, 'Jk8qzuePiJ1qE3mDYhLQ3T73DtDoAhLP')
if (!dd) throw new Error('解密失败')
const j = utf8Bytes2str(dd)
return JSON.parse(j)
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const contentId = query.contentId as string;
// 白名单校验,非法值回退为基础音质
const VALID_TONE_FLAGS = ['PQ', 'HQ', 'SQ', 'ZQ', 'ZQ24']
const toneFlag = VALID_TONE_FLAGS.includes(query.toneFlag as string) ? (query.toneFlag as string) : 'PQ';
if (!contentId) throw createError({ statusCode: 400, message: 'Missing contentId' });
try {
const headers = {
'birth': 'h5page',
'channel': '014X031',
'Referer': 'https://y.migu.cn/',
'location-data': '30.6698676660,104.1229614820',
'location-info': '',
}, baseUrl = 'http://c.musicapp.migu.cn', strategyU = '/listen-url/h5',
// 匿名请求上游仅提供基础音质,固定以 PQ 取链后再本地升级路径
params = `contentId=${contentId}&copyrightId=&resourceType=2&netType=01&toneFlag=PQ&scene=&lowerQualityContentId=${contentId}`;
const res = await fetch(
`${baseUrl}/strategy${strategyU}/v2.4?${params}`
,{headers}
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const ab = await res.arrayBuffer(), data = await mr(ab);
let url = decodeURIComponent(data?.data?.url ?? '');
if (!url) {
return {
success: false,
url: '',
source: 'migu'
}
}
// 移除查询参数后统一为 https 避免混合内容拦截,再按降级链升级并探测可用性
url = (url.split('?')[0] as string).replace(/^http:\/\//, 'https://');
const { url: finalUrl, quality: actualQuality } = await resolveAvailableUrl(url, toneFlag)
return {
success: true,
url: finalUrl,
// 实际提供的音质(高音质资源缺失时会低于请求值)
quality: actualQuality,
source: 'migu'
}
} catch (err: any) {
console.error('[migu/playurl.get] 获取播放链接失败:', err)
throw createError({
statusCode: err.statusCode || 500,
message: err.message || 'Internal Server Error'
})
}
})
+32
View File
@@ -0,0 +1,32 @@
import { getQqUserAvatar } from '~~/server/utils/qq_music_sdk'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const uin = String(query.uin || '').trim()
const k = String(query.k || '').trim()
const size = Number(query.size || 140)
if (!uin && !k) {
throw createError({ statusCode: 400, message: '缺少 uin 或 k 参数' })
}
if (!Number.isFinite(size) || size <= 0) {
throw createError({ statusCode: 400, message: 'size 参数无效' })
}
try {
return {
success: true,
data: await getQqUserAvatar({
uin: uin || undefined,
k: k || undefined,
size
})
}
} catch (error: any) {
throw createError({
statusCode: 502,
message: error?.message || '获取 QQ 用户头像失败'
})
}
})
@@ -0,0 +1,41 @@
import { checkQqCookie } from '~~/server/utils/qq_music_sdk'
// 校验 QQ 音乐登录 Cookie 是否有效;无效时前端据此清理本地登录态
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const cookie = String(body?.cookie || '').trim()
if (!cookie) {
throw createError({ statusCode: 400, message: '缺少 cookie 参数' })
}
try {
const result = await checkQqCookie({ cookie })
return {
success: true,
data: {
valid: result.valid,
isVip: result.isVip,
signals: result.signals,
user: result.profile
? {
userId: result.profile.userId,
id: result.profile.userId,
nickname: result.profile.nickname,
userName: result.profile.nickname,
avatarUrl: result.profile.avatarUrl
}
: undefined,
authDiagnostic: result.authDiagnostic
}
}
} catch (error: any) {
// 网络等异常不属于「登录失效」,向上抛错让前端保留本地状态
console.warn('[qq/check-cookie] 校验 QQ 登录态失败:', error?.message || error)
throw createError({
statusCode: 502,
message: error?.message || '校验 QQ 登录状态失败'
})
}
})
@@ -0,0 +1,77 @@
import {
checkQqLogin,
getQqCookieDiagnostic,
getQqUserAvatar,
normalizeQqCookie
} from '~~/server/utils/qq_music_sdk'
const getSessionCookie = (session: any) => {
if (typeof session?.cookie === 'string') return session.cookie
if (Array.isArray(session?.cookieList)) return session.cookieList.join('; ')
return ''
}
const normalizeLoginStatus = (data: any) => {
if (data?.isOk) return 'success'
if (data?.refresh) return 'expired'
return 'waiting'
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const ptqrtoken = body?.ptqrtoken
const qrsig = String(body?.qrsig || '').trim()
if (!ptqrtoken || !qrsig) {
throw createError({ statusCode: 400, message: '缺少 ptqrtoken 或 qrsig 参数' })
}
try {
const data: any = await checkQqLogin(ptqrtoken, qrsig)
const session = data?.session || null
const cookie = normalizeQqCookie(getSessionCookie(session))
const cookieObject = session?.cookieObject || {}
const uin = cookieObject.qqmusic_uin || session?.uin || session?.loginUin || cookieObject.uin || ''
let avatarUrl = ''
if (data?.isOk && uin) {
try {
const avatar = await getQqUserAvatar({ uin, size: 140 })
avatarUrl = avatar?.avatarUrl || ''
} catch (avatarError) {
console.warn('[qq/check-login] 获取 QQ 头像失败:', avatarError)
}
}
return {
success: true,
data: {
...data,
status: normalizeLoginStatus(data),
cookie: cookie || undefined,
authDiagnostic: getQqCookieDiagnostic(cookie),
user: data?.isOk
? {
userId: uin,
id: uin,
nickname: uin ? `QQ ${uin}` : 'QQ音乐账号',
userName: uin ? `QQ ${uin}` : 'QQ音乐账号',
avatarUrl,
raw: session
}
: undefined,
session: session
? {
...session,
cookie
}
: session
}
}
} catch (error: any) {
throw createError({
statusCode: 502,
message: error?.message || '检查 QQ 登录状态失败'
})
}
})
@@ -0,0 +1,66 @@
import {
checkQqWxLogin,
getQqCookieDiagnostic,
normalizeQqCookie
} from '~~/server/utils/qq_music_sdk'
const getSessionCookie = (session: any) => {
if (typeof session?.cookie === 'string') return session.cookie
if (Array.isArray(session?.cookieList)) return session.cookieList.join('; ')
return ''
}
const normalizeLoginStatus = (data: any) => {
// 微信取消登录(refused)同样归入过期态,前端引导重新出码
if (data?.isOk) return 'success'
if (data?.refresh) return 'expired'
return 'waiting'
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const uuid = String(body?.uuid || '').trim()
if (!uuid) {
throw createError({ statusCode: 400, message: '缺少 uuid 参数' })
}
try {
const data: any = await checkQqWxLogin(uuid)
const session = data?.session || null
const cookie = normalizeQqCookie(getSessionCookie(session))
const cookieObject = session?.cookieObject || {}
const uin = cookieObject.qqmusic_uin || session?.uin || session?.loginUin || cookieObject.uin || ''
return {
success: true,
data: {
...data,
status: normalizeLoginStatus(data),
cookie: cookie || undefined,
authDiagnostic: getQqCookieDiagnostic(cookie),
user: data?.isOk
? {
userId: uin,
id: uin,
nickname: `微信 ${String(uin).slice(-4)}`,
userName: `微信 ${String(uin).slice(-4)}`,
avatarUrl: '',
raw: session
}
: undefined,
session: session
? {
...session,
cookie
}
: session
}
}
} catch (error: any) {
throw createError({
statusCode: 502,
message: error?.message || '检查微信登录状态失败'
})
}
})
@@ -0,0 +1,16 @@
import { getQqWxLoginQr } from '~~/server/utils/qq_music_sdk'
// 获取 QQ 音乐微信扫码登录二维码
export default defineEventHandler(async () => {
try {
return {
success: true,
data: await getQqWxLoginQr()
}
} catch (error: any) {
throw createError({
statusCode: 502,
message: error?.message || '获取微信登录二维码失败'
})
}
})
+15
View File
@@ -0,0 +1,15 @@
import { getQqLoginQr } from '~~/server/utils/qq_music_sdk'
export default defineEventHandler(async () => {
try {
return {
success: true,
data: await getQqLoginQr()
}
} catch (error: any) {
throw createError({
statusCode: 502,
message: error?.message || '获取 QQ 登录二维码失败'
})
}
})
@@ -0,0 +1,26 @@
import { getQqPlaylistSongs } from '~~/server/utils/qq_music_sdk'
// 获取 QQ 音乐歌单内歌曲列表;favSongs=true 时返回"我喜欢"歌曲
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const cookie = String(body?.cookie || '').trim()
const disstid = String(body?.disstid || '').trim()
const favSongs = Boolean(body?.favSongs)
const limit = Math.min(Math.max(Number(body?.limit) || 100, 1), 500)
const offset = Math.max(Number(body?.offset) || 0, 0)
if (!cookie || (!disstid && !favSongs)) {
throw createError({ statusCode: 400, message: '缺少 cookie 或 disstid 参数' })
}
try {
const result = await getQqPlaylistSongs({ disstid, favSongs, cookie, limit, offset })
return { success: true, data: result }
} catch (error: any) {
console.warn('[qq/playlist-songs] 获取歌单歌曲失败:', error?.message || error)
throw createError({
statusCode: 502,
message: error?.message || '获取歌单歌曲失败'
})
}
})
@@ -0,0 +1,22 @@
import { getQqUserPlaylists } from '~~/server/utils/qq_music_sdk'
// 获取用户创建(含"我喜欢")与收藏的 QQ 音乐歌单
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const cookie = String(body?.cookie || '').trim()
if (!cookie) {
throw createError({ statusCode: 400, message: '缺少 cookie 参数' })
}
try {
const result = await getQqUserPlaylists({ cookie })
return { success: true, data: result }
} catch (error: any) {
console.warn('[qq/playlists] 获取用户歌单失败:', error?.message || error)
throw createError({
statusCode: 502,
message: error?.message || '获取用户歌单失败'
})
}
})
+162
View File
@@ -0,0 +1,162 @@
import { formatPlayTime } from '../../../utils/native_common'
/**
* 咪咕网页 v5 搜索接口
*/
async function searchPC(str: string, page: number, limit: number) {
const response: any = await $fetch(
`https://app.u.nf.migu.cn/pc/resource/song/item/search/v1.0?text=${encodeURIComponent(str)}` +
`&pageSize=${limit}&pageNo=${page}`,
{
headers: {
Origin: 'https://music.migu.cn',
Referer: 'https://music.migu.cn/v5',
birth: 'h5page',
channel: '014X031'
},
timeout: 10000
}
)
// v5 接口直接返回歌曲扁平数组,无 total 字段
if (!Array.isArray(response)) throw createError({ statusCode: 502, message: 'Migu PC API Error' })
const list = await Promise.all(
response
.filter((item: any) => item?.contentId)
.map(async (item: any) => {
// 歌手列表处理
const singerList = item.singerList || []
const singer = singerList.map((s: any) => s.name).join('/') || '未知艺术家'
// 封面图(相对路径补全域名)
const img = item.img2 || item.img3 || item.img1 || ''
const cover = img && !/^https?:/.test(img) ? `https://d.musicapp.migu.cn${img}` : img
const contentId = item.contentId
// 音质格式列表
const formats = item.audioFormats?.map((f: any) => f.formatType) || ['PQ']
return {
singer,
name: item.songName || item.name || '',
albumName: item.album || '',
albumId: item.albumId || '',
source: 'mg',
interval: formatPlayTime(item.duration || 0),
duration: item.duration || 0,
songmid: contentId,
copyrightId: item.copyrightId || '',
img: cover,
lrc: item.lrcUrl || null,
mrcUrl: item.mrcurl || null,
types: formats,
_types: {},
typeUrl: {}
}
})
)
return {
list,
// v5 接口无 total 字段,按当前页数量推算(满页则视为还有下一页)
total: list.length < limit ? (page - 1) * limit + list.length : page * limit + 1
}
}
/**
* 咪咕移动端搜索接口(app.c.nf.migu.cn,海外服务器可访问)
*/
async function searchMobile(str: string, page: number, limit: number) {
const response: any = await $fetch('https://app.c.nf.migu.cn/bmw/search/song/v1.0', {
params: {
pageNo: page,
text: str
},
timeout: 10000
})
if (!response?.data) {
throw createError({ statusCode: 502, message: 'Migu Mobile API Error' })
}
const items = response.data.items || []
const list = await Promise.all(
items
// contentId 位于 item.song 内,而非 item 顶层
.filter((item: any) => item?.song?.contentId)
.map(async (item: any) => {
const song = item.song
// 歌手列表处理
const singerList = song.singerList || []
const singer = singerList.map((s: any) => s.name).join('/') || '未知艺术家'
// 封面图(相对路径补全域名)
const img = song.img2 ? `https://d.musicapp.migu.cn${song.img2}` : ''
const contentId = song.contentId
// 音质格式列表
const formats = song.audioFormats?.map((f: any) => f.formatType) || ['PQ']
return {
singer,
name: song.songName || '',
albumName: song.album || '',
albumId: song.albumId || '',
source: 'mg',
interval: formatPlayTime(song.duration || 0),
duration: song.duration || 0,
songmid: contentId,
copyrightId: '',
img: img,
lrc: song.lrcUrl || null,
mrcUrl: null,
types: formats,
_types: {},
typeUrl: {}
}
})
)
return {
list,
// 移动端接口无 total 字段,按当前页数量推算(满页则视为还有下一页)
total: list.length < limit ? (page - 1) * limit + list.length : page * limit + 1
}
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const str = query.str as string
const page = parseInt((query.page as string) || '1')
const limit = parseInt((query.limit as string) || '30')
if (!str) {
throw createError({ statusCode: 400, message: 'Missing search query' })
}
try {
let result
try {
result = await searchPC(str, page, limit)
} catch (err) {
console.warn('[mg.get] 咪咕网页 v5 接口请求失败,回退到移动端接口:', err)
result = await searchMobile(str, page, limit)
}
return {
...result,
page,
limit,
source: 'mg'
}
} catch (err: any) {
console.error('[mg.get] 咪咕搜索失败:', err)
throw createError({
statusCode: err.statusCode || 500,
message: err.message || 'Internal Server Error'
})
}
})
+181
View File
@@ -0,0 +1,181 @@
import { createTxSearchBody, txRequest, txSignedRequest } from '../../../utils/native_tx'
import { decodeName, formatPlayTime, sizeFormate } from '../../../utils/native_common'
import { searchQqMusic } from '~~/server/utils/qq_music_sdk'
const stripHtml = (value: unknown) => String(value ?? '').replace(/[<>]/g, '')
const formatSdkSearchList = (items: any[]) => {
return items.map((item: any) => {
const songmid = item.songmid || item.mid
const strMediaMid = item.strMediaMid || item.media_mid || item.file?.media_mid || songmid
const albumMid = item.albummid || item.albumMid || item.album?.mid || ''
const singers = Array.isArray(item.singer)
? item.singer.map((s: any) => s.name).filter(Boolean).join('、')
: item.singer || ''
const interval = Number(item.interval || item.duration || 0)
const types = []
const _types: any = {}
if (Number(item.size128 || item.file?.size_128mp3 || 0) !== 0) {
const size = sizeFormate(Number(item.size128 || item.file?.size_128mp3))
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (Number(item.size320 || item.file?.size_320mp3 || 0) !== 0) {
const size = sizeFormate(Number(item.size320 || item.file?.size_320mp3))
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (Number(item.sizeflac || item.file?.size_flac || 0) !== 0) {
const size = sizeFormate(Number(item.sizeflac || item.file?.size_flac))
types.push({ type: 'flac', size })
_types.flac = { size }
}
return {
singer: decodeName(stripHtml(singers)),
name: decodeName(stripHtml(item.songname || item.name || item.title)),
albumName: decodeName(stripHtml(item.albumname || item.albumName || item.album?.name || '')),
albumId: albumMid,
source: 'tx',
interval: formatPlayTime(interval),
duration: interval,
songId: item.songid || item.songId || item.id,
albumMid,
strMediaMid,
songmid,
img:
albumMid === '' || albumMid === '空'
? Array.isArray(item.singer) && item.singer.length > 0 && item.singer[0]?.mid
? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg`
: ''
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumMid}.jpg`,
types,
_types,
typeUrl: {}
}
}).filter((item) => item.songmid)
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const str = query.str as string
const page = parseInt((query.page as string) || '1')
const limit = parseInt((query.limit as string) || '50') // 腾讯接口默认每页 50 条
const cookie = String(query.cookie || '').trim()
if (!str) {
throw createError({ statusCode: 400, message: 'Missing search query' })
}
const body = createTxSearchBody(str, page, limit)
let result: any
try {
try {
const sdkResult: any = await searchQqMusic({ key: str, page, limit, cookie })
const sdkList = sdkResult?.song?.list || sdkResult?.data?.song?.list || []
const list = formatSdkSearchList(sdkList)
if (list.length > 0) {
return {
list,
total: sdkResult?.song?.totalnum || sdkResult?.data?.song?.totalnum || list.length,
page,
limit,
source: 'qq-music-api'
}
}
} catch (sdkErr) {
console.warn('[tx.get] qq-music-api 搜索失败,回退到原生搜索:', sdkErr)
}
try {
result = await txSignedRequest(body)
} catch (signedErr) {
console.warn('[tx.get] 签名请求失败,回退到普通请求:', signedErr)
result = await txRequest('https://u.y.qq.com/cgi-bin/musicu.fcg', body)
}
const service = result?.['music.search.SearchCgiService']
if (result.code !== 0 || service?.code !== 0) {
throw createError({ statusCode: 502, message: 'Tencent API Error' })
}
const rawList = service?.data?.body?.song?.list || []
const list = []
for (const item of rawList) {
if (!item.file?.media_mid) continue
const types = []
const _types: any = {}
const file = item.file
if (file.size_128mp3 != 0) {
const size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (file.size_320mp3 !== 0) {
const size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (file.size_flac !== 0) {
const size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (file.size_hires !== 0) {
const size = sizeFormate(file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = { size }
}
let albumId = ''
let albumName = ''
if (item.album) {
albumName = item.album.name
albumId = item.album.mid
}
const singerName = item.singer ? item.singer.map((s: any) => s.name).join('、') : ''
list.push({
singer: decodeName(singerName),
name: decodeName(item.name + (item.title_extra ?? '')),
albumName: decodeName(albumName),
albumId,
source: 'tx',
interval: formatPlayTime(item.interval),
duration: item.interval,
songId: item.id,
albumMid: item.album?.mid ?? '',
strMediaMid: item.file.media_mid,
songmid: item.mid,
img:
albumId === '' || albumId === '空'
? Array.isArray(item.singer) && item.singer.length > 0 && item.singer[0]?.mid
? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg`
: ''
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
types,
_types,
typeUrl: {}
})
}
return {
list,
total: service?.data?.meta?.sum ?? list.length,
page,
limit,
source: 'tx'
}
} catch (err) {
console.error(err)
throw createError({ statusCode: 500, message: 'Internal Server Error' })
}
})
+92
View File
@@ -0,0 +1,92 @@
import { wyEapiRequest } from '../../../utils/native_wy'
import { formatPlayTime, sizeFormate } from '../../../utils/native_common'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const str = query.str as string
const page = parseInt((query.page as string) || '1')
const limit = parseInt((query.limit as string) || '30')
if (!str) {
throw createError({ statusCode: 400, message: 'Missing search query' })
}
const offset = limit * (page - 1)
const data = {
s: str,
type: 1, // 1: 单曲
limit,
total: page === 1,
offset
}
try {
const result: any = await wyEapiRequest('/api/cloudsearch/pc', data)
if (!result || result.code !== 200) {
throw createError({ statusCode: 502, message: 'Netease API Error' })
}
const songs = result.result?.songs || []
// 处理歌曲列表,参考 LX Music 的 handleResult 逻辑
const list = songs.map((item: any) => {
const types: any[] = []
const _types: any = {}
let size
if (item.privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = { size }
}
switch (item.privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = { size }
// LX 代码中有意为之的 switch 贯穿 (fallthrough)
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = { size }
// 贯穿
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = { size }
}
types.reverse()
return {
singer: item.ar.map((s: any) => s.name).join('、'),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
duration: item.dt / 1000,
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {}
}
})
return {
list,
total: result.result?.songCount || 0,
page,
limit,
source: 'wy'
}
} catch (err) {
console.error(err)
throw createError({ statusCode: 500, message: 'Internal Server Error' })
}
})