增加快速优选模式

This commit is contained in:
mtvpls
2026-04-28 16:08:09 +08:00
parent 46d53cd6ae
commit 6d9afd8072
2 changed files with 250 additions and 121 deletions
+203 -121
View File
@@ -1645,6 +1645,16 @@ function PlayPageClient() {
return true; 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重复测速 // 保存优选时的测速结果,避免EpisodeSelector重复测速
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState< const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
Map<string, { quality: string; loadSpeed: string; pingTime: number; bitrate: string }> Map<string, { quality: string; loadSpeed: string; pingTime: number; bitrate: string }>
@@ -1860,145 +1870,217 @@ function PlayPageClient() {
): Promise<SearchResult> => { ): Promise<SearchResult> => {
if (sources.length === 1) return sources[0]; if (sources.length === 1) return sources[0];
// 将播放源均分为两批,并发测速各批,避免一次性过多请求 type SourceTestResult = {
const batchSize = Math.ceil(sources.length / 2);
const allResults: Array<{
source: SearchResult; source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string }; testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string };
} | null> = []; };
type MaybeSourceTestResult = SourceTestResult | null;
for (let start = 0; start < sources.length; start += batchSize) { const sortedByWeight = [...sources].sort((a, b) => {
const batchSources = sources.slice(start, start + batchSize); const weightA = a.weight ?? 0;
const batchResults = await Promise.all( const weightB = b.weight ?? 0;
batchSources.map(async (source) => { return weightB - weightA;
try { });
// 检查是否有第一集的播放地址
if (!source.episodes || source.episodes.length === 0) {
console.warn(`播放源 ${source.source_name} 没有可用的播放地址`);
return null;
}
let episodeUrl = const finalizeSelection = (
source.episodes.length > 1 completedResults: MaybeSourceTestResult[]
? source.episodes[1] ): SearchResult => {
: source.episodes[0]; const newVideoInfoMap = new Map<
string,
// 对优选源进行测速时也需要考虑代理情况 {
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/); quality: string;
if (source.source === 'directplay' && isM3u8) { loadSpeed: string;
if (isDirectplayDomainProxied(episodeUrl)) { pingTime: number;
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; bitrate: string;
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`; }
} >();
} else if (source.proxyMode && isM3u8) { completedResults.forEach((result) => {
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(source.source)}`; if (!result) return;
} const sourceKey = `${result.source.source}-${result.source.id}`;
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) {
// 成功的结果
newVideoInfoMap.set(sourceKey, result.testResult); 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<MaybeSourceTestResult> => {
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<MaybeSourceTestResult[]> => {
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 topPriorityKeys = new Set(
const validSpeeds = successfulResults sortedByWeight
.map((result) => { .slice(0, 5)
const speedStr = result.testResult.loadSpeed; .map((source) => `${source.source}-${source.id}`)
if (speedStr === '未知' || speedStr === '测量中...') return 0; );
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/); const quickResults = await new Promise<MaybeSourceTestResult[]>((resolve) => {
if (!match) return 0; const results: Array<MaybeSourceTestResult | undefined> = 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 maybeResolve = () => {
const unit = match[2]; if (settled) return;
return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s
})
.filter((speed) => speed > 0);
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;
}
// 找出所有有效延迟的最小值和最大值,用于线性映射 if (completedCount === sources.length) {
const validPings = successfulResults settled = true;
.map((result) => result.testResult.pingTime) resolve(results as MaybeSourceTestResult[]);
.filter((ping) => ping > 0); return;
}
const minPing = validPings.length > 0 ? Math.min(...validPings) : 50; while (!settled && activeCount < maxConcurrency && nextIndex < sources.length) {
const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000; const currentIndex = nextIndex++;
const currentSource = sources[currentIndex];
const sourceKey = `${currentSource.source}-${currentSource.id}`;
activeCount += 1;
// 计算每个结果的评分 testSingleSource(currentSource)
const resultsWithScore = successfulResults.map((result) => ({ .then((result) => {
...result, results[currentIndex] = result;
score: calculateSourceScore( completedCount += 1;
result.testResult,
maxSpeed,
minPing,
maxPing,
result.source.weight ?? 0
),
}));
// 按综合评分排序,选择最佳播放源 if (topPriorityKeys.has(sourceKey)) {
resultsWithScore.sort((a, b) => b.score - a.score); topCompletedCount += 1;
if (result) {
topSuccessCount += 1;
}
}
})
.finally(() => {
activeCount -= 1;
maybeResolve();
});
}
};
console.log('播放源评分排序结果:'); maybeResolve();
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; return finalizeSelection(quickResults);
}; };
// 计算播放源综合评分 // 计算播放源综合评分
+47
View File
@@ -117,6 +117,7 @@ export const UserMenu: React.FC = () => {
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true); const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState(''); const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true); const [enableOptimization, setEnableOptimization] = useState(true);
const [preferStrategy, setPreferStrategy] = useState<'fast' | 'full'>('fast');
const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒) const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒)
const [fluidSearch, setFluidSearch] = useState(true); const [fluidSearch, setFluidSearch] = useState(true);
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false); const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
@@ -534,6 +535,11 @@ export const UserMenu: React.FC = () => {
setEnableOptimization(JSON.parse(savedEnableOptimization)); setEnableOptimization(JSON.parse(savedEnableOptimization));
} }
const savedPreferStrategy = localStorage.getItem('preferStrategy');
if (savedPreferStrategy === 'fast' || savedPreferStrategy === 'full') {
setPreferStrategy(savedPreferStrategy);
}
const savedSpeedTestTimeout = localStorage.getItem('speedTestTimeout'); const savedSpeedTestTimeout = localStorage.getItem('speedTestTimeout');
if (savedSpeedTestTimeout !== null) { if (savedSpeedTestTimeout !== null) {
setSpeedTestTimeout(Number(savedSpeedTestTimeout)); 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) => { const handleSpeedTestTimeoutChange = (value: number) => {
setSpeedTestTimeout(value); setSpeedTestTimeout(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -1356,6 +1369,7 @@ export const UserMenu: React.FC = () => {
setDefaultAggregateSearch(true); setDefaultAggregateSearch(true);
setEnableOptimization(true); setEnableOptimization(true);
setPreferStrategy('fast');
setFluidSearch(defaultFluidSearch); setFluidSearch(defaultFluidSearch);
setTmdbBackdropDisabled(false); setTmdbBackdropDisabled(false);
setEnableTrailers(false); setEnableTrailers(false);
@@ -1384,6 +1398,7 @@ export const UserMenu: React.FC = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true)); localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true)); localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('preferStrategy', 'fast');
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch)); localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
localStorage.setItem('liveDirectConnect', JSON.stringify(false)); localStorage.setItem('liveDirectConnect', JSON.stringify(false));
localStorage.setItem('tmdb_backdrop_disabled', 'false'); localStorage.setItem('tmdb_backdrop_disabled', 'false');
@@ -2243,6 +2258,38 @@ export const UserMenu: React.FC = () => {
{/* 测速超时设置 */} {/* 测速超时设置 */}
{enableOptimization && ( {enableOptimization && (
<div className='ml-4 mt-2 space-y-2'> <div className='ml-4 mt-2 space-y-2'>
<div className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<span className='text-xs text-gray-600 dark:text-gray-400'>
</span>
<div className='inline-flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800'>
<button
type='button'
onClick={() => handlePreferStrategyChange('fast')}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
preferStrategy === 'fast'
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
</button>
<button
type='button'
onClick={() => handlePreferStrategyChange('full')}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
preferStrategy === 'full'
? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
</button>
</div>
</div>
</div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-xs text-gray-600 dark:text-gray-400'> <span className='text-xs text-gray-600 dark:text-gray-400'>