feat(play): 直链播放默认不走代理及无后缀流透传兼容
1. 将直链播放(directplay)模式默认改为浏览器直连播放。 2. 播放失败UI新增'使用代理播放'按钮,点击后自动记忆域名并使用代理重试。 3. 仅在 localStorage 中记录的失败域名会自动走代理。
This commit is contained in:
@@ -89,8 +89,58 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后端 MIME Sniffing: 防御伪装成 m3u8 的大文件二进制流
|
||||||
|
// 使用白名单策略:只有明确属于文本/m3u8 类型的才放行解析
|
||||||
|
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
const isTextType = (
|
||||||
|
contentType === '' || // 无 Content-Type 时保守放行(后续有内容校验兜底)
|
||||||
|
contentType.includes('application/vnd.apple.mpegurl') || // 标准 m3u8
|
||||||
|
contentType.includes('application/x-mpegurl') || // 兼容 m3u8
|
||||||
|
contentType.includes('audio/mpegurl') || // 兼容 m3u8
|
||||||
|
contentType.includes('text/') || // text/plain 等
|
||||||
|
contentType.includes('application/json') // 部分 API 返回 JSON 格式的错误
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isTextType) {
|
||||||
|
if (source === 'directplay') {
|
||||||
|
console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`);
|
||||||
|
// 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header
|
||||||
|
const newHeaders = new Headers(response.headers);
|
||||||
|
newHeaders.set('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
|
// 如果源站返回了跨站相关的禁止头,尽量移除它们
|
||||||
|
newHeaders.delete('X-Frame-Options');
|
||||||
|
newHeaders.delete('Content-Security-Policy');
|
||||||
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[Proxy-M3U8] 拦截到非文本媒体流 (Content-Type: ${contentType}), 拒绝按文本解析, URL: ${m3u8Url}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
details: `The source returned Content-Type "${contentType}", which is not a text m3u8 playlist.`,
|
||||||
|
fallbackToDirect: true,
|
||||||
|
originalUrl: m3u8Url
|
||||||
|
},
|
||||||
|
{ status: 415, headers: { 'Access-Control-Allow-Origin': '*' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let m3u8Content = await response.text();
|
let m3u8Content = await response.text();
|
||||||
|
|
||||||
|
// 二次内容校验:即使 Content-Type 通过了白名单,检查实际内容是否为有效的 m3u8
|
||||||
|
// 有些服务器返回 text/plain 但实际内容是 HTML 错误页或其他格式
|
||||||
|
const trimmedContent = m3u8Content.trimStart();
|
||||||
|
if (trimmedContent.length > 0 && !trimmedContent.startsWith('#EXTM3U') && !trimmedContent.startsWith('#EXT')) {
|
||||||
|
console.warn(`[Proxy-M3U8] 内容校验失败:响应体不以 #EXTM3U 或 #EXT 开头, 可能非有效 m3u8, URL: ${m3u8Url}`);
|
||||||
|
// 不直接拒绝(可能是不规范但仍可播放的 m3u8),仅打印警告继续处理
|
||||||
|
}
|
||||||
|
|
||||||
// 执行去广告逻辑
|
// 执行去广告逻辑
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
|
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
|
||||||
|
|||||||
+90
-10
@@ -1335,6 +1335,30 @@ function PlayPageClient() {
|
|||||||
'initing' | 'sourceChanging'
|
'initing' | 'sourceChanging'
|
||||||
>('initing');
|
>('initing');
|
||||||
const [videoError, setVideoError] = useState<string | null>(null);
|
const [videoError, setVideoError] = useState<string | null>(null);
|
||||||
|
// 直链播放时 CORS 失败的原始 URL,用于显示"使用代理播放"按钮
|
||||||
|
const [corsFailedUrl, setCorsFailedUrl] = useState<string | null>(null);
|
||||||
|
// 标记当前视频是否已经尝试过代理(防止 415→直连→失败→代理 的无限循环)
|
||||||
|
const proxyAttemptedRef = useRef(false);
|
||||||
|
|
||||||
|
// 直链代理域名记忆:检查某个域名是否需要代理
|
||||||
|
const isDirectplayDomainProxied = (url: string): boolean => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]');
|
||||||
|
return domains.includes(domain);
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
// 将域名记录到代理列表
|
||||||
|
const addDirectplayProxyDomain = (url: string) => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]');
|
||||||
|
if (!domains.includes(domain)) {
|
||||||
|
domains.push(domain);
|
||||||
|
localStorage.setItem('directplay_proxy_domains', JSON.stringify(domains));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
// 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置)
|
// 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置)
|
||||||
const [playerReady, setPlayerReady] = useState(false);
|
const [playerReady, setPlayerReady] = useState(false);
|
||||||
@@ -1424,8 +1448,11 @@ function PlayPageClient() {
|
|||||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
||||||
|
|
||||||
if (currentSource === 'directplay' && isM3u8) {
|
if (currentSource === 'directplay' && isM3u8) {
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
// 仅当 localStorage 记忆了该域名需要代理时才走代理
|
||||||
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
|
if (isDirectplayDomainProxied(episodeUrl)) {
|
||||||
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
|
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
|
||||||
|
}
|
||||||
} else if (sourceProxyMode && isM3u8) {
|
} else if (sourceProxyMode && isM3u8) {
|
||||||
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}`;
|
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}`;
|
||||||
}
|
}
|
||||||
@@ -1487,8 +1514,10 @@ function PlayPageClient() {
|
|||||||
// 对优选源进行测速时也需要考虑代理情况
|
// 对优选源进行测速时也需要考虑代理情况
|
||||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
||||||
if (source.source === 'directplay' && isM3u8) {
|
if (source.source === 'directplay' && isM3u8) {
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
if (isDirectplayDomainProxied(episodeUrl)) {
|
||||||
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
|
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
|
||||||
|
}
|
||||||
} else if (source.proxyMode && isM3u8) {
|
} else if (source.proxyMode && isM3u8) {
|
||||||
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(source.source)}`;
|
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(source.source)}`;
|
||||||
}
|
}
|
||||||
@@ -2193,13 +2222,16 @@ 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 && isM3u8) {
|
} else if (currentSource === 'directplay' && newUrl && isM3u8) {
|
||||||
// 直链播放模式:通过 proxy-m3u8 代理播放,避免 CORS 问题
|
// 直链播放模式:检查 localStorage 是否记录了该域名需要代理
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
if (isDirectplayDomainProxied(newUrl)) {
|
||||||
newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`;
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
console.log('直链播放使用代理模式:', newUrl);
|
newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`;
|
||||||
|
console.log('直链播放(域名已记忆)使用代理模式:', newUrl);
|
||||||
|
} else {
|
||||||
|
console.log('直链播放默认直连模式,不使用代理:', newUrl);
|
||||||
|
}
|
||||||
} else if (!isM3u8) {
|
} else if (!isM3u8) {
|
||||||
console.log('非 m3u8 格式,豁免代理框架,直接播放原始URL:', newUrl);
|
console.log('非 m3u8 格式,豁免代理框架,直接播放原始URL:', newUrl);
|
||||||
// 在豁免代理时也必须确保变量被赋给播放源
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3665,6 +3697,8 @@ function PlayPageClient() {
|
|||||||
setVideoLoadingStage('sourceChanging');
|
setVideoLoadingStage('sourceChanging');
|
||||||
setIsVideoLoading(true);
|
setIsVideoLoading(true);
|
||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
|
setCorsFailedUrl(null);
|
||||||
|
proxyAttemptedRef.current = false;
|
||||||
|
|
||||||
// 记录当前播放进度(仅在同一集数切换时恢复)
|
// 记录当前播放进度(仅在同一集数切换时恢复)
|
||||||
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||||||
@@ -5339,10 +5373,16 @@ function PlayPageClient() {
|
|||||||
setVideoError('访问被拒绝 (403)');
|
setVideoError('访问被拒绝 (403)');
|
||||||
} else if (statusCode === 404) {
|
} else if (statusCode === 404) {
|
||||||
setVideoError('视频不存在 (404)');
|
setVideoError('视频不存在 (404)');
|
||||||
|
} else if (statusCode === 415) {
|
||||||
|
setVideoError('视频格式不兼容 (415)');
|
||||||
} else if (statusCode) {
|
} else if (statusCode) {
|
||||||
setVideoError(`HTTP ${statusCode} 错误`);
|
setVideoError(`HTTP ${statusCode} 错误`);
|
||||||
} else {
|
} else {
|
||||||
// CORS 错误或其他网络错误
|
// CORS 错误或其他网络错误
|
||||||
|
// 如果是直链直连模式(URL 不含代理前缀),记录原始 URL 以便用户一键启用代理
|
||||||
|
if (currentSourceRef.current === 'directplay' && !url.includes('/api/proxy-m3u8') && !url.includes('/api/proxy/vod/m3u8')) {
|
||||||
|
setCorsFailedUrl(url);
|
||||||
|
}
|
||||||
setVideoError('无法访问视频源(可能是跨域限制或访问被拒绝)');
|
setVideoError('无法访问视频源(可能是跨域限制或访问被拒绝)');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -7003,6 +7043,7 @@ function PlayPageClient() {
|
|||||||
// 隐藏换源加载状态
|
// 隐藏换源加载状态
|
||||||
setIsVideoLoading(false);
|
setIsVideoLoading(false);
|
||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
|
setCorsFailedUrl(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听视频时间更新事件,实现跳过片头片尾
|
// 监听视频时间更新事件,实现跳过片头片尾
|
||||||
@@ -7051,9 +7092,26 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
artPlayerRef.current.on('error', (err: any) => {
|
artPlayerRef.current.on('error', (err: any) => {
|
||||||
console.error('播放器错误:', err);
|
console.error('播放器错误:', err);
|
||||||
if (artPlayerRef.current.currentTime > 0) {
|
// 如果已经成功播放过一段时间,忽略后续错误(可能是短暂网络波动)
|
||||||
|
if (artPlayerRef.current && artPlayerRef.current.currentTime > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 原生 <video> 播放失败(非 HLS.js 管理的场景,如无后缀的直链)
|
||||||
|
// 需要触发播放失败 UI,否则会永远卡在"加载中"
|
||||||
|
const currentUrl = artPlayerRef.current?.option?.url || videoUrl;
|
||||||
|
const isUsingHls = currentUrl.includes('/api/proxy-m3u8') || currentUrl.includes('/api/proxy/vod/m3u8') || currentUrl.toLowerCase().includes('.m3u8') || currentUrl.toLowerCase().includes('.m3u');
|
||||||
|
if (!isUsingHls) {
|
||||||
|
// 非 HLS 场景下的原生视频错误,显示错误 UI
|
||||||
|
if (proxyAttemptedRef.current) {
|
||||||
|
// 代理已经尝试过(走了 415→直连 的路径),直连也失败了,不再提供代理按钮
|
||||||
|
setVideoError('视频无法在浏览器中播放(已尝试代理,格式不兼容)');
|
||||||
|
} else if (currentSourceRef.current === 'directplay' && !currentUrl.includes('/api/proxy-m3u8')) {
|
||||||
|
setCorsFailedUrl(currentUrl);
|
||||||
|
setVideoError('视频播放失败(格式不支持或跨域限制)');
|
||||||
|
} else {
|
||||||
|
setVideoError('视频播放失败(格式不支持或跨域限制)');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听视频播放结束事件,自动播放下一集(房员禁用)
|
// 监听视频播放结束事件,自动播放下一集(房员禁用)
|
||||||
@@ -7673,6 +7731,28 @@ function PlayPageClient() {
|
|||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
|
{/* 直链播放 CORS 失败时,显示"使用代理播放"按钮 */}
|
||||||
|
{!proxyAttemptedRef.current && (corsFailedUrl || (isDirectPlay && videoUrl && !videoUrl.includes('/api/proxy-m3u8'))) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const originalUrl = corsFailedUrl || videoUrl;
|
||||||
|
// 记忆域名到 localStorage
|
||||||
|
addDirectplayProxyDomain(originalUrl);
|
||||||
|
// 构建代理 URL
|
||||||
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
|
const proxyUrl = `/api/proxy-m3u8?url=${encodeURIComponent(originalUrl)}&source=directplay${tokenParam}`;
|
||||||
|
// 清除错误状态并重新播放
|
||||||
|
setVideoError(null);
|
||||||
|
setCorsFailedUrl(null);
|
||||||
|
setIsVideoLoading(true);
|
||||||
|
proxyAttemptedRef.current = true;
|
||||||
|
setVideoUrl(proxyUrl);
|
||||||
|
}}
|
||||||
|
className='mt-4 ml-3 px-6 py-2 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-lg hover:from-blue-600 hover:to-indigo-700 transition-all duration-200'
|
||||||
|
>
|
||||||
|
使用代理播放
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -316,6 +316,22 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||||
console.error('HLS错误:', data);
|
console.error('HLS错误:', data);
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
|
const statusCode = data.response?.code || data.response?.status;
|
||||||
|
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
|
||||||
|
if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) {
|
||||||
|
console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过');
|
||||||
|
clearTimeout(timeout);
|
||||||
|
hls.destroy();
|
||||||
|
video.remove();
|
||||||
|
resolve({
|
||||||
|
quality: '原生画质',
|
||||||
|
loadSpeed: '直连',
|
||||||
|
pingTime: 10,
|
||||||
|
bitrate: '未知',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
video.remove();
|
video.remove();
|
||||||
|
|||||||
Reference in New Issue
Block a user