Merge branch 'pr-213' into dev

This commit is contained in:
mtvpls
2026-03-12 20:10:41 +08:00
9 changed files with 2277 additions and 1889 deletions
+102 -7
View File
@@ -1,15 +1,18 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const maxDuration = 60; // 设置最大执行时间为 60 秒
/** /**
* M3U8 代理接口 * M3U8 代理接口
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接 * 用于外部播放器访问,会执行去广告逻辑并处理相对链接
* 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');
@@ -34,12 +37,40 @@ export async function GET(request: Request) {
); );
} }
const DIRECT_PLAY_SOURCE = 'directplay';
// 安全校验:防 SSRF / 域名重绑定,只允许合法的公网 URL。对所有经过 proxy-m3u8 的请求强制校验,不仅限于 directplay
const isSafeUrl = await validateProxyUrlServerSide(m3u8Url);
if (!isSafeUrl) {
return NextResponse.json(
{ error: 'Proxy request to local or invalid network is forbidden' },
{ status: 403 }
);
}
// 获取当前请求的 origin // 获取当前请求的 origin
// 优先级: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}`; let host = request.headers.get('host') || request.headers.get('x-forwarded-host');
// 安全校验:防 Host 头注入漏洞 (要求仅包含合法域名或 IP 格式字符)
if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) {
host = null;
}
// Fallback:如果以上 Header 无效或未提供,回退到 request.url 获取
if (!host) {
try {
host = new URL(request.url).host;
} catch {
return NextResponse.json({ error: 'Invalid Request Host' }, { status: 400 });
}
}
const proto = request.headers.get('x-forwarded-proto') ||
(host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https');
origin = `${proto}://${host}`;
} }
// 获取原始 m3u8 内容 // 获取原始 m3u8 内容
@@ -61,8 +92,58 @@ export async function GET(request: Request) {
); );
} }
// 后端 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 === DIRECT_PLAY_SOURCE) {
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 || '';
@@ -111,7 +192,10 @@ export async function GET(request: Request) {
} }
/** /**
* 默认去广告规则 * 默认去广告规则(服务端版本)
* 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。
* 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。
* 两套逻辑需要保持同步更新。
*/ */
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string { function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
if (!m3u8Content) return ''; if (!m3u8Content) return '';
@@ -167,7 +251,10 @@ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
} }
/** /**
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接 * 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接
* 此函数仅在代理模式下由服务端调用。
* - 子 m3u8 链接 → 指向 /api/proxy-m3u8(递归代理)
* - ts 分片/密钥 → directplay 模式指向 /api/proxy/vod/segment(解决 CORS
*/ */
function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string { function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string {
const lines = m3u8Content.split('\n'); const lines = m3u8Content.split('\n');
@@ -196,11 +283,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 +332,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);
+12 -6
View File
@@ -3,6 +3,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config"; import { getConfig } from "@/lib/config";
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -33,6 +35,13 @@ export async function GET(request: Request) {
try { try {
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
// 安全校验:防 SSRF 拦截请求内网或非法 URL
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
const response = await fetch(decodedUrl, { const response = await fetch(decodedUrl, {
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@@ -44,12 +53,9 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 }); return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
} }
const headers = new Headers(); const headers = buildProxyStreamHeaders(
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream'); response.headers.get('Content-Type') || 'application/octet-stream'
headers.set('Access-Control-Allow-Origin', '*'); );
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return new Response(response.body, { headers }); return new Response(response.body, { headers });
} catch (error) { } catch (error) {
+12 -13
View File
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config"; import { getConfig } from "@/lib/config";
import { getBaseUrl, resolveUrl } from "@/lib/live"; import { getBaseUrl, resolveUrl } from "@/lib/live";
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { buildProxyM3u8Headers, buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -38,6 +40,12 @@ export async function GET(request: Request) {
try { try {
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
// 安全校验:防 SSRF 拦截请求内网或非法 URL
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
response = await fetch(decodedUrl, { response = await fetch(decodedUrl, {
cache: 'no-cache', cache: 'no-cache',
redirect: 'follow', redirect: 'follow',
@@ -66,23 +74,14 @@ export async function GET(request: Request) {
// 重写 M3U8 内容 // 重写 M3U8 内容
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source); const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
const headers = new Headers(); const headers = buildProxyM3u8Headers(contentType || undefined);
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return new Response(modifiedContent, { headers }); return new Response(modifiedContent, { headers });
} }
// just proxy // just proxy
const headers = new Headers(); const headers = buildProxyStreamHeaders(
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'); response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'
headers.set('Access-Control-Allow-Origin', '*'); );
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache'); headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
// 直接返回视频流 // 直接返回视频流
return new Response(response.body, { return new Response(response.body, {
+24 -14
View File
@@ -1,8 +1,10 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ /* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server"; import { NextResponse } from 'next/server';
import { getConfig } from "@/lib/config"; import { getConfig } from '@/lib/config';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -19,6 +21,11 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Missing source' }, { status: 400 }); return NextResponse.json({ error: 'Missing source' }, { status: 400 });
} }
// 定义直链播放模式常量
const DIRECT_PLAY_SOURCE = 'directplay';
// 直链播放模式:跳过源站配置检查,直接代理
if (source !== DIRECT_PLAY_SOURCE) {
// 检查该视频源是否启用了代理模式 // 检查该视频源是否启用了代理模式
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,12 +37,20 @@ 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;
try { try {
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
// 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求)
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
response = await fetch(decodedUrl, { response = await fetch(decodedUrl, {
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@@ -46,19 +61,14 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 }); return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
} }
const headers = new Headers(); const headers = buildProxyStreamHeaders(
headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t'); response.headers.get('Content-Type') || 'video/mp2t',
headers.set('Access-Control-Allow-Origin', '*'); response.headers.get('content-length')
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); );
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
const contentLength = response.headers.get('content-length');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
// 使用流式传输,避免占用内存 // 使用流式传输,避免占用内存
let isCancelled = false;
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
if (!response?.body) { if (!response?.body) {
@@ -67,7 +77,6 @@ export async function GET(request: Request) {
} }
reader = response.body.getReader(); reader = response.body.getReader();
const isCancelled = false;
function pump() { function pump() {
if (isCancelled || !reader) { if (isCancelled || !reader) {
@@ -109,6 +118,7 @@ export async function GET(request: Request) {
pump(); pump();
}, },
cancel() { cancel() {
isCancelled = true;
// 当流被取消时,确保释放所有资源 // 当流被取消时,确保释放所有资源
if (reader) { if (reader) {
try { try {
+7
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -11,6 +12,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Missing video URL' }, { status: 400 }); return NextResponse.json({ error: 'Missing video URL' }, { status: 400 });
} }
// 安全校验:防 SSRF,只允许合法的公网 URL
const isSafeUrl = await validateProxyUrlServerSide(videoUrl);
if (!isSafeUrl) {
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
}
try { try {
// 获取客户端的Range请求头 // 获取客户端的Range请求头
const range = request.headers.get('range'); const range = request.headers.get('range');
+158 -30
View File
@@ -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);
@@ -1415,11 +1439,31 @@ function PlayPageClient() {
} }
// 获取当前集数的播放地址 // 获取当前集数的播放地址
const episodeUrl = detail.episodes[currentEpisodeIndex]; let episodeUrl = detail.episodes[currentEpisodeIndex];
if (!episodeUrl) { if (!episodeUrl) {
return; return;
} }
// 简单的正则或者后缀判断,如果明确不是 m3u8 (比如 mp4),则不走 m3u8 代理
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
if (currentSource === 'directplay' && isM3u8) {
// 仅当 localStorage 记忆了该域名需要代理时才走代理
if (isDirectplayDomainProxied(episodeUrl)) {
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
} else {
// 直链模式且未走代理:跳过 HLS.js 探测。
// getVideoResolutionFromM3u8 内部使用 HLS.js (XMLHttpRequest) 加载,
// 而 XHR 受 CORS 限制,探测必然失败。实际播放器通过 <video src> 加载不受 CORS 影响。
console.log('[视频信息] 直链直连模式,跳过分辨率探测(避免 CORS 误报)');
setCurrentSourceVideoInfo(null);
return;
}
} else if (sourceProxyMode && isM3u8) {
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}`;
}
try { try {
const info = await getVideoResolutionFromM3u8(episodeUrl, 4000); const info = await getVideoResolutionFromM3u8(episodeUrl, 4000);
setCurrentSourceVideoInfo(info); setCurrentSourceVideoInfo(info);
@@ -1469,10 +1513,22 @@ function PlayPageClient() {
return null; return null;
} }
const episodeUrl = let episodeUrl =
source.episodes.length > 1 source.episodes.length > 1
? source.episodes[1] ? source.episodes[1]
: source.episodes[0]; : source.episodes[0];
// 对优选源进行测速时也需要考虑代理情况
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
if (source.source === 'directplay' && isM3u8) {
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)}`;
}
const testResult = await getVideoResolutionFromM3u8(episodeUrl); const testResult = await getVideoResolutionFromM3u8(episodeUrl);
return { return {
@@ -1571,10 +1627,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)`
); );
}); });
@@ -2167,10 +2221,25 @@ function PlayPageClient() {
// 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别 // 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别
newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`; newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`;
console.log('使用服务器端本地下载文件播放:', newUrl); console.log('使用服务器端本地下载文件播放:', newUrl);
} else if (sourceProxyMode && newUrl) { } else {
const isM3u8 = newUrl.toLowerCase().includes('.m3u') || !newUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
if (sourceProxyMode && newUrl && isM3u8) {
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放 // 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
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) {
// 直链播放模式:检查 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);
}
} }
} }
@@ -3635,6 +3704,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;
@@ -5041,8 +5112,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 +5196,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,
@@ -5287,10 +5380,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;
@@ -6951,6 +7050,7 @@ function PlayPageClient() {
// 隐藏换源加载状态 // 隐藏换源加载状态
setIsVideoLoading(false); setIsVideoLoading(false);
setVideoError(null); setVideoError(null);
setCorsFailedUrl(null);
}); });
// 监听视频时间更新事件,实现跳过片头片尾 // 监听视频时间更新事件,实现跳过片头片尾
@@ -6999,9 +7099,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('视频播放失败(格式不支持或跨域限制)');
}
}
}); });
// 监听视频播放结束事件,自动播放下一集(房员禁用) // 监听视频播放结束事件,自动播放下一集(房员禁用)
@@ -7224,8 +7341,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 +7350,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 +7358,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 +7633,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 +7658,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 +7677,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 +7686,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'
}`} }`}
> >
{/* 播放器容器 */} {/* 播放器容器 */}
@@ -7629,6 +7738,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>
</> </>
) : ( ) : (
@@ -8034,8 +8165,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 +8205,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 +8402,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}
> >
+39
View File
@@ -0,0 +1,39 @@
/**
* 代理接口共享工具函数
* 用于构建标准化的 CORS 响应头,避免各代理路由中的重复代码。
*/
/**
* 构建标准的代理流式响应头 (用于 ts 分片、密钥、二进制流等)
* 包含 CORS、Accept-Ranges 和 Content-Length 等标准头。
*/
export function buildProxyStreamHeaders(
contentType: string,
contentLength?: string | null
): Headers {
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
return headers;
}
/**
* 构建标准的代理 M3U8 播放列表响应头
*/
export function buildProxyM3u8Headers(contentType?: string): Headers {
const headers = new Headers();
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return headers;
}
+94
View File
@@ -0,0 +1,94 @@
import dns from 'dns';
/**
* 判断 IP 地址是否为内网/本地私有地址
* 覆盖 IPv4 和 IPv6,彻底杜绝所有变体绕过。
*/
export function isPrivateIP(ip: string): boolean {
// IPv4 私有地址和环回地址
if (ip.includes('.')) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4) return false;
return (
parts[0] === 10 || // 10.x.x.x
parts[0] === 127 || // 127.x.x.x (Loopback)
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.x.x - 172.31.x.x
(parts[0] === 192 && parts[1] === 168) || // 192.168.x.x
(parts[0] === 169 && parts[1] === 254) || // 169.254.x.x (Link-local)
parts[0] === 0 // 0.x.x.x ("This network")
);
}
// IPv6 私有地址和环回地址
if (ip.includes(':')) {
// ::1 环回地址 (Loopback)
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true;
// 0::0 / :: 未指定地址
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
const lowerIp = ip.toLowerCase();
// IPv4 映射到 IPv6 的地址 (例如 ::ffff:127.0.0.1)
if (lowerIp.startsWith('::ffff:')) {
return isPrivateIP(lowerIp.substring(7));
}
// 唯一本地地址 (Unique Local Addresses, fc00::/7)
if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true;
// 链路本地地址 (Link-Local Addresses, fe80::/10)
if (lowerIp.startsWith('fe8') || lowerIp.startsWith('fe9') || lowerIp.startsWith('fea') || lowerIp.startsWith('feb')) return true;
}
return false;
}
/**
* 校验代理 URL 是否安全 (防止 SSRF / DNS 重绑定漏洞)
* 只在 Node.js 服务端运行。
*/
export async function validateProxyUrlServerSide(urlStr: string): Promise<boolean> {
if (!urlStr) return false;
try {
const parsed = new URL(urlStr);
// 1. 协议检查
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
// 2. 剥离认证信息防止混淆
if (parsed.username || parsed.password) {
return false;
}
let { hostname } = parsed;
// 清洗 IPv6 括号边界
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.substring(1, hostname.length - 1);
}
// 3. DNS 真实解析 (获取底层物理 IP)
// 这一步能彻底打碎各种形式的短格式 IP (127.1)、八/十六进制 IP (0x7f.0.0.1)、或者指向 127.0.0.1 的恶意外部域名 DNS 重绑定。
const lookupResult = await dns.promises.lookup(hostname);
if (!lookupResult || !lookupResult.address) {
return false; // 解析不出 IP 则拒绝
}
// 4. 对物理 IP 进行内网校验
if (isPrivateIP(lookupResult.address)) {
console.warn(`[SSRF 防护] 拦截到尝试访问内部网络的请求 URL: ${urlStr} (解析出的底层 IP: ${lookupResult.address})`);
return false;
}
return true;
} catch (error) {
// 凡是报错(无论是 URL 解析失败,还是 DNS 解析失败,还是域名不存在),均作为不安全拒绝
console.warn(`[SSRF 防护] URL解析失败或不合法, 拒绝代理请求: ${urlStr}`);
return false;
}
}
+65 -55
View File
@@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string {
*/ */
export async function getVideoResolutionFromM3u8( export async function getVideoResolutionFromM3u8(
m3u8Url: string, m3u8Url: string,
timeoutMs = 4000 timeoutMs = 6000
): Promise<{ ): Promise<{
quality: string; // 如720p、1080p等 quality: string; // 如720p、1080p等
loadSpeed: string; // 自动转换为KB/s或MB/s loadSpeed: string; // 自动转换为KB/s或MB/s
@@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8(
// 固定使用hls.js加载 // 固定使用hls.js加载
const hls = new Hls(); const hls = new Hls();
// 设置超时处理 - 使用传入的超时时间 let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let estimatedBitrate = 0; // 估算的码率(bps
// 提取核心返回逻辑供 resolve 和 timeout 共同调用
const resolveCurrentState = () => {
const width = video.videoWidth;
const quality =
width >= 3840
? '4K'
: width >= 2560
? '2K'
: width >= 1920
? '1080p'
: width >= 1280
? '720p'
: width >= 854
? '480p'
: width > 0
? 'SD'
: '未知';
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
hls.destroy();
video.remove();
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
};
// 设置超时处理 - 如果部分数据已拿到,则宽容返回
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (hasMetadataLoaded || hasSpeedCalculated) {
resolveCurrentState();
} else {
hls.destroy(); hls.destroy();
video.remove(); video.remove();
reject(new Error('Timeout loading video metadata')); reject(new Error('Timeout loading video metadata'));
}
}, timeoutMs); }, timeoutMs);
video.onerror = () => { video.onerror = () => {
@@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8(
reject(new Error('Failed to load video metadata')); reject(new Error('Failed to load video metadata'));
}; };
let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let estimatedBitrate = 0; // 估算的码率(bps
let fragmentStartTime = 0; let fragmentStartTime = 0;
// 检查是否可以返回结果 // 检查是否可以相互满足要求
const checkAndResolve = () => { const checkAndResolve = () => {
if ( if (
hasMetadataLoaded && hasMetadataLoaded &&
(hasSpeedCalculated || actualLoadSpeed !== '未知') (hasSpeedCalculated || actualLoadSpeed !== '未知')
) { ) {
clearTimeout(timeout); clearTimeout(timeout);
const width = video.videoWidth; resolveCurrentState();
if (width && width > 0) {
hls.destroy();
video.remove();
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
const quality =
width >= 3840
? '4K' // 4K: 3840x2160
: width >= 2560
? '2K' // 2K: 2560x1440
: width >= 1920
? '1080p' // 1080p: 1920x1080
: width >= 1280
? '720p' // 720p: 1280x720
: width >= 854
? '480p'
: 'SD'; // 480p: 854x480
// 格式化码率
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
} else {
// webkit 无法获取尺寸,直接返回
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality: '未知',
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
}
} }
}; };
@@ -323,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();
@@ -397,3 +406,4 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer // 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8'); return Buffer.from(bytes).toString('utf-8');
} }