feat: add stitle and stype, optimize prefer choose

This commit is contained in:
shinya
2025-07-11 01:51:53 +08:00
parent e18dc3787f
commit 367ad8aa48
7 changed files with 140 additions and 46 deletions

View File

@@ -49,6 +49,7 @@ function HomeClient() {
episodes: number;
source_name: string;
currentEpisode?: number;
search_title?: string;
};
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
@@ -112,6 +113,7 @@ function HomeClient() {
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
} as FavoriteItem;
});
setFavoriteItems(sorted);
@@ -161,7 +163,11 @@ function HomeClient() {
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:px-4'>
{favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'>
<VideoCard {...item} from='favorite' />
<VideoCard
query={item.search_title}
{...item}
from='favorite'
/>
</div>
))}
{favoriteItems.length === 0 && (

View File

@@ -74,6 +74,10 @@ function PlayPageClient() {
);
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
// 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || '');
// 是否需要优选
const [needPrefer, setNeedPrefer] = useState(
searchParams.get('prefer') === 'true'
@@ -159,7 +163,6 @@ function PlayPageClient() {
const allResults: Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
score: number;
} | null> = [];
for (let start = 0; start < sources.length; start += batchSize) {
@@ -181,7 +184,6 @@ function PlayPageClient() {
return {
source,
testResult,
score: calculateSourceScore(testResult),
};
} catch (error) {
return null;
@@ -216,7 +218,6 @@ function PlayPageClient() {
const successfulResults = allResults.filter(Boolean) as Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
score: number;
}>;
setPrecomputedVideoInfo(newVideoInfoMap);
@@ -226,11 +227,47 @@ function PlayPageClient() {
return sources[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; // 统一转换为 KB/s
})
.filter((speed) => speed > 0);
const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准
// 找出所有有效延迟的最小值和最大值,用于线性映射
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
),
}));
// 按综合评分排序,选择最佳播放源
successfulResults.sort((a, b) => b.score - a.score);
resultsWithScore.sort((a, b) => b.score - a.score);
console.log('播放源评分排序结果:');
successfulResults.forEach((result, index) => {
resultsWithScore.forEach((result, index) => {
console.log(
`${index + 1}. ${
result.source.source_name
@@ -240,15 +277,20 @@ function PlayPageClient() {
);
});
return successfulResults[0].source;
return resultsWithScore[0].source;
};
// 计算播放源综合评分
const calculateSourceScore = (testResult: {
quality: string;
loadSpeed: string;
pingTime: number;
}): number => {
const calculateSourceScore = (
testResult: {
quality: string;
loadSpeed: string;
pingTime: number;
},
maxSpeed: number,
minPing: number,
maxPing: number
): number => {
let score = 0;
// 分辨率评分 (40% 权重)
@@ -272,7 +314,7 @@ function PlayPageClient() {
})();
score += qualityScore * 0.4;
// 下载速度评分 (40% 权重)
// 下载速度评分 (40% 权重) - 基于最大速度线性映射
const speedScore = (() => {
const speedStr = testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 30;
@@ -285,25 +327,23 @@ function PlayPageClient() {
const unit = match[2];
const speedKBps = unit === 'MB/s' ? value * 1024 : value;
// 根据速度给
if (speedKBps >= 5120) return 100; // ≥5MB/s
if (speedKBps >= 2048) return 85; // ≥2MB/s
if (speedKBps >= 1024) return 70; // ≥1MB/s
if (speedKBps >= 512) return 55; // ≥512KB/s
if (speedKBps >= 256) return 40; // ≥256KB/s
return 25; // <256KB/s
// 基于最大速度线性映射最高100
const speedRatio = speedKBps / maxSpeed;
return Math.min(100, Math.max(0, speedRatio * 100));
})();
score += speedScore * 0.4;
// 网络延迟评分 (20% 权重)
// 网络延迟评分 (20% 权重) - 基于延迟范围线性映射
const pingScore = (() => {
const ping = testResult.pingTime;
if (ping <= 50) return 100; // ≤50ms
if (ping <= 100) return 85; // ≤100ms
if (ping <= 200) return 70; // ≤200ms
if (ping <= 500) return 50; // ≤500ms
if (ping <= 1000) return 30; // ≤1s
return 15; // >1s
if (ping <= 0) return 0; // 无效延迟给默认分
// 如果所有延迟都相同,给满分
if (maxPing === minPing) return 100;
// 线性映射:最低延迟=100分最高延迟=0分
const pingRatio = (maxPing - ping) / (maxPing - minPing);
return Math.min(100, Math.max(0, pingRatio * 100));
})();
score += pingScore * 0.2;
@@ -407,8 +447,14 @@ function PlayPageClient() {
// 获取视频详情
useEffect(() => {
const fetchDetailAsync = async () => {
console.log('fetchDetailAsync', currentSource, currentId, videoTitle);
if (!currentSource && !currentId && !videoTitle) {
console.log(
'fetchDetailAsync',
currentSource,
currentId,
videoTitle,
searchTitle
);
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
setError('缺少必要参数');
setLoading(false);
return;
@@ -420,8 +466,15 @@ function PlayPageClient() {
setLoadingStage('searching');
setLoadingMessage('🔍 正在搜索播放源...');
const searchResults = await handleSearchSources(videoTitle);
const searchResults = await handleSearchSources(
searchTitle || videoTitle
);
if (searchResults.length == 0) {
if (currentSource && currentId) {
// 跳过优选
setNeedPrefer(false);
return;
}
setError('未找到匹配结果');
setLoading(false);
return;
@@ -453,8 +506,9 @@ function PlayPageClient() {
const detailData = await fetchVideoDetail({
source: currentSource,
id: currentId,
fallbackTitle: videoTitleRef.current.trim(),
fallbackYear: videoYearRef.current,
fallbackTitle: searchTitle || videoTitleRef.current.trim(),
fallbackYear:
videoYearRef.current === 'unknown' ? '' : videoYearRef.current,
});
// 更新状态保存详情
@@ -493,7 +547,7 @@ function PlayPageClient() {
};
fetchDetailAsync();
}, [currentSource, currentId]);
}, [currentSource, currentId, needPrefer]);
// 播放记录处理
useEffect(() => {
@@ -601,13 +655,20 @@ function PlayPageClient() {
result.title.toLowerCase() ===
videoTitleRef.current.toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
? videoYearRef.current === 'unknown'
? result.year === ''
: result.year.toLowerCase() ===
videoYearRef.current.toLowerCase()
: true) &&
(detailRef.current
? (detailRef.current.episodes.length === 1 &&
result.episodes.length === 1) ||
(detailRef.current.episodes.length > 1 &&
result.episodes.length > 1)
: true) &&
(searchType
? (searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
);
if (exactMatchs.length > 0) {
@@ -855,13 +916,14 @@ function PlayPageClient() {
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year || '',
year: detailRef.current?.year || 'unknown',
cover: detailRef.current?.poster || '',
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
total_episodes: detailRef.current?.episodes.length || 1,
play_time: Math.floor(currentTime),
total_time: Math.floor(duration),
save_time: Date.now(),
search_title: searchTitle,
});
lastSaveTimeRef.current = Date.now();
@@ -941,10 +1003,11 @@ function PlayPageClient() {
{
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year || '',
year: detailRef.current?.year || 'unknown',
cover: detailRef.current?.poster || '',
total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(),
search_title: searchTitle,
}
);
setFavorited(newState);
@@ -1503,7 +1566,7 @@ function PlayPageClient() {
onSourceChange={handleSourceChange}
currentSource={currentSource}
currentId={currentId}
videoTitle={videoTitle}
videoTitle={searchTitle || videoTitle}
availableSources={availableSources}
onSearchSources={handleSearchSources}
sourceSearchLoading={sourceSearchLoading}

View File

@@ -201,7 +201,15 @@ function SearchPageClient() {
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard from='search' items={group} />
<VideoCard
from='search'
items={group}
query={
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
/>
</div>
);
})
@@ -218,6 +226,11 @@ function SearchPageClient() {
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id?.toString()}
query={
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
from='search'
/>
</div>

View File

@@ -120,6 +120,7 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>

View File

@@ -12,6 +12,7 @@ interface VideoCardProps {
id?: string;
source?: string;
title?: string;
query?: string;
poster?: string;
episodes?: number;
source_name?: string;
@@ -28,6 +29,7 @@ interface VideoCardProps {
export default function VideoCard({
id,
title = '',
query = '',
poster = '',
episodes,
source,
@@ -54,7 +56,6 @@ export default function VideoCard({
const countMap = new Map<string | number, number>();
const episodeCountMap = new Map<number, number>();
const yearCountMap = new Map<string, number>();
items.forEach((item) => {
if (item.douban_id && item.douban_id !== 0) {
@@ -64,10 +65,6 @@ export default function VideoCard({
if (len > 0) {
episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1);
}
if (item.year?.trim()) {
const yearStr = item.year.trim();
yearCountMap.set(yearStr, (yearCountMap.get(yearStr) || 0) + 1);
}
});
const getMostFrequent = <T extends string | number>(
@@ -88,7 +85,6 @@ export default function VideoCard({
first: items[0],
mostFrequentDoubanId: getMostFrequent(countMap),
mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0,
mostFrequentYear: getMostFrequent(yearCountMap),
};
}, [isAggregate, items]);
@@ -100,7 +96,14 @@ export default function VideoCard({
aggregateData?.mostFrequentDoubanId ?? douban_id
);
const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes;
const actualYear = aggregateData?.mostFrequentYear ?? year;
const actualYear =
(isAggregate ? aggregateData?.first.year : year) || 'unknown';
const actualQuery = query || '';
const actualSearchType = isAggregate
? aggregateData?.first.episodes.length === 1
? 'movie'
: 'tv'
: '';
// 获取收藏状态
useEffect(() => {
@@ -176,7 +179,9 @@ export default function VideoCard({
actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${
isAggregate ? '&prefer=true' : ''
}`
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
);
}
}, [
@@ -187,6 +192,8 @@ export default function VideoCard({
actualTitle,
actualYear,
isAggregate,
actualQuery,
actualSearchType,
]);
const config = useMemo(() => {

View File

@@ -24,6 +24,7 @@ export interface PlayRecord {
play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题
}
// ---- 常量 ----
@@ -308,6 +309,7 @@ export interface Favorite {
cover: string;
total_episodes: number;
save_time: number;
search_title?: string; // 搜索时使用的标题
}
// 收藏在 localStorage 中使用的 key

View File

@@ -10,6 +10,7 @@ export interface PlayRecord {
play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题
}
// 收藏数据结构
@@ -19,6 +20,7 @@ export interface Favorite {
title: string;
cover: string;
save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题
}
// 存储接口