From ef433ba4df5291bac1befe74c78bde878b0b1aeb Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 28 Apr 2026 00:14:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=9F=B3=E4=B9=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/music/v2/stream/route.ts | 181 ++++++++++++++++++++++----- 1 file changed, 147 insertions(+), 34 deletions(-) diff --git a/src/app/api/music/v2/stream/route.ts b/src/app/api/music/v2/stream/route.ts index bc54414..64e984d 100644 --- a/src/app/api/music/v2/stream/route.ts +++ b/src/app/api/music/v2/stream/route.ts @@ -5,6 +5,124 @@ import { badRequest } from '@/lib/music-v2-api'; export const runtime = 'nodejs'; +const STREAM_URL_CACHE_TTL_MS = 10 * 60 * 1000; + +type StreamUrlCacheValue = { + url: string; + expiresAt: number; +}; + +const globalMusicStreamCache = globalThis as typeof globalThis & { + __musicV2StreamUrlCache?: Map; + __musicV2StreamUrlInflight?: Map>; +}; + +const streamUrlCache = globalMusicStreamCache.__musicV2StreamUrlCache ?? new Map(); +const streamUrlInflight = globalMusicStreamCache.__musicV2StreamUrlInflight ?? new Map>(); + +globalMusicStreamCache.__musicV2StreamUrlCache = streamUrlCache; +globalMusicStreamCache.__musicV2StreamUrlInflight = streamUrlInflight; + +function getStreamCacheKey(song: ReturnType, quality: string) { + return `${song.source}:${song.songId}:${quality}`; +} + +function getCachedStreamUrl(cacheKey: string) { + const cached = streamUrlCache.get(cacheKey); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + streamUrlCache.delete(cacheKey); + return null; + } + return cached.url; +} + +function setCachedStreamUrl(cacheKey: string, url: string) { + streamUrlCache.set(cacheKey, { + url, + expiresAt: Date.now() + STREAM_URL_CACHE_TTL_MS, + }); +} + +async function resolveStreamUrl( + song: ReturnType, + quality: string, + cacheKey: string +) { + const cachedUrl = getCachedStreamUrl(cacheKey); + if (cachedUrl) return cachedUrl; + + const inflight = streamUrlInflight.get(cacheKey); + if (inflight) return inflight; + + const resolverPromise = (async () => { + const urlResult = await lxPostJson<{ url?: string; error?: string }>( + '/api/music/url', + { + songInfo: { + id: song.songId, + name: song.name, + singer: song.artist, + source: song.source, + songmid: extractSongmid(song), + hash: song.hash, + interval: song.durationText, + copyrightId: song.copyrightId, + albumId: song.albumId, + lrcUrl: song.lrcUrl, + mrcUrl: song.mrcUrl, + trcUrl: song.trcUrl, + }, + quality, + }, + 'auto' + ); + + const upstreamUrl = urlResult?.url; + if (!upstreamUrl) { + throw new Error(urlResult?.error || '获取音频流失败'); + } + + setCachedStreamUrl(cacheKey, upstreamUrl); + return upstreamUrl; + })(); + + streamUrlInflight.set(cacheKey, resolverPromise); + + try { + return await resolverPromise; + } finally { + streamUrlInflight.delete(cacheKey); + } +} + +function buildUpstreamHeaders(request: NextRequest, upstreamUrl: string) { + const headers = new Headers(); + headers.set( + 'User-Agent', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ); + + const range = request.headers.get('range'); + if (range) headers.set('Range', range); + + const url = new URL(upstreamUrl); + const hostname = url.hostname.toLowerCase(); + + if (hostname.includes('kuwo.cn')) { + headers.set('Referer', 'http://www.kuwo.cn/'); + headers.set('Origin', 'http://www.kuwo.cn'); + } else if (hostname.includes('qqmusic.qq.com') || hostname === 'y.qq.com') { + headers.set('Referer', 'https://y.qq.com/'); + headers.set('Origin', 'https://y.qq.com'); + } else if (hostname.includes('music.163.com')) { + headers.set('Referer', 'https://music.163.com/'); + headers.set('Origin', 'https://music.163.com'); + } + + return headers; +} + export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); @@ -30,44 +148,39 @@ export async function GET(request: NextRequest) { trcUrl: searchParams.get('trcUrl') || undefined, }); - const urlResult = await lxPostJson<{ url?: string; error?: string }>( - '/api/music/url', - { - songInfo: { - id: song.songId, - name: song.name, - singer: song.artist, - source: song.source, - songmid: extractSongmid(song), - hash: song.hash, - interval: song.durationText, - copyrightId: song.copyrightId, - albumId: song.albumId, - lrcUrl: song.lrcUrl, - mrcUrl: song.mrcUrl, - trcUrl: song.trcUrl, - }, - quality, - }, - 'auto' - ); + const cacheKey = getStreamCacheKey(song, quality); + let upstreamUrl = await resolveStreamUrl(song, quality, cacheKey); + let upstreamHeaders = buildUpstreamHeaders(request, upstreamUrl); - const upstreamUrl = urlResult?.url; - if (!upstreamUrl) { - return NextResponse.json({ success: false, error: { code: 'STREAM_FAILED', message: urlResult?.error || '获取音频流失败' } }, { status: 502 }); + let upstream: Response; + try { + upstream = await fetch(upstreamUrl, { + headers: upstreamHeaders, + signal: AbortSignal.timeout(30000), + cache: 'no-store', + }); + } catch (error) { + streamUrlCache.delete(cacheKey); + upstreamUrl = await resolveStreamUrl(song, quality, cacheKey); + upstreamHeaders = buildUpstreamHeaders(request, upstreamUrl); + upstream = await fetch(upstreamUrl, { + headers: upstreamHeaders, + signal: AbortSignal.timeout(30000), + cache: 'no-store', + }); + console.warn('[music-v2/stream] upstream fetch failed once, retried with refreshed URL', { + source, + songId, + quality, + range: request.headers.get('range'), + reason: (error as Error).message, + }); } - const headers = new Headers(); - headers.set('User-Agent', 'Mozilla/5.0'); - const range = request.headers.get('range'); - if (range) headers.set('Range', range); - - const upstream = await fetch(upstreamUrl, { - headers, - signal: AbortSignal.timeout(30000), - }); - if (!upstream.ok && upstream.status !== 206) { + if (upstream.status === 401 || upstream.status === 403 || upstream.status === 404) { + streamUrlCache.delete(cacheKey); + } return NextResponse.json({ success: false, error: { code: 'STREAM_FAILED', message: '获取音频流失败' } }, { status: upstream.status }); }