feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user