搜索逻辑优化,聚合逻辑优化
This commit is contained in:
+51
-16
@@ -2288,6 +2288,41 @@ function PlayPageClient() {
|
|||||||
.replace(/[^\w\u4e00-\u9fa5]/g, ''); // 去除特殊符号,保留字母、数字、下划线和中文
|
.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[]> => {
|
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
|
||||||
// 根据搜索词获取全部源信息
|
// 根据搜索词获取全部源信息
|
||||||
try {
|
try {
|
||||||
@@ -2303,19 +2338,19 @@ function PlayPageClient() {
|
|||||||
const cachedData = JSON.parse(cached);
|
const cachedData = JSON.parse(cached);
|
||||||
|
|
||||||
// 处理缓存的搜索结果,根据规则过滤
|
// 处理缓存的搜索结果,根据规则过滤
|
||||||
results = cachedData.filter(
|
results = cachedData.filter(
|
||||||
(result: SearchResult) =>
|
(result: SearchResult) =>
|
||||||
normalizeTitle(result.title).toLowerCase() ===
|
normalizeTitle(result.title).toLowerCase() ===
|
||||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||||
(videoYearRef.current
|
(videoYearRef.current
|
||||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||||
: true) &&
|
!result.year ||
|
||||||
(searchType
|
result.year.trim() === '' ||
|
||||||
? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
|
result.year === 'unknown' ||
|
||||||
result.source === 'openlist' ||
|
!/^\d{4}$/.test(result.year)
|
||||||
result.source === 'emby' ||
|
: true) &&
|
||||||
(searchType === 'tv' && result.episodes.length > 1) ||
|
(searchType
|
||||||
(searchType === 'movie' && result.episodes.length === 1)
|
? getType(result) === searchType
|
||||||
: true)
|
: true)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2343,14 +2378,14 @@ function PlayPageClient() {
|
|||||||
normalizeTitle(result.title).toLowerCase() ===
|
normalizeTitle(result.title).toLowerCase() ===
|
||||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||||
(videoYearRef.current
|
(videoYearRef.current
|
||||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||||
: true) &&
|
!result.year ||
|
||||||
|
result.year.trim() === '' ||
|
||||||
|
result.year === 'unknown' ||
|
||||||
|
!/^\d{4}$/.test(result.year)
|
||||||
|
: true) &&
|
||||||
(searchType
|
(searchType
|
||||||
? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
|
? getType(result) === searchType
|
||||||
result.source === 'openlist' ||
|
|
||||||
result.source === 'emby' ||
|
|
||||||
(searchType === 'tv' && result.episodes.length > 1) ||
|
|
||||||
(searchType === 'movie' && result.episodes.length === 1)
|
|
||||||
: true)
|
: true)
|
||||||
);
|
);
|
||||||
setAvailableSources(results);
|
setAvailableSources(results);
|
||||||
|
|||||||
+93
-17
@@ -220,29 +220,98 @@ function SearchPageClient() {
|
|||||||
.replace(/[^\w\u4e00-\u9fa5]/g, ''); // 去除特殊符号,保留字母、数字、下划线和中文
|
.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 aggregatedResults = useMemo(() => {
|
||||||
const map = new Map<string, SearchResult[]>();
|
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
||||||
const keyOrder: string[] = []; // 记录键出现的顺序
|
const preliminaryMap = new Map<string, SearchResult[]>();
|
||||||
|
|
||||||
searchResults.forEach((item) => {
|
searchResults.forEach((item) => {
|
||||||
// 使用规范化后的 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
|
|
||||||
const normalizedTitle = normalizeTitle(item.title);
|
const normalizedTitle = normalizeTitle(item.title);
|
||||||
const key = `${normalizedTitle}-${item.year || 'unknown'
|
const type = getType(item);
|
||||||
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
|
const preliminaryKey = `${normalizedTitle}-${type}`;
|
||||||
const arr = map.get(key) || [];
|
|
||||||
|
|
||||||
// 如果是新的键,记录其顺序
|
|
||||||
if (arr.length === 0) {
|
|
||||||
keyOrder.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const arr = preliminaryMap.get(preliminaryKey) || [];
|
||||||
arr.push(item);
|
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]);
|
}, [searchResults]);
|
||||||
|
|
||||||
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
||||||
@@ -296,9 +365,9 @@ function SearchPageClient() {
|
|||||||
const aIsOpenList = a[0] === 'openlist';
|
const aIsOpenList = a[0] === 'openlist';
|
||||||
const bIsOpenList = b[0] === 'openlist';
|
const bIsOpenList = b[0] === 'openlist';
|
||||||
|
|
||||||
// 判断是否为 emby 源(包括 emby 和 emby:xxx 格式)
|
// 判断是否为 emby 源(包括 emby 和 emby_xxx 格式)
|
||||||
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby:');
|
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby_');
|
||||||
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby:');
|
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby_');
|
||||||
|
|
||||||
// 优先级:OpenList(100) > Emby(90) > 其他(0)
|
// 优先级:OpenList(100) > Emby(90) > 其他(0)
|
||||||
const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0;
|
const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0;
|
||||||
@@ -1027,7 +1096,14 @@ function SearchPageClient() {
|
|||||||
const poster = group[0]?.poster || '';
|
const poster = group[0]?.poster || '';
|
||||||
const year = group[0]?.year || 'unknown';
|
const year = group[0]?.year || 'unknown';
|
||||||
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
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)) {
|
if (!groupStatsRef.current.has(mapKey)) {
|
||||||
|
|||||||
@@ -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
|
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
||||||
${isCurrentSource
|
${isCurrentSource
|
||||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
|
? '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'
|
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
>
|
>
|
||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
@@ -855,7 +855,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
|
|
||||||
{/* 源名称和集数信息 - 垂直居中 */}
|
{/* 源名称和集数信息 - 垂直居中 */}
|
||||||
<div className='flex items-center justify-between'>
|
<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}
|
{source.source_name}
|
||||||
</span>
|
</span>
|
||||||
{source.episodes.length > 1 && (
|
{source.episodes.length > 1 && (
|
||||||
|
|||||||
@@ -159,9 +159,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
const actualEpisodes = dynamicEpisodes;
|
const actualEpisodes = dynamicEpisodes;
|
||||||
const actualYear = year;
|
const actualYear = year;
|
||||||
const actualQuery = query || '';
|
const actualQuery = query || '';
|
||||||
const actualSearchType = isAggregate
|
const actualSearchType = type;
|
||||||
? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv')
|
|
||||||
: type;
|
|
||||||
|
|
||||||
// 获取收藏状态(搜索结果页面不检查)
|
// 获取收藏状态(搜索结果页面不检查)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user