初次提交
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { getQuery, createError } from 'h3'
|
||||
import axios from 'axios'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const url = query.url as string
|
||||
|
||||
if (!url || !url.includes('kugou.com')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid Kugou URL'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取重定向后的最终URL或HTML内容
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
|
||||
}
|
||||
})
|
||||
|
||||
// 很多时候链接会跳转,或者HTML里面有 hash 变量
|
||||
const html = response.data
|
||||
// 尝试正则匹配 "hash":"xxx" 或者 hash: 'xxx'
|
||||
const hashMatch = html.match(/"hash"\s*:\s*"([a-zA-Z0-9]+)"/i) || html.match(/hash\s*=\s*"([a-zA-Z0-9]+)"/i) || html.match(/"320hash"\s*:\s*"([a-zA-Z0-9]+)"/i)
|
||||
const albumMatch = html.match(/"album_id"\s*:\s*(\d+)/i)
|
||||
|
||||
if (hashMatch && hashMatch[1]) {
|
||||
const hash = hashMatch[1]
|
||||
// 用 hash 再次查询歌曲详情或者直接返回
|
||||
// 我们可以调用移动端 track info API
|
||||
const infoUrl = `http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`
|
||||
const infoRes = await axios.get(infoUrl, { validateStatus: () => true })
|
||||
|
||||
if (infoRes.data && infoRes.data.songName) {
|
||||
return {
|
||||
id: hash,
|
||||
title: infoRes.data.songName,
|
||||
artist: infoRes.data.singerName,
|
||||
cover: infoRes.data.imgUrl ? infoRes.data.imgUrl.replace('{size}', '150') : '',
|
||||
duration: infoRes.data.timeLength * 1000,
|
||||
musicPlatform: 'kugou',
|
||||
musicId: hash,
|
||||
hasUrl: true,
|
||||
isVip: infoRes.data.privilege >= 10,
|
||||
sourceInfo: {
|
||||
source: 'kugou',
|
||||
originalId: hash,
|
||||
fetchedAt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 getSongInfo 失败,我们只返回 hash
|
||||
return {
|
||||
id: hash,
|
||||
title: "解析成功,请点击投稿",
|
||||
artist: "未知歌手",
|
||||
musicPlatform: 'kugou',
|
||||
musicId: hash,
|
||||
hasUrl: true,
|
||||
sourceInfo: {
|
||||
source: 'kugou',
|
||||
originalId: hash,
|
||||
fetchedAt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Cannot find song hash in the provided link'
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || 'Failed to parse link'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getQuery, createError } from 'h3'
|
||||
import axios from 'axios'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const hash = query.hash as string
|
||||
|
||||
if (!hash) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Hash is required'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 首先尝试通过移动端API获取播放链接
|
||||
const infoUrl = `http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`
|
||||
const infoRes = await axios.get(infoUrl, { validateStatus: () => true })
|
||||
|
||||
if (infoRes.data && infoRes.data.url) {
|
||||
return {
|
||||
data: {
|
||||
play_url: infoRes.data.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果失败或者需要付费(VIP),通过标题和歌手名从网易云匹配一个试听链接
|
||||
if (infoRes.data && infoRes.data.songName) {
|
||||
const keyword = `${infoRes.data.songName} ${infoRes.data.singerName || ''}`.trim()
|
||||
|
||||
try {
|
||||
// 搜索网易云音乐
|
||||
const searchUrl = `https://music.163.com/api/search/get/web?s=${encodeURIComponent(keyword)}&type=1&offset=0&total=true&limit=1`
|
||||
const searchRes = await axios.get(searchUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
if (searchRes.data?.result?.songs?.length > 0) {
|
||||
const songId = searchRes.data.result.songs[0].id
|
||||
// 获取网易云播放链接
|
||||
const neteaseUrl = `https://music.163.com/song/media/outer/url?id=${songId}.mp3`
|
||||
return {
|
||||
data: {
|
||||
play_url: neteaseUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fallback to Netease failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有,尝试使用原本的 API
|
||||
const url = `http://www.kugou.com/yy/index.php?r=play/getdata&hash=${hash}`
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Cookie': 'kg_mid=1234567890'
|
||||
},
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Music service unavailable'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import axios from 'axios'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const keyword = query.keyword || query.keywords
|
||||
|
||||
if (!keyword) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Keyword is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 我们在这里使用不需要签名的通用接口或移动端旧接口
|
||||
|
||||
try {
|
||||
const url = `http://mobilecdn.kugou.com/api/v3/search/song?format=json&keyword=${encodeURIComponent(keyword as string)}&page=1&pagesize=20&showtype=1`
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
// 允许处理不严谨的 SSL 证书或忽略部分请求错误
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
const data = response.data
|
||||
|
||||
// Process results to map cover images and VIP status correctly
|
||||
if (data && data.data && data.data.info) {
|
||||
data.data.info = data.data.info.map((item: any) => {
|
||||
return {
|
||||
...item,
|
||||
isVip: item.pay_type !== 0,
|
||||
// Extract proper image URL from 'union_cover', '320' size or hash, and use https
|
||||
pic: item.trans_param?.union_cover
|
||||
? item.trans_param.union_cover.replace('{size}', '400').replace('http://', 'https://')
|
||||
: item['320hash']
|
||||
? `https://imge.kugou.com/stdmusic/400/${item['320hash'].slice(0,2)}/${item['320hash'].slice(2,4)}/${item['320hash']}.jpg`
|
||||
: item['hash']
|
||||
? `https://imge.kugou.com/stdmusic/400/${item['hash'].slice(0,2)}/${item['hash'].slice(2,4)}/${item['hash']}.jpg`
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Music service unavailable'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
statusMessage: '无效的请求数据'
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
statusMessage: '不支持的操作类型'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: '无效的请求数据'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Music state update error:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: '服务器内部错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
// 存储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 {
|
||||
if (process.env.JWT_SECRET) {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET) as { userId: number }
|
||||
userId = decoded.userId
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid token for music WebSocket connection, proceeding as anonymous:', error)
|
||||
// 不抛出错误,允许匿名连接
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// 生成连接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