From e08e953047c892a12e79b2fe9cc268283155c04b Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 21:12:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=90=86kuwo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/music/proxy/route.ts | 108 +++++++++++++++++++++++++ src/app/api/music/route.ts | 84 ++++++++++++++++--- src/app/music/page.tsx | 134 +++++++++++++++++++++++-------- 3 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 src/app/api/music/proxy/route.ts diff --git a/src/app/api/music/proxy/route.ts b/src/app/api/music/proxy/route.ts new file mode 100644 index 0000000..0235c18 --- /dev/null +++ b/src/app/api/music/proxy/route.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +// 代理音频流 +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json( + { error: '缺少 url 参数' }, + { status: 400 } + ); + } + + // 安全检查:只允许代理音乐平台的音频和图片 CDN + const allowedDomains = [ + 'sycdn.kuwo.cn', + 'kwcdn.kuwo.cn', + 'img1.kwcdn.kuwo.cn', + 'img2.kwcdn.kuwo.cn', + 'img3.kwcdn.kuwo.cn', + 'img4.kwcdn.kuwo.cn', + 'music.163.com', + 'y.qq.com', + 'ws.stream.qqmusic.qq.com', + 'isure.stream.qqmusic.qq.com', + 'dl.stream.qqmusic.qq.com', + ]; + + let urlObj: URL; + try { + urlObj = new URL(url); + } catch { + return NextResponse.json( + { error: '无效的 URL' }, + { status: 400 } + ); + } + + const isAllowed = allowedDomains.some(domain => + urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`) + ); + + if (!isAllowed) { + console.warn(`拒绝代理音频请求: ${urlObj.hostname}`); + return NextResponse.json( + { error: '不允许的目标域名' }, + { status: 403 } + ); + } + + // 发起请求获取音频流 + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'http://www.kuwo.cn/', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: '获取音频失败' }, + { status: response.status } + ); + } + + // 获取响应头 + const contentType = response.headers.get('content-type') || 'audio/mpeg'; + const contentLength = response.headers.get('content-length'); + + // 创建响应头 + const headers: Record = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + }; + + if (contentLength) { + headers['Content-Length'] = contentLength; + } + + // 支持 Range 请求(用于音频拖动) + const range = request.headers.get('range'); + if (range) { + headers['Accept-Ranges'] = 'bytes'; + } + + // 返回音频流 + return new NextResponse(response.body, { + status: response.status, + headers, + }); + } catch (error) { + console.error('代理音频失败:', error); + return NextResponse.json( + { + error: '代理请求失败', + details: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/music/route.ts b/src/app/api/music/route.ts index 8848470..18f5035 100644 --- a/src/app/api/music/route.ts +++ b/src/app/api/music/route.ts @@ -79,17 +79,56 @@ async function executeMethod( let url = config.url; const params: Record = {}; + // 先将 variables 中的值转换为可执行的变量 + const evalContext: Record = {}; + for (const [key, value] of Object.entries(variables)) { + // 尝试将字符串转换为数字(如果可能) + const numValue = Number(value); + evalContext[key] = isNaN(numValue) ? value : numValue; + } + + // 递归处理对象中的模板变量 + function processTemplateValue(value: any): any { + if (typeof value === 'string') { + // 处理包含模板变量的表达式 + const expressionRegex = /\{\{(.+?)\}\}/g; + return value.replace(expressionRegex, (match, expression) => { + try { + // 创建一个函数来执行表达式,传入所有变量作为参数 + // eslint-disable-next-line no-new-func + const func = new Function(...Object.keys(evalContext), `return ${expression}`); + const result = func(...Object.values(evalContext)); + return String(result); + } catch (err) { + console.error(`[executeMethod] 执行表达式失败: ${expression}`, err); + return '0'; // 默认值 + } + }); + } else if (Array.isArray(value)) { + return value.map(item => processTemplateValue(item)); + } else if (typeof value === 'object' && value !== null) { + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = processTemplateValue(v); + } + return result; + } + return value; + } + + // 处理 URL 参数 if (config.params) { for (const [key, value] of Object.entries(config.params)) { - let processedValue = String(value); - // 替换所有模板变量 - for (const [varName, varValue] of Object.entries(variables)) { - processedValue = processedValue.replace(`{{${varName}}}`, varValue); - } - params[key] = processedValue; + params[key] = processTemplateValue(value); } } + // 处理 POST body + let processedBody = config.body; + if (config.body) { + processedBody = processTemplateValue(config.body); + } + // 3. 构建完整 URL if (config.method === 'GET' && Object.keys(params).length > 0) { const urlObj = new URL(url); @@ -105,8 +144,8 @@ async function executeMethod( headers: config.headers || {}, }; - if (config.method === 'POST' && config.body) { - requestOptions.body = JSON.stringify(config.body); + if (config.method === 'POST' && processedBody) { + requestOptions.body = JSON.stringify(processedBody); requestOptions.headers = { ...requestOptions.headers, 'Content-Type': 'application/json', @@ -123,10 +162,31 @@ async function executeMethod( const transformFn = eval(`(${config.transform})`); data = transformFn(data); } catch (err) { - console.error('Transform 函数执行失败:', err); + console.error('[executeMethod] Transform 函数执行失败:', err); } } + // 6. 处理酷我音乐的图片 URL(转换为代理 URL) + if (platform === 'kuwo') { + const processKuwoImages = (obj: any): any => { + if (typeof obj === 'string' && obj.startsWith('http://') && obj.includes('kwcdn.kuwo.cn')) { + // 将 HTTP 图片 URL 转换为代理 URL + return `/api/music/proxy?url=${encodeURIComponent(obj)}`; + } else if (Array.isArray(obj)) { + return obj.map(item => processKuwoImages(item)); + } else if (typeof obj === 'object' && obj !== null) { + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = processKuwoImages(value); + } + return result; + } + return obj; + }; + + data = processKuwoImages(data); + } + return data; } @@ -231,7 +291,7 @@ export async function GET(request: NextRequest) { // 搜索歌曲 const platform = searchParams.get('platform'); const keyword = searchParams.get('keyword'); - const page = searchParams.get('page') || '0'; + const page = searchParams.get('page') || '1'; const pageSize = searchParams.get('pageSize') || '20'; if (!platform || !keyword) { @@ -248,11 +308,15 @@ export async function GET(request: NextRequest) { return NextResponse.json(cached.data); } + // 注意:不同平台可能使用不同的变量名 + // 统一传递 keyword, page, pageSize, limit (limit = pageSize) const data = await executeMethod(baseUrl, platform, 'search', { keyword, page, pageSize, + limit: pageSize, // 有些平台使用 limit 而不是 pageSize }); + serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() }); return NextResponse.json(data); diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 2cf397f..295cdc5 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -15,6 +15,7 @@ interface Song { artist: string; album?: string; pic?: string; + platform?: 'netease' | 'qq' | 'kuwo'; // 添加平台信息 } interface PlayRecord { @@ -70,6 +71,20 @@ export default function MusicPage() { const lastSaveTimeRef = useRef(0); const restoredTimeRef = useRef(0); + // 工具函数:处理图片 URL(在 HTTPS 环境下代理 HTTP 图片) + const processImageUrl = (url: string | undefined, platform: string): string | undefined => { + if (!url) return url; + + const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'; + + // 只对酷我音乐的 HTTP 图片在 HTTPS 环境下进行代理 + if (platform === 'kuwo' && isHttps && url.startsWith('http://')) { + return `/api/music/proxy?url=${encodeURIComponent(url)}`; + } + + return url; + }; + // 保存播放状态到 localStorage const savePlayState = () => { if (!currentSong) return; @@ -96,7 +111,7 @@ export default function MusicPage() { }; // 从 localStorage 恢复播放状态 - const restorePlayState = () => { + const restorePlayState = async () => { try { const saved = localStorage.getItem('musicPlayState'); if (!saved) return; @@ -112,7 +127,6 @@ export default function MusicPage() { setQuality(playState.quality || '320k'); setPlayMode(playState.playMode || 'loop'); setVolume(playState.volume || 100); - setCurrentSongUrl(playState.currentSongUrl || ''); setLyrics(playState.lyrics || []); setPlayRecords(playState.playRecords || []); setPlaylist(playState.playlist || []); // 恢复播放列表 @@ -121,29 +135,65 @@ export default function MusicPage() { // 保存需要恢复的时间点 restoredTimeRef.current = playState.currentTime || 0; - if (playState.currentSong && playState.currentSongUrl) { + if (playState.currentSong) { setShowPlayer(true); - // 延迟设置音频源,等待 audio 元素加载 - setTimeout(() => { - if (audioRef.current && playState.currentSongUrl) { - audioRef.current.src = playState.currentSongUrl; - // 监听多个事件以确保进度恢复 - const restoreTime = () => { - if (audioRef.current && restoredTimeRef.current > 0) { - audioRef.current.currentTime = restoredTimeRef.current; - restoredTimeRef.current = 0; + // 获取歌曲的平台信息 + const platform = playState.currentSong.platform || playState.currentSource || 'netease'; + + // 重新解析歌曲获取新的播放链接 + try { + const response = await fetch('/api/music', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'parse', + platform: platform, + ids: playState.currentSong.id, + quality: playState.quality || '320k', + }), + }); + + const data = await response.json(); + + if (data.code === 0 && data.data?.data && data.data.data.length > 0) { + const songData = data.data.data[0]; + + if (songData.url && songData.success) { + // 对于酷我音乐,使用代理 + let playUrl = songData.url; + if (platform === 'kuwo') { + playUrl = `/api/music/proxy?url=${encodeURIComponent(songData.url)}`; } - }; - // 监听加载完成事件 - audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true }); - audioRef.current.addEventListener('canplay', restoreTime, { once: true }); + setCurrentSongUrl(songData.url); - // 调用 load() 触发音频加载 - audioRef.current.load(); + // 延迟设置音频源,等待 audio 元素加载 + setTimeout(() => { + if (audioRef.current) { + audioRef.current.src = playUrl; + + // 监听多个事件以确保进度恢复 + const restoreTime = () => { + if (audioRef.current && restoredTimeRef.current > 0) { + audioRef.current.currentTime = restoredTimeRef.current; + restoredTimeRef.current = 0; + } + }; + + // 监听加载完成事件 + audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true }); + audioRef.current.addEventListener('canplay', restoreTime, { once: true }); + + // 调用 load() 触发音频加载 + audioRef.current.load(); + } + }, 100); + } } - }, 100); + } catch (error) { + console.error('重新解析歌曲失败:', error); + } } } catch (error) { console.error('恢复播放状态失败:', error); @@ -179,6 +229,7 @@ export default function MusicPage() { artist: record.artist, album: record.album, pic: record.pic, + platform: record.platform, // 添加平台信息 }); }); @@ -210,9 +261,11 @@ export default function MusicPage() { `/api/music?action=toplists&platform=${source}` ); const data = await response.json(); - setPlaylists(data || []); + // 确保返回的是数组 + setPlaylists(Array.isArray(data) ? data : []); } catch (error) { console.error('加载排行榜失败:', error); + setPlaylists([]); } finally { setLoading(false); } @@ -226,11 +279,13 @@ export default function MusicPage() { `/api/music?action=toplist&platform=${currentSource}&id=${playlistId}` ); const data = await response.json(); - setSongs(data || []); + // 确保返回的是数组 + setSongs(Array.isArray(data) ? data : []); setCurrentPlaylistTitle(playlistName); setCurrentView('songs'); } catch (error) { console.error('加载歌单失败:', error); + setSongs([]); } finally { setLoading(false); } @@ -246,11 +301,13 @@ export default function MusicPage() { `/api/music?action=search&platform=${currentSource}&keyword=${encodeURIComponent(searchKeyword)}&page=1&pageSize=20` ); const data = await response.json(); - setSongs(data || []); + // 确保返回的是数组 + setSongs(Array.isArray(data) ? data : []); setCurrentPlaylistTitle(`搜索: ${searchKeyword}`); setCurrentView('songs'); } catch (error) { console.error('搜索失败:', error); + setSongs([]); } finally { setLoading(false); } @@ -259,6 +316,9 @@ export default function MusicPage() { // 播放歌曲 const playSong = async (song: Song, index: number) => { try { + // 使用歌曲自己的平台信息,如果没有则使用当前选择的平台 + const platform = song.platform || currentSource; + // 先设置当前歌曲和显示播放器 setCurrentSong(song); setCurrentSongIndex(index); @@ -267,7 +327,7 @@ export default function MusicPage() { // 添加到播放记录和播放列表 const record: PlayRecord = { - platform: currentSource, + platform: platform, id: song.id, playTime: 0, // 初始播放时间 duration: 0, // 将在音频加载后更新 @@ -294,11 +354,11 @@ export default function MusicPage() { }); setPlaylist(prev => { - const existingIndex = prev.findIndex(s => s.id === song.id); + const existingIndex = prev.findIndex(s => s.id === song.id && s.platform === platform); if (existingIndex >= 0) { return prev; } else { - return [...prev, song]; + return [...prev, { ...song, platform }]; } }); @@ -308,39 +368,49 @@ export default function MusicPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'parse', - platform: currentSource, + platform: platform, // 使用歌曲的平台 ids: song.id, quality: quality, }), }); const data = await response.json(); - console.log('解析返回数据:', data); // TuneHub 返回格式: { code: 0, data: { data: [...] } } if (data.code === 0 && data.data?.data && data.data.data.length > 0) { const songData = data.data.data[0]; if (songData.url && songData.success) { + // 处理封面图片(在 HTTPS 环境下代理 HTTP 图片) + const coverUrl = processImageUrl(songData.cover, platform); + // 更新歌曲信息,包括封面 - if (songData.cover) { + if (coverUrl) { setCurrentSong({ ...song, - pic: songData.cover, + pic: coverUrl, + platform, }); } - // 解析歌词 - 字段名是 lyrics + // 解析歌词 if (songData.lyrics) { const parsedLyrics = parseLyric(songData.lyrics); setLyrics(parsedLyrics); } - // 保存歌曲URL用于下载 + // 保存原始 URL 用于下载 setCurrentSongUrl(songData.url); + // 对于酷我音乐,使用代理 + let playUrl = songData.url; + if (platform === 'kuwo') { + playUrl = `/api/music/proxy?url=${encodeURIComponent(songData.url)}`; + } + if (audioRef.current) { - audioRef.current.src = songData.url; + audioRef.current.src = playUrl; + audioRef.current.load(); audioRef.current.play().catch(err => { console.error('播放失败:', err); });