diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 66b9097..b57d3d3 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1645,6 +1645,16 @@ function PlayPageClient() { return true; }); + const [preferStrategy] = useState<'fast' | 'full'>(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('preferStrategy'); + if (saved === 'fast' || saved === 'full') { + return saved; + } + } + return 'fast'; + }); + // 保存优选时的测速结果,避免EpisodeSelector重复测速 const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState< Map @@ -1860,145 +1870,217 @@ function PlayPageClient() { ): Promise => { if (sources.length === 1) return sources[0]; - // 将播放源均分为两批,并发测速各批,避免一次性过多请求 - const batchSize = Math.ceil(sources.length / 2); - const allResults: Array<{ + type SourceTestResult = { source: SearchResult; testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string }; - } | null> = []; + }; + type MaybeSourceTestResult = SourceTestResult | null; - for (let start = 0; start < sources.length; start += batchSize) { - const batchSources = sources.slice(start, start + batchSize); - const batchResults = await Promise.all( - batchSources.map(async (source) => { - try { - // 检查是否有第一集的播放地址 - if (!source.episodes || source.episodes.length === 0) { - console.warn(`播放源 ${source.source_name} 没有可用的播放地址`); - return null; - } + const sortedByWeight = [...sources].sort((a, b) => { + const weightA = a.weight ?? 0; + const weightB = b.weight ?? 0; + return weightB - weightA; + }); - let episodeUrl = - source.episodes.length > 1 - ? source.episodes[1] - : 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); - - return { - source, - testResult, - }; - } catch (error) { - return null; - } - }) - ); - allResults.push(...batchResults); - } - - // 等待所有测速完成,包含成功和失败的结果 - // 保存所有测速结果到 precomputedVideoInfo,供 EpisodeSelector 使用(包含错误结果) - const newVideoInfoMap = new Map< - string, - { - quality: string; - loadSpeed: string; - pingTime: number; - bitrate: string; - hasError?: boolean; - } - >(); - allResults.forEach((result, index) => { - const source = sources[index]; - const sourceKey = `${source.source}-${source.id}`; - - if (result) { - // 成功的结果 + const finalizeSelection = ( + completedResults: MaybeSourceTestResult[] + ): SearchResult => { + const newVideoInfoMap = new Map< + string, + { + quality: string; + loadSpeed: string; + pingTime: number; + bitrate: string; + } + >(); + completedResults.forEach((result) => { + if (!result) return; + const sourceKey = `${result.source.source}-${result.source.id}`; newVideoInfoMap.set(sourceKey, result.testResult); - } - }); - - // 过滤出成功的结果用于优选计算 - const successfulResults = allResults.filter(Boolean) as Array<{ - source: SearchResult; - testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string }; - }>; - - setPrecomputedVideoInfo(newVideoInfoMap); - - // 如果所有测速都失败,仍然按权重排序返回 - if (successfulResults.length === 0) { - console.warn('所有播放源测速都失败,按权重排序'); - const sortedByWeight = [...sources].sort((a, b) => { - const weightA = a.weight ?? 0; - const weightB = b.weight ?? 0; - return weightB - weightA; }); - return sortedByWeight[0]; + setPrecomputedVideoInfo(newVideoInfoMap); + + const successfulResults = completedResults.filter( + Boolean + ) as SourceTestResult[]; + + if (successfulResults.length === 0) { + console.warn('所有播放源测速都失败,按权重排序'); + return sortedByWeight[0]; + } + + const validSpeeds = successfulResults + .map((result) => { + const speedStr = result.testResult.loadSpeed; + if (speedStr === '未知' || speedStr === '测量中...') return 0; + + const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2]; + return unit === 'MB/s' ? value * 1024 : value; + }) + .filter((speed) => speed > 0); + + const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; + + const validPings = successfulResults + .map((result) => result.testResult.pingTime) + .filter((ping) => ping > 0); + + const minPing = validPings.length > 0 ? Math.min(...validPings) : 50; + const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000; + + const resultsWithScore = successfulResults.map((result) => ({ + ...result, + score: calculateSourceScore( + result.testResult, + maxSpeed, + minPing, + maxPing, + result.source.weight ?? 0 + ), + })); + + resultsWithScore.sort((a, b) => b.score - a.score); + + console.log('播放源评分排序结果:'); + resultsWithScore.forEach((result, index) => { + console.log( + `${index + 1}. ${result.source.source_name + } - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${result.testResult.loadSpeed + }, ${result.testResult.pingTime}ms)` + ); + }); + + return resultsWithScore[0].source; + }; + + const testSingleSource = async ( + source: SearchResult + ): Promise => { + try { + if (!source.episodes || source.episodes.length === 0) { + console.warn(`播放源 ${source.source_name} 没有可用的播放地址`); + return null; + } + + let episodeUrl = + source.episodes.length > 1 + ? source.episodes[1] + : 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); + + return { + source, + testResult, + }; + } catch (error) { + return null; + } + }; + + const maxConcurrency = Math.ceil(sources.length / 2); + + const runAllWithSameConcurrency = async (): Promise => { + const results: MaybeSourceTestResult[] = new Array(sources.length); + let nextIndex = 0; + + const worker = async () => { + while (nextIndex < sources.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await testSingleSource(sources[currentIndex]); + } + }; + + await Promise.all( + Array.from({ length: Math.min(maxConcurrency, sources.length) }, () => + worker() + ) + ); + + return results; + }; + + if (preferStrategy === 'full' || sortedByWeight.length < 5) { + const allResults = await runAllWithSameConcurrency(); + return finalizeSelection(allResults); } - // 找出所有有效速度的最大值,用于线性映射 - const validSpeeds = successfulResults - .map((result) => { - const speedStr = result.testResult.loadSpeed; - if (speedStr === '未知' || speedStr === '测量中...') return 0; + const topPriorityKeys = new Set( + sortedByWeight + .slice(0, 5) + .map((source) => `${source.source}-${source.id}`) + ); - const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/); - if (!match) return 0; + const quickResults = await new Promise((resolve) => { + const results: Array = new Array(sources.length); + let nextIndex = 0; + let activeCount = 0; + let completedCount = 0; + let topCompletedCount = 0; + let topSuccessCount = 0; + let settled = false; - const value = parseFloat(match[1]); - const unit = match[2]; - return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s - }) - .filter((speed) => speed > 0); + const maybeResolve = () => { + if (settled) return; - const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准 + if (topCompletedCount === 5 && topSuccessCount > 0) { + settled = true; + resolve( + results.filter((result) => result !== undefined) as MaybeSourceTestResult[] + ); + return; + } - // 找出所有有效延迟的最小值和最大值,用于线性映射 - const validPings = successfulResults - .map((result) => result.testResult.pingTime) - .filter((ping) => ping > 0); + if (completedCount === sources.length) { + settled = true; + resolve(results as MaybeSourceTestResult[]); + return; + } - const minPing = validPings.length > 0 ? Math.min(...validPings) : 50; - const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000; + while (!settled && activeCount < maxConcurrency && nextIndex < sources.length) { + const currentIndex = nextIndex++; + const currentSource = sources[currentIndex]; + const sourceKey = `${currentSource.source}-${currentSource.id}`; + activeCount += 1; - // 计算每个结果的评分 - const resultsWithScore = successfulResults.map((result) => ({ - ...result, - score: calculateSourceScore( - result.testResult, - maxSpeed, - minPing, - maxPing, - result.source.weight ?? 0 - ), - })); + testSingleSource(currentSource) + .then((result) => { + results[currentIndex] = result; + completedCount += 1; - // 按综合评分排序,选择最佳播放源 - resultsWithScore.sort((a, b) => b.score - a.score); + if (topPriorityKeys.has(sourceKey)) { + topCompletedCount += 1; + if (result) { + topSuccessCount += 1; + } + } + }) + .finally(() => { + activeCount -= 1; + maybeResolve(); + }); + } + }; - console.log('播放源评分排序结果:'); - resultsWithScore.forEach((result, index) => { - console.log( - `${index + 1}. ${result.source.source_name - } - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${result.testResult.loadSpeed - }, ${result.testResult.pingTime}ms)` - ); + maybeResolve(); }); - return resultsWithScore[0].source; + return finalizeSelection(quickResults); }; // 计算播放源综合评分 diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 26ebca9..ec5a33a 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -117,6 +117,7 @@ export const UserMenu: React.FC = () => { const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true); const [doubanProxyUrl, setDoubanProxyUrl] = useState(''); const [enableOptimization, setEnableOptimization] = useState(true); + const [preferStrategy, setPreferStrategy] = useState<'fast' | 'full'>('fast'); const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒) const [fluidSearch, setFluidSearch] = useState(true); const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false); @@ -534,6 +535,11 @@ export const UserMenu: React.FC = () => { setEnableOptimization(JSON.parse(savedEnableOptimization)); } + const savedPreferStrategy = localStorage.getItem('preferStrategy'); + if (savedPreferStrategy === 'fast' || savedPreferStrategy === 'full') { + setPreferStrategy(savedPreferStrategy); + } + const savedSpeedTestTimeout = localStorage.getItem('speedTestTimeout'); if (savedSpeedTestTimeout !== null) { setSpeedTestTimeout(Number(savedSpeedTestTimeout)); @@ -1008,6 +1014,13 @@ export const UserMenu: React.FC = () => { } }; + const handlePreferStrategyChange = (value: 'fast' | 'full') => { + setPreferStrategy(value); + if (typeof window !== 'undefined') { + localStorage.setItem('preferStrategy', value); + } + }; + const handleSpeedTestTimeoutChange = (value: number) => { setSpeedTestTimeout(value); if (typeof window !== 'undefined') { @@ -1356,6 +1369,7 @@ export const UserMenu: React.FC = () => { setDefaultAggregateSearch(true); setEnableOptimization(true); + setPreferStrategy('fast'); setFluidSearch(defaultFluidSearch); setTmdbBackdropDisabled(false); setEnableTrailers(false); @@ -1384,6 +1398,7 @@ export const UserMenu: React.FC = () => { if (typeof window !== 'undefined') { localStorage.setItem('defaultAggregateSearch', JSON.stringify(true)); localStorage.setItem('enableOptimization', JSON.stringify(true)); + localStorage.setItem('preferStrategy', 'fast'); localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch)); localStorage.setItem('liveDirectConnect', JSON.stringify(false)); localStorage.setItem('tmdb_backdrop_disabled', 'false'); @@ -2243,6 +2258,38 @@ export const UserMenu: React.FC = () => { {/* 测速超时设置 */} {enableOptimization && (
+
+
+ + 优选策略 + +
+ + +
+
+
+
换源面板测速超时