diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index cebf3ab..1889a12 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -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(); + // 二次内容校验:即使 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 customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || ''; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 9d4043d..ceda03e 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1335,6 +1335,30 @@ function PlayPageClient() { 'initing' | 'sourceChanging' >('initing'); const [videoError, setVideoError] = useState(null); + // 直链播放时 CORS 失败的原始 URL,用于显示"使用代理播放"按钮 + const [corsFailedUrl, setCorsFailedUrl] = useState(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 的事件监听器设置) 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)(\?.*)?$/); if (currentSource === 'directplay' && isM3u8) { - const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; - episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`; + // 仅当 localStorage 记忆了该域名需要代理时才走代理 + if (isDirectplayDomainProxied(episodeUrl)) { + const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; + episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`; + } } else if (sourceProxyMode && isM3u8) { 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)(\?.*)?$/); if (source.source === 'directplay' && isM3u8) { - const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; - 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 (source.proxyMode && isM3u8) { 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)}`; console.log('使用代理模式播放:', newUrl); } else if (currentSource === 'directplay' && newUrl && isM3u8) { - // 直链播放模式:通过 proxy-m3u8 代理播放,避免 CORS 问题 - const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; - newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`; - console.log('直链播放使用代理模式:', newUrl); + // 直链播放模式:检查 localStorage 是否记录了该域名需要代理 + if (isDirectplayDomainProxied(newUrl)) { + const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; + newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`; + console.log('直链播放(域名已记忆)使用代理模式:', newUrl); + } else { + console.log('直链播放默认直连模式,不使用代理:', newUrl); + } } else if (!isM3u8) { console.log('非 m3u8 格式,豁免代理框架,直接播放原始URL:', newUrl); - // 在豁免代理时也必须确保变量被赋给播放源 } } } @@ -3665,6 +3697,8 @@ function PlayPageClient() { setVideoLoadingStage('sourceChanging'); setIsVideoLoading(true); setVideoError(null); + setCorsFailedUrl(null); + proxyAttemptedRef.current = false; // 记录当前播放进度(仅在同一集数切换时恢复) const currentPlayTime = artPlayerRef.current?.currentTime || 0; @@ -5339,10 +5373,16 @@ function PlayPageClient() { setVideoError('访问被拒绝 (403)'); } else if (statusCode === 404) { setVideoError('视频不存在 (404)'); + } else if (statusCode === 415) { + setVideoError('视频格式不兼容 (415)'); } else if (statusCode) { setVideoError(`HTTP ${statusCode} 错误`); } else { // CORS 错误或其他网络错误 + // 如果是直链直连模式(URL 不含代理前缀),记录原始 URL 以便用户一键启用代理 + if (currentSourceRef.current === 'directplay' && !url.includes('/api/proxy-m3u8') && !url.includes('/api/proxy/vod/m3u8')) { + setCorsFailedUrl(url); + } setVideoError('无法访问视频源(可能是跨域限制或访问被拒绝)'); } return; @@ -7003,6 +7043,7 @@ function PlayPageClient() { // 隐藏换源加载状态 setIsVideoLoading(false); setVideoError(null); + setCorsFailedUrl(null); }); // 监听视频时间更新事件,实现跳过片头片尾 @@ -7051,9 +7092,26 @@ function PlayPageClient() { artPlayerRef.current.on('error', (err: any) => { console.error('播放器错误:', err); - if (artPlayerRef.current.currentTime > 0) { + // 如果已经成功播放过一段时间,忽略后续错误(可能是短暂网络波动) + if (artPlayerRef.current && artPlayerRef.current.currentTime > 0) { return; } + // 原生