feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { upgradeTxAudioUrl } from '~~/server/utils/native_tx'
|
||||
import {
|
||||
getQqCookieDiagnostic,
|
||||
normalizeQqCookie,
|
||||
resolveQqOfficialPlayUrl,
|
||||
resolveQqSdkPlayUrl
|
||||
} from '~~/server/utils/qq_music_sdk'
|
||||
|
||||
const HYW_TX_URL = 'http://103.79.184.97/api/music/url'
|
||||
const HYW_CARD_KEY = 'PYPW-QFRL-3DBF-95O6'
|
||||
|
||||
const resolveTxWithHyw = async (songmid: string, quality: unknown) => {
|
||||
const qualityMap: Record<string, string> = {
|
||||
'4': '128k',
|
||||
'8': '320k',
|
||||
'10': 'flac',
|
||||
'11': 'master',
|
||||
'14': 'master'
|
||||
}
|
||||
const query = new URLSearchParams({
|
||||
source: 'tx',
|
||||
songId: songmid,
|
||||
songmid,
|
||||
platform: 'tx',
|
||||
quality: qualityMap[String(quality)] || String(quality || '128k'),
|
||||
key: HYW_CARD_KEY
|
||||
})
|
||||
const response = await fetch(`${HYW_TX_URL}?${query.toString()}`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
headers: { Accept: 'application/json', 'X-Card-Key': HYW_CARD_KEY }
|
||||
})
|
||||
if (!response.ok) throw new Error(`HYW 返回 ${response.status}`)
|
||||
const data: any = await response.json()
|
||||
const url = typeof data?.url === 'string' ? data.url.trim() : ''
|
||||
if (data?.code !== 200 || !/^https?:\/\//i.test(url)) {
|
||||
throw new Error(data?.message || 'HYW 未返回播放链接')
|
||||
}
|
||||
return upgradeTxAudioUrl(url)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const platform = String(body?.platform || '').trim()
|
||||
const musicId = body?.musicId
|
||||
const playUrl = String(body?.playUrl || '').trim()
|
||||
const cookie = normalizeQqCookie(String(body?.cookie || '').trim())
|
||||
// official-first:QQ 官方 Cookie 链路;fallback:第三方兜底链路
|
||||
const strategy = body?.strategy === 'official-first' ? 'official-first' : 'fallback'
|
||||
const excludedSources = new Set(
|
||||
Array.isArray(body?.excludeSources)
|
||||
? body.excludeSources.map((item: unknown) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
)
|
||||
|
||||
if (playUrl) {
|
||||
return {
|
||||
success: true,
|
||||
url: platform === 'tencent' ? upgradeTxAudioUrl(playUrl) : playUrl,
|
||||
source: 'play-url',
|
||||
normalizedMusicId: musicId ? String(musicId).trim() : '',
|
||||
idType: 'provided-url',
|
||||
authUsed: Boolean(cookie),
|
||||
authDiagnostic: getQqCookieDiagnostic(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
if (platform !== 'tencent') {
|
||||
throw createError({ statusCode: 400, message: '暂不支持的平台' })
|
||||
}
|
||||
|
||||
const normalizedMusicId = String(musicId || '').trim()
|
||||
|
||||
// 官方 Cookie 链路:带用户登录态走 CgiGetVkey,可获取 VIP/高音质直链
|
||||
if (strategy === 'official-first') {
|
||||
if (excludedSources.has('qq-official')) {
|
||||
throw createError({ statusCode: 502, message: 'QQ 音乐播放链接解析失败' })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await resolveQqSdkPlayUrl(
|
||||
normalizedMusicId,
|
||||
body?.quality,
|
||||
cookie || undefined,
|
||||
String(body?.mediaId || '').trim() || undefined
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
source: 'qq-official',
|
||||
normalizedMusicId,
|
||||
idType: 'songmid',
|
||||
authUsed: Boolean(cookie),
|
||||
authDiagnostic: getQqCookieDiagnostic(cookie)
|
||||
}
|
||||
} catch (error: any) {
|
||||
const sdkError = error?.message || String(error)
|
||||
console.warn('[music/resolve-url] QQ 官方 SDK 解析失败,尝试原生直连:', sdkError)
|
||||
|
||||
// 原生直连:单文件名组合 + zzcSign 签名请求,与 SDK 的文件名策略互为校验
|
||||
try {
|
||||
const url = await resolveQqOfficialPlayUrl({
|
||||
songmid: normalizedMusicId,
|
||||
quality: body?.quality,
|
||||
cookie: cookie || undefined,
|
||||
mediaId: String(body?.mediaId || '').trim() || undefined
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
source: 'qq-official',
|
||||
normalizedMusicId,
|
||||
idType: 'songmid',
|
||||
authUsed: Boolean(cookie),
|
||||
authDiagnostic: getQqCookieDiagnostic(cookie)
|
||||
}
|
||||
} catch (nativeError: any) {
|
||||
const nativeErrorText = nativeError?.message || String(nativeError)
|
||||
console.warn('[music/resolve-url] QQ 原生直连解析失败:', nativeErrorText)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: `QQ 官方链路解析失败:${sdkError};原生直连:${nativeErrorText}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (excludedSources.has('hyw-tx')) {
|
||||
throw createError({ statusCode: 502, message: 'QQ 音乐播放链接解析失败' })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await resolveTxWithHyw(normalizedMusicId, body?.quality)
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
source: 'hyw-tx',
|
||||
normalizedMusicId,
|
||||
idType: 'songmid',
|
||||
authUsed: Boolean(cookie),
|
||||
authDiagnostic: getQqCookieDiagnostic(cookie)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('[music/resolve-url] HYW QQ 回退失败:', error?.message || error)
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: 'QQ 音乐播放链接解析失败'
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { broadcastMusicState, broadcastSongChange } from './websocket'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
|
||||
// 验证请求数据
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的请求数据'
|
||||
})
|
||||
}
|
||||
|
||||
const { type, data } = body
|
||||
|
||||
switch (type) {
|
||||
case 'state_update':
|
||||
// 音乐状态更新
|
||||
if (data && typeof data === 'object') {
|
||||
const musicState = {
|
||||
songId: data.songId,
|
||||
isPlaying: Boolean(data.isPlaying),
|
||||
position: Number(data.position) || 0,
|
||||
duration: Number(data.duration) || 0,
|
||||
volume: Number(data.volume) || 1,
|
||||
playlistIndex: data.playlistIndex,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// 广播状态更新
|
||||
broadcastMusicState(musicState)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '音乐状态已更新'
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'song_change':
|
||||
// 歌曲切换
|
||||
if (data && typeof data === 'object') {
|
||||
const songInfo = {
|
||||
songId: data.songId,
|
||||
title: data.title || '',
|
||||
artist: data.artist || '',
|
||||
cover: data.cover || '',
|
||||
duration: Number(data.duration) || 0,
|
||||
playlistIndex: data.playlistIndex,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// 广播歌曲切换
|
||||
broadcastSongChange(songInfo)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '歌曲切换已广播'
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'position_update':
|
||||
// 播放位置更新
|
||||
if (data && typeof data === 'object') {
|
||||
const positionState = {
|
||||
songId: data.songId,
|
||||
isPlaying: Boolean(data.isPlaying),
|
||||
position: Number(data.position) || 0,
|
||||
duration: Number(data.duration) || 0,
|
||||
volume: Number(data.volume) || 1,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// 广播位置更新
|
||||
broadcastMusicState(positionState)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '播放位置已更新'
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '不支持的操作类型'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的请求数据'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Music state update error:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '服务器内部错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { db, users } from '~/drizzle/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { resolveRequirePasswordChange } from '~~/server/utils/system-settings-helper'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
// 存储WebSocket连接
|
||||
const musicConnections = new Map<string, any>()
|
||||
|
||||
// 音乐状态接口
|
||||
interface MusicState {
|
||||
songId?: number
|
||||
isPlaying: boolean
|
||||
position: number
|
||||
duration: number
|
||||
volume: number
|
||||
playlistIndex?: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 广播音乐状态到所有连接的客户端(改进错误处理)
|
||||
export function broadcastMusicState(state: MusicState) {
|
||||
const message = JSON.stringify({
|
||||
type: 'music_state_update',
|
||||
data: state
|
||||
})
|
||||
|
||||
const deadConnections: string[] = []
|
||||
|
||||
musicConnections.forEach((connection, id) => {
|
||||
try {
|
||||
// 检查连接是否仍然有效
|
||||
if (connection.destroyed || connection.writableEnded) {
|
||||
deadConnections.push(id)
|
||||
return
|
||||
}
|
||||
|
||||
connection.write(`data: ${message}\n\n`)
|
||||
} catch (error) {
|
||||
// 忽略常见的连接错误,避免日志污染
|
||||
if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE' && error.code !== 'ENOTFOUND') {
|
||||
console.error(`Failed to send music state to connection ${id}:`, error.message)
|
||||
}
|
||||
deadConnections.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
// 清理死连接
|
||||
deadConnections.forEach((id) => musicConnections.delete(id))
|
||||
}
|
||||
|
||||
// 发送播放列表更新(改进错误处理)
|
||||
export function broadcastPlaylistUpdate(playlist: any[]) {
|
||||
const message = JSON.stringify({
|
||||
type: 'playlist_update',
|
||||
data: { playlist }
|
||||
})
|
||||
|
||||
const deadConnections: string[] = []
|
||||
|
||||
musicConnections.forEach((connection, id) => {
|
||||
try {
|
||||
if (connection.destroyed || connection.writableEnded) {
|
||||
deadConnections.push(id)
|
||||
return
|
||||
}
|
||||
|
||||
connection.write(`data: ${message}\n\n`)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE' && error.code !== 'ENOTFOUND') {
|
||||
console.error(`Failed to send playlist update to connection ${id}:`, error.message)
|
||||
}
|
||||
deadConnections.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
deadConnections.forEach((id) => musicConnections.delete(id))
|
||||
}
|
||||
|
||||
// 发送歌曲切换通知(改进错误处理)
|
||||
export function broadcastSongChange(songInfo: any) {
|
||||
const message = JSON.stringify({
|
||||
type: 'song_change',
|
||||
data: songInfo
|
||||
})
|
||||
|
||||
const deadConnections: string[] = []
|
||||
|
||||
musicConnections.forEach((connection, id) => {
|
||||
try {
|
||||
if (connection.destroyed || connection.writableEnded) {
|
||||
deadConnections.push(id)
|
||||
return
|
||||
}
|
||||
|
||||
connection.write(`data: ${message}\n\n`)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE' && error.code !== 'ENOTFOUND') {
|
||||
console.error(`Failed to send song change to connection ${id}:`, error.message)
|
||||
}
|
||||
deadConnections.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
deadConnections.forEach((id) => musicConnections.delete(id))
|
||||
}
|
||||
|
||||
// WebSocket事件处理器
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 获取查询参数
|
||||
const query = getQuery(event)
|
||||
const token = query.token as string
|
||||
|
||||
// 验证token(可选,根据需要决定是否需要认证)
|
||||
let userId: number | null = null
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = JWTEnhanced.verifyToken(token)
|
||||
const userResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
status: users.status,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
forcePasswordChange: users.forcePasswordChange,
|
||||
tokenVersion: users.tokenVersion
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, decoded.userId))
|
||||
.limit(1)
|
||||
const user = userResult[0]
|
||||
|
||||
// 失效会话降级为匿名收听,保持公共播放页/信息屏可用;仅对有效登录态执行强制改密门控。
|
||||
if (user && user.status === 'active' && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
|
||||
if (await resolveRequirePasswordChange(user)) {
|
||||
throw createApiError(403, 'AUTH_PASSWORD_CHANGE_REQUIRED', '请先完成密码修改', {
|
||||
requirePasswordChange: true
|
||||
})
|
||||
}
|
||||
userId = user.id
|
||||
} else {
|
||||
console.warn('音乐 SSE 连接会话已失效,降级为匿名连接')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||
throw error
|
||||
}
|
||||
// 令牌无法解码时同样降级匿名,避免残留失效 Cookie 导致重连循环持续报错。
|
||||
console.warn('音乐 SSE 连接令牌无效,降级为匿名连接:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成连接ID
|
||||
const connectionId = `music_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// 设置SSE头
|
||||
const response = event.node.res
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
// 发送连接成功消息
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'connection_established',
|
||||
data: {
|
||||
connectionId,
|
||||
userId,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
|
||||
// 存储连接
|
||||
musicConnections.set(connectionId, response)
|
||||
|
||||
// 监听客户端断开连接(改进错误处理)
|
||||
const cleanup = () => {
|
||||
if (musicConnections.has(connectionId)) {
|
||||
musicConnections.delete(connectionId)
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
response.on('close', cleanup)
|
||||
response.on('error', (error) => {
|
||||
// 忽略常见的连接错误
|
||||
if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') {
|
||||
console.error(`Music WebSocket connection ${connectionId} error:`, error.message)
|
||||
}
|
||||
cleanup()
|
||||
})
|
||||
|
||||
event.node.req.on('close', cleanup)
|
||||
event.node.req.on('error', (error) => {
|
||||
// 忽略常见的连接错误
|
||||
if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') {
|
||||
console.error(`Music WebSocket request ${connectionId} error:`, error.message)
|
||||
}
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// 定期发送心跳(改进错误处理)
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (!musicConnections.has(connectionId)) {
|
||||
clearInterval(heartbeatInterval)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查连接状态
|
||||
if (response.destroyed || response.writableEnded) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'heartbeat',
|
||||
data: { timestamp: Date.now() }
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (error) {
|
||||
// 忽略常见的连接错误
|
||||
if (error.code !== 'ECONNRESET' && error.code !== 'EPIPE') {
|
||||
console.error(`Heartbeat failed for connection ${connectionId}:`, error.message)
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}, 30000) // 每30秒发送一次心跳
|
||||
})
|
||||
Reference in New Issue
Block a user