feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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 || '')
|
||||
: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 参数'
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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}©rightId=&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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '获取微信登录二维码失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '获取用户歌单失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user