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;
+49 -35
View File
@@ -2,7 +2,7 @@
'use client'; 'use client';
import { AlertCircle,Cloud, Heart, Sparkles, X } from 'lucide-react'; import { AlertCircle, Cloud, Heart, Sparkles, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
@@ -29,7 +29,7 @@ import {
saveDanmakuSourceIndex, saveDanmakuSourceIndex,
saveManualDanmakuSelection, saveManualDanmakuSelection,
} from '@/lib/danmaku/selection-memory'; } from '@/lib/danmaku/selection-memory';
import type { DanmakuAnime, DanmakuComment,DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; import type { DanmakuAnime, DanmakuComment, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
import { import {
deleteFavorite, deleteFavorite,
deletePlayRecord, deletePlayRecord,
@@ -47,7 +47,7 @@ import {
} from '@/lib/db.client'; } from '@/lib/db.client';
import { getDoubanDetail } from '@/lib/douban.client'; import { getDoubanDetail } from '@/lib/douban.client';
import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { getTMDBImageUrl } from '@/lib/tmdb.search';
import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types';
import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import { useEnableComments } from '@/hooks/useEnableComments'; import { useEnableComments } from '@/hooks/useEnableComments';
import { usePlaySync } from '@/hooks/usePlaySync'; import { usePlaySync } from '@/hooks/usePlaySync';
@@ -1255,7 +1255,7 @@ function PlayPageClient() {
const [videoUrl, setVideoUrl] = useState(''); const [videoUrl, setVideoUrl] = useState('');
// 视频清晰度列表 // 视频清晰度列表
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]); const [videoQualities, setVideoQualities] = useState<Array<{ name: string, url: string }>>([]);
// Xiaoya链接刷新相关状态 // Xiaoya链接刷新相关状态
const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接 const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接
@@ -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,
@@ -5725,7 +5750,7 @@ function PlayPageClient() {
style: { style: {
color: '#fff', color: '#fff',
}, },
mounted: function($el: HTMLElement) { mounted: function ($el: HTMLElement) {
// 添加 CSS 样式:横屏和竖屏都显示 // 添加 CSS 样式:横屏和竖屏都显示
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
@@ -6802,7 +6827,7 @@ function PlayPageClient() {
// 监听弹幕插件的配置变化 // 监听弹幕插件的配置变化
if (danmakuPluginRef.current) { if (danmakuPluginRef.current) {
const originalConfig = danmakuPluginRef.current.config; const originalConfig = danmakuPluginRef.current.config;
danmakuPluginRef.current.config = function(...args: any[]) { danmakuPluginRef.current.config = function (...args: any[]) {
const result = originalConfig.apply(this, args); const result = originalConfig.apply(this, args);
setTimeout(updateHeatmapData, 100); setTimeout(updateHeatmapData, 100);
return result; return result;
@@ -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'
}`} }`}
> >
{/* 播放器容器 */} {/* 播放器容器 */}
@@ -7675,8 +7692,8 @@ function PlayPageClient() {
<div className='absolute inset-0 flex items-center justify-center bg-black/50 z-50 pointer-events-none'> <div className='absolute inset-0 flex items-center justify-center bg-black/50 z-50 pointer-events-none'>
<div className='bg-black/80 text-white px-6 py-3 rounded-lg flex items-center gap-3 backdrop-blur-sm border border-green-500/30'> <div className='bg-black/80 text-white px-6 py-3 rounded-lg flex items-center gap-3 backdrop-blur-sm border border-green-500/30'>
<svg className='animate-spin h-5 w-5' viewBox='0 0 24 24'> <svg className='animate-spin h-5 w-5' viewBox='0 0 24 24'>
<circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' fill='none'/> <circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' fill='none' />
<path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'/> <path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z' />
</svg> </svg>
<span>...</span> <span>...</span>
</div> </div>
@@ -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}
> >
@@ -8373,7 +8387,7 @@ function PlayPageClient() {
<div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'> <div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'> <h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'> <svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/> <path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z' />
</svg> </svg>
</h3> </h3>