搜索逻辑优化,聚合逻辑优化

This commit is contained in:
mtvpls
2026-01-09 15:58:16 +08:00
parent 1c8a4f7bd2
commit 1e3df4f88e
4 changed files with 152 additions and 39 deletions

View File

@@ -2288,6 +2288,41 @@ function PlayPageClient() {
.replace(/[^\w\u4e00-\u9fa5]/g, ''); // 去除特殊符号,保留字母、数字、下划线和中文
};
// 辅助函数:获取视频类型
const getType = (item: SearchResult): 'movie' | 'tv' => {
// 1. Emby 和 OpenList 源:使用 type_name基于 TMDB最可靠
if (item.source === 'emby' || item.source?.startsWith('emby_') || item.source === 'openlist') {
return item.type_name === '电影' ? 'movie' : 'tv';
}
// 2. API 采集源:综合判断
const typeName = item.type_name?.toLowerCase() || '';
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
if (typeName.includes('电影') || typeName.includes('movie') ||
typeName.endsWith('片') && !typeName.includes('动漫')) {
return 'movie';
}
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
if (typeName.includes('剧') || typeName.includes('动漫') ||
typeName.includes('综艺') || typeName.includes('anime')) {
return 'tv';
}
// 2.3 检查 episodes_titles如果包含"第X集",判断为剧集
if (item.episodes_titles && item.episodes_titles.length > 0) {
const firstTitle = item.episodes_titles[0] || '';
if (/第\d+集|第\d+话|EP?\d+/i.test(firstTitle)) {
return 'tv';
}
}
// 2.4 兜底:使用 episodes.length最不可靠
return item.episodes.length === 1 ? 'movie' : 'tv';
};
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
// 根据搜索词获取全部源信息
try {
@@ -2303,19 +2338,19 @@ function PlayPageClient() {
const cachedData = JSON.parse(cached);
// 处理缓存的搜索结果,根据规则过滤
results = cachedData.filter(
results = cachedData.filter(
(result: SearchResult) =>
normalizeTitle(result.title).toLowerCase() ===
normalizeTitle(videoTitleRef.current).toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
result.source === 'openlist' ||
result.source === 'emby' ||
(searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
!result.year ||
result.year.trim() === '' ||
result.year === 'unknown' ||
!/^\d{4}$/.test(result.year)
: true) &&
(searchType
? getType(result) === searchType
: true)
);
@@ -2343,14 +2378,14 @@ function PlayPageClient() {
normalizeTitle(result.title).toLowerCase() ===
normalizeTitle(videoTitleRef.current).toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
!result.year ||
result.year.trim() === '' ||
result.year === 'unknown' ||
!/^\d{4}$/.test(result.year)
: true) &&
(searchType
? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
result.source === 'openlist' ||
result.source === 'emby' ||
(searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
? getType(result) === searchType
: true)
);
setAvailableSources(results);

View File

@@ -220,29 +220,98 @@ function SearchPageClient() {
.replace(/[^\w\u4e00-\u9fa5]/g, ''); // 去除特殊符号,保留字母、数字、下划线和中文
};
// 辅助函数:获取视频类型
const getType = (item: SearchResult): 'movie' | 'tv' => {
// 1. Emby 和 OpenList 源:使用 type_name基于 TMDB最可靠
if (item.source === 'emby' || item.source?.startsWith('emby_') || item.source === 'openlist') {
return item.type_name === '电影' ? 'movie' : 'tv';
}
// 2. API 采集源:综合判断
const typeName = item.type_name?.toLowerCase() || '';
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
if (typeName.includes('电影') || typeName.includes('movie') ||
typeName.endsWith('片') && !typeName.includes('动漫')) {
return 'movie';
}
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
if (typeName.includes('剧') || typeName.includes('动漫') ||
typeName.includes('综艺') || typeName.includes('anime')) {
return 'tv';
}
// 2.3 检查 episodes_titles如果包含"第X集",判断为剧集
if (item.episodes_titles && item.episodes_titles.length > 0) {
const firstTitle = item.episodes_titles[0] || '';
if (/第\d+集|第\d+话|EP?\d+/i.test(firstTitle)) {
return 'tv';
}
}
// 2.4 兜底:使用 episodes.length最不可靠
return item.episodes.length === 1 ? 'movie' : 'tv';
};
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
const map = new Map<string, SearchResult[]>();
const keyOrder: string[] = []; // 记录键出现的顺序
//===== 阶段1按 normalizedTitle-type 初步分组 =====
const preliminaryMap = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
// 使用规范化后的 title + year + type 作为键year 必然存在,但依然兜底 'unknown'
const normalizedTitle = normalizeTitle(item.title);
const key = `${normalizedTitle}-${item.year || 'unknown'
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
const arr = map.get(key) || [];
// 如果是新的键,记录其顺序
if (arr.length === 0) {
keyOrder.push(key);
}
const type = getType(item);
const preliminaryKey = `${normalizedTitle}-${type}`;
const arr = preliminaryMap.get(preliminaryKey) || [];
arr.push(item);
map.set(key, arr);
preliminaryMap.set(preliminaryKey, arr);
});
//===== 阶段2智能年份推断和最终分组 =====
const finalMap = new Map<string, SearchResult[]>();
const keyOrder: string[] = [];
preliminaryMap.forEach((group, preliminaryKey) => {
// 分离有年份和无年份的结果
const withYear = new Map<string, SearchResult[]>();
const withoutYear: SearchResult[] = [];
group.forEach((item) => {
const year = item.year;
// 判断是否为有效年份必须是4位数字且不能是空字符串或'unknown'
if (year && year.trim() !== '' && year !== 'unknown' && /^\d{4}$/.test(year)) {
// 有有效年份
const arr = withYear.get(year) || [];
arr.push(item);
withYear.set(year, arr);
} else {
// 无年份(包括空字符串、'unknown'、null、undefined等
withoutYear.push(item);
}
});
// 如果有有效年份组
if (withYear.size > 0) {
// 将无年份的结果复制到每个有年份的组中
withYear.forEach((yearGroup, year) => {
const finalKey = `${preliminaryKey}-${year}`;
// 合并:有年份的 + 无年份的(复制)
const mergedGroup = [...yearGroup, ...withoutYear];
finalMap.set(finalKey, mergedGroup);
keyOrder.push(finalKey);
});
} else if (withoutYear.length > 0) {
// 如果完全没有年份信息,单独成组
const finalKey = `${preliminaryKey}-unknown`;
finalMap.set(finalKey, withoutYear);
keyOrder.push(finalKey);
}
});
// 按出现顺序返回聚合结果
return keyOrder.map(key => [key, map.get(key)!] as [string, SearchResult[]]);
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
}, [searchResults]);
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
@@ -296,9 +365,9 @@ function SearchPageClient() {
const aIsOpenList = a[0] === 'openlist';
const bIsOpenList = b[0] === 'openlist';
// 判断是否为 emby 源(包括 emby 和 emby:xxx 格式)
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby:');
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby:');
// 判断是否为 emby 源(包括 emby 和 emby_xxx 格式)
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby_');
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby_');
// 优先级OpenList(100) > Emby(90) > 其他(0)
const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0;
@@ -1027,7 +1096,14 @@ function SearchPageClient() {
const poster = group[0]?.poster || '';
const year = group[0]?.year || 'unknown';
const { episodes, source_names, douban_id } = computeGroupStats(group);
const type = episodes === 1 ? 'movie' : 'tv';
// 从 mapKey 中提取类型mapKey 格式normalizedTitle-type-year
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
const lastDashIndex = mapKey.lastIndexOf('-');
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
const type = secondLastDashIndex > 0
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
// 如果该聚合第一次出现,写入初始统计
if (!groupStatsRef.current.has(mapKey)) {

View File

@@ -779,8 +779,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
}
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()}
>
{/* 封面 */}
@@ -855,7 +855,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{/* 源名称和集数信息 - 垂直居中 */}
<div className='flex items-center justify-between'>
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
? 'border-yellow-500'
: 'border-gray-500/60'
}`}>
{source.source_name}
</span>
{source.episodes.length > 1 && (

View File

@@ -159,9 +159,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const actualEpisodes = dynamicEpisodes;
const actualYear = year;
const actualQuery = query || '';
const actualSearchType = isAggregate
? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv')
: type;
const actualSearchType = type;
// 获取收藏状态(搜索结果页面不检查)
useEffect(() => {