fix(player):修复直链播放m3u8时跨域错误及分片加载失败的问题

This commit is contained in:
Troray
2026-03-10 17:28:48 +08:00
parent cdeddd48c1
commit 3563d80510
3 changed files with 1818 additions and 1790 deletions
+16 -5
View File
@@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
@@ -9,7 +9,7 @@ export const runtime = 'nodejs';
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接 * 用于外部播放器访问,会执行去广告逻辑并处理相对链接
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token> * GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
*/ */
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const m3u8Url = searchParams.get('url'); const m3u8Url = searchParams.get('url');
@@ -38,8 +38,11 @@ export async function GET(request: Request) {
// 优先级:SITE_BASE 环境变量 > 从请求头构建 // 优先级:SITE_BASE 环境变量 > 从请求头构建
let origin = process.env.SITE_BASE; let origin = process.env.SITE_BASE;
if (!origin) { if (!origin) {
const requestUrl = new URL(request.url); // 从请求头中获取 Host 和协议
origin = `${requestUrl.protocol}//${requestUrl.host}`; const host = request.headers.get('host') || request.headers.get('x-forwarded-host');
const proto = request.headers.get('x-forwarded-proto') ||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
origin = `${proto}://${host}`;
} }
// 获取原始 m3u8 内容 // 获取原始 m3u8 内容
@@ -196,11 +199,16 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
} else { } else {
keyUri = new URL(keyUri, baseDir).href; keyUri = new URL(keyUri, baseDir).href;
} }
}
// 直链播放模式:通过代理访问密钥,避免 CORS 问题
if (source === 'directplay') {
keyUri = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(keyUri)}&source=directplay`;
}
// 替换原来的 URI // 替换原来的 URI
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`); line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
} }
}
resolvedLines.push(line); resolvedLines.push(line);
continue; continue;
} }
@@ -240,6 +248,9 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
if (isM3u8) { if (isM3u8) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''; const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`; url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
} else if (source === 'directplay') {
// 直链播放模式:通过代理访问媒体分片(ts/jpeg/png 等),避免 CORS 问题
url = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(url)}&source=directplay`;
} }
resolvedLines.push(url); resolvedLines.push(url);
+3
View File
@@ -19,6 +19,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Missing source' }, { status: 400 }); return NextResponse.json({ error: 'Missing source' }, { status: 400 });
} }
// 直链播放模式:跳过源站配置检查,直接代理
if (source !== 'directplay') {
// 检查该视频源是否启用了代理模式 // 检查该视频源是否启用了代理模式
const config = await getConfig(); const config = await getConfig();
const videoSource = config.SourceConfig?.find((s: any) => s.key === source); const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
@@ -30,6 +32,7 @@ export async function GET(request: Request) {
if (!videoSource.proxyMode) { if (!videoSource.proxyMode) {
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 }); return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
} }
}
let response: Response | null = null; let response: Response | null = null;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null; let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
+40 -26
View File
@@ -1571,10 +1571,8 @@ function PlayPageClient() {
console.log('播放源评分排序结果:'); console.log('播放源评分排序结果:');
resultsWithScore.forEach((result, index) => { resultsWithScore.forEach((result, index) => {
console.log( console.log(
`${index + 1}. ${ `${index + 1}. ${result.source.source_name
result.source.source_name } - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${result.testResult.loadSpeed
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
result.testResult.loadSpeed
}, ${result.testResult.pingTime}ms)` }, ${result.testResult.pingTime}ms)`
); );
}); });
@@ -2171,6 +2169,11 @@ function PlayPageClient() {
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放 // 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`; newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`;
console.log('使用代理模式播放:', newUrl); console.log('使用代理模式播放:', newUrl);
} else if (currentSource === 'directplay' && newUrl) {
// 直链播放模式:通过 proxy-m3u8 代理播放,避免 CORS 问题
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`;
console.log('直链播放使用代理模式:', newUrl);
} }
} }
@@ -5041,8 +5044,29 @@ function PlayPageClient() {
return false; return false;
})(); })();
// 辅助函数:检测代理 URL 是否需要显式声明 m3u8 类型
// Artplayer 通过 URL 扩展名自动检测类型,但代理 URL(如 /api/proxy-m3u8?url=...)没有 .m3u8 扩展名
const getVideoType = (url: string): string | undefined => {
if (!url) return undefined;
// 如果 URL 路径中已包含 .m3u8 扩展名,Artplayer 可自动检测,无需显式设置
const urlPath = url.split('?')[0];
if (urlPath.includes('.m3u8')) return undefined;
// 代理 URL 返回的是 m3u8 内容,需要显式声明类型
if (url.includes('/api/proxy-m3u8') || url.includes('/api/proxy/vod/m3u8')) {
return 'm3u8';
}
return undefined;
};
// 非WebKit浏览器且播放器已存在,使用switch方法切换 // 非WebKit浏览器且播放器已存在,使用switch方法切换
if (!isWebkit && artPlayerRef.current) { if (!isWebkit && artPlayerRef.current) {
// 显式设置类型,确保代理 URL 能被 HLS.js 正确处理
const videoType = getVideoType(videoUrl);
if (videoType) {
artPlayerRef.current.option.type = videoType;
} else {
artPlayerRef.current.option.type = '';
}
artPlayerRef.current.switch = videoUrl; artPlayerRef.current.switch = videoUrl;
artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`; artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
artPlayerRef.current.poster = videoCover; artPlayerRef.current.poster = videoCover;
@@ -5104,6 +5128,7 @@ function PlayPageClient() {
artPlayerRef.current = new Artplayer({ artPlayerRef.current = new Artplayer({
container: artRef.current!, container: artRef.current!,
url: videoUrl, url: videoUrl,
...(getVideoType(videoUrl) ? { type: getVideoType(videoUrl) } : {}),
poster: videoCover, poster: videoCover,
volume: 0.7, volume: 0.7,
isLive: false, isLive: false,
@@ -7224,8 +7249,7 @@ function PlayPageClient() {
<div className='mb-6 w-80 mx-auto'> <div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'> <div className='flex justify-center space-x-2 mb-4'>
<div <div
className={`w-3 h-3 rounded-full transition-all duration-500 ${ className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'searching' || loadingStage === 'fetching'
loadingStage === 'searching' || loadingStage === 'fetching'
? 'bg-green-500 scale-125' ? 'bg-green-500 scale-125'
: loadingStage === 'preferring' || : loadingStage === 'preferring' ||
loadingStage === 'ready' loadingStage === 'ready'
@@ -7234,8 +7258,7 @@ function PlayPageClient() {
}`} }`}
></div> ></div>
<div <div
className={`w-3 h-3 rounded-full transition-all duration-500 ${ className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'preferring'
loadingStage === 'preferring'
? 'bg-green-500 scale-125' ? 'bg-green-500 scale-125'
: loadingStage === 'ready' : loadingStage === 'ready'
? 'bg-green-500' ? 'bg-green-500'
@@ -7243,8 +7266,7 @@ function PlayPageClient() {
}`} }`}
></div> ></div>
<div <div
className={`w-3 h-3 rounded-full transition-all duration-500 ${ className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready'
loadingStage === 'ready'
? 'bg-green-500 scale-125' ? 'bg-green-500 scale-125'
: 'bg-gray-300' : 'bg-gray-300'
}`} }`}
@@ -7519,8 +7541,7 @@ function PlayPageClient() {
return ( return (
<span <span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status === 'completed'
status === 'completed'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`} }`}
@@ -7545,8 +7566,7 @@ function PlayPageClient() {
} }
> >
<svg <svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${ className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
}`} }`}
fill='none' fill='none'
stroke='currentColor' stroke='currentColor'
@@ -7565,8 +7585,7 @@ function PlayPageClient() {
{/* 精致的状态指示点 */} {/* 精致的状态指示点 */}
<div <div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${ className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isEpisodeSelectorCollapsed
isEpisodeSelectorCollapsed
? 'bg-orange-400 animate-pulse' ? 'bg-orange-400 animate-pulse'
: 'bg-green-400' : 'bg-green-400'
}`} }`}
@@ -7575,16 +7594,14 @@ function PlayPageClient() {
</div> </div>
<div <div
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${ className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
isEpisodeSelectorCollapsed
? 'grid-cols-1' ? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-4'
}`} }`}
> >
{/* 播放器 */} {/* 播放器 */}
<div <div
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${ className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
}`} }`}
> >
{/* 播放器容器 */} {/* 播放器容器 */}
@@ -8034,8 +8051,7 @@ function PlayPageClient() {
{/* 去广告开关 */} {/* 去广告开关 */}
<button <button
onClick={() => setExternalPlayerAdBlock(!externalPlayerAdBlock)} onClick={() => setExternalPlayerAdBlock(!externalPlayerAdBlock)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${externalPlayerAdBlock
externalPlayerAdBlock
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400' ? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400'
: 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600' : 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600'
}`} }`}
@@ -8075,8 +8091,7 @@ function PlayPageClient() {
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */} {/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
<div <div
className={`relative z-10 h-[350px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${ className={`relative z-10 h-[350px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
isEpisodeSelectorCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95' ? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100' : 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`} }`}
@@ -8273,8 +8288,7 @@ function PlayPageClient() {
)} )}
{detail?.source_name && ( {detail?.source_name && (
<span <span
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${ className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
}`} }`}
onClick={fetchCurrentSourceVideoInfo} onClick={fetchCurrentSourceVideoInfo}
> >