diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index c69700a..c38a241 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -3,7 +3,7 @@ import { ChevronUp, Search, X } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useRef, useState, startTransition } from 'react'; +import React, { Suspense, useEffect, useMemo, useRef, useState, startTransition } from 'react'; import { addSearchHistory, @@ -18,7 +18,7 @@ import { SearchResult } from '@/lib/types'; import PageLayout from '@/components/PageLayout'; import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter'; import SearchSuggestions from '@/components/SearchSuggestions'; -import VideoCard from '@/components/VideoCard'; +import VideoCard, { VideoCardHandle } from '@/components/VideoCard'; function SearchPageClient() { // 搜索历史 @@ -39,6 +39,50 @@ function SearchPageClient() { const [completedSources, setCompletedSources] = useState(0); const pendingResultsRef = useRef([]); const flushTimerRef = useRef(null); + // 聚合卡片 refs 与聚合统计缓存 + const groupRefs = useRef>>(new Map()); + const groupStatsRef = useRef>(new Map()); + + const getGroupRef = (key: string) => { + let ref = groupRefs.current.get(key); + if (!ref) { + ref = React.createRef(); + groupRefs.current.set(key, ref); + } + return ref; + }; + + const computeGroupStats = (group: SearchResult[]) => { + const episodes = (() => { + const countMap = new Map(); + group.forEach((g) => { + const len = g.episodes?.length || 0; + if (len > 0) countMap.set(len, (countMap.get(len) || 0) + 1); + }); + let max = 0; + let res = 0; + countMap.forEach((v, k) => { + if (v > max) { max = v; res = k; } + }); + return res; + })(); + const douban_id = (() => { + const idMap = new Map(); + group.forEach((g) => { + if (g.douban_id && g.douban_id !== 0) { + idMap.set(g.douban_id, (idMap.get(g.douban_id) || 0) + 1); + } + }); + let max = 0; + let res: number | undefined = undefined; + idMap.forEach((v, k) => { + if (v > max) { max = v; res = k; } + }); + return res; + })(); + const source_names = Array.from(new Set(group.map((g) => g.source_name).filter(Boolean))) as string[]; + return { episodes, douban_id, source_names }; + }; // 过滤器:非聚合与聚合 const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({ source: 'all', @@ -132,6 +176,35 @@ function SearchPageClient() { return keyOrder.map(key => [key, map.get(key)!] as [string, SearchResult[]]); }, [searchResults]); + // 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新 + useEffect(() => { + aggregatedResults.forEach(([mapKey, group]) => { + const stats = computeGroupStats(group); + const prev = groupStatsRef.current.get(mapKey); + if (!prev) { + // 第一次出现,记录初始值,不调用 ref(由初始 props 渲染) + groupStatsRef.current.set(mapKey, stats); + return; + } + // 对比变化并调用对应的 set 方法 + const ref = groupRefs.current.get(mapKey); + if (ref && ref.current) { + if (prev.douban_id !== stats.douban_id) { + ref.current.setDoubanId(stats.douban_id); + } + if (prev.episodes !== stats.episodes) { + ref.current.setEpisodes(stats.episodes); + } + const prevNames = (prev.source_names || []).join('|'); + const nextNames = (stats.source_names || []).join('|'); + if (prevNames !== nextNames) { + ref.current.setSourceNames(stats.source_names); + } + groupStatsRef.current.set(mapKey, stats); + } + }); + }, [aggregatedResults]); + // 构建筛选选项 const filterOptions = useMemo(() => { const sourcesSet = new Map(); @@ -604,16 +677,35 @@ function SearchPageClient() { > {viewMode === 'agg' ? filteredAggResults.map(([mapKey, group]) => { + const title = group[0]?.title || ''; + const poster = group[0]?.poster || ''; + const year = group[0]?.year || 'unknown'; + const { episodes, douban_id, source_names } = computeGroupStats(group); + const type = episodes === 1 ? 'movie' : 'tv'; + + // 如果该聚合第一次出现,写入初始统计 + if (!groupStatsRef.current.has(mapKey)) { + groupStatsRef.current.set(mapKey, { episodes, douban_id, source_names }); + } + return (
); diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 96e5584..a8917ca 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -3,7 +3,15 @@ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useState, + forwardRef, + useImperativeHandle, +} from 'react'; import { deleteFavorite, @@ -13,12 +21,11 @@ import { saveFavorite, subscribeToDataUpdates, } from '@/lib/db.client'; -import { SearchResult } from '@/lib/types'; import { processImageUrl } from '@/lib/utils'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; -interface VideoCardProps { +export interface VideoCardProps { id?: string; source?: string; title?: string; @@ -26,6 +33,7 @@ interface VideoCardProps { poster?: string; episodes?: number; source_name?: string; + source_names?: string[]; progress?: number; year?: string; from: 'playrecord' | 'favorite' | 'search' | 'douban'; @@ -33,81 +41,83 @@ interface VideoCardProps { douban_id?: number; onDelete?: () => void; rate?: string; - items?: SearchResult[]; type?: string; isBangumi?: boolean; + isAggregate?: boolean; } -function VideoCard({ - id, - title = '', - query = '', - poster = '', - episodes, - source, - source_name, - progress = 0, - year, - from, - currentEpisode, - douban_id, - onDelete, - rate, - items, - type = '', - isBangumi = false, -}: VideoCardProps) { +export type VideoCardHandle = { + setDoubanId: (id?: number) => void; + setEpisodes: (episodes?: number) => void; + setSourceNames: (names?: string[]) => void; +}; + +const VideoCard = forwardRef(function VideoCard( + { + id, + title = '', + query = '', + poster = '', + episodes, + source, + source_name, + source_names, + progress = 0, + year, + from, + currentEpisode, + douban_id, + onDelete, + rate, + type = '', + isBangumi = false, + isAggregate = false, + }: VideoCardProps, + ref +) { const router = useRouter(); const [favorited, setFavorited] = useState(false); const [isLoading, setIsLoading] = useState(false); - const isAggregate = from === 'search' && !!items?.length; + // 可外部修改的可控字段 + const [dynamicDoubanId, setDynamicDoubanId] = useState( + douban_id + ); + const [dynamicEpisodes, setDynamicEpisodes] = useState( + episodes + ); + const [dynamicSourceNames, setDynamicSourceNames] = useState( + source_names + ); - const aggregateData = useMemo(() => { - if (!isAggregate || !items) return null; - const countMap = new Map(); - const episodeCountMap = new Map(); - items.forEach((item) => { - if (item.douban_id && item.douban_id !== 0) { - countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1); - } - const len = item.episodes?.length || 0; - if (len > 0) { - episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1); - } - }); + useEffect(() => { + setDynamicDoubanId(douban_id); + }, [douban_id]); - const getMostFrequent = (map: Map) => { - let maxCount = 0; - let result: number | undefined; - map.forEach((cnt, key) => { - if (cnt > maxCount) { - maxCount = cnt; - result = key; - } - }); - return result; - }; + useEffect(() => { + setDynamicEpisodes(episodes); + }, [episodes]); - return { - first: items[0], - mostFrequentDoubanId: getMostFrequent(countMap), - mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0, - }; - }, [isAggregate, items]); + useEffect(() => { + setDynamicSourceNames(source_names); + }, [source_names]); - const actualTitle = aggregateData?.first.title ?? title; - const actualPoster = aggregateData?.first.poster ?? poster; - const actualSource = aggregateData?.first.source ?? source; - const actualId = aggregateData?.first.id ?? id; - const actualDoubanId = aggregateData?.mostFrequentDoubanId ?? douban_id; - const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes; - const actualYear = aggregateData?.first.year ?? year; + useImperativeHandle(ref, () => ({ + setDoubanId: (id?: number) => setDynamicDoubanId(id), + setEpisodes: (eps?: number) => setDynamicEpisodes(eps), + setSourceNames: (names?: string[]) => setDynamicSourceNames(names), + })); + + const actualTitle = title; + const actualPoster = poster; + const actualSource = source; + const actualId = id; + const actualDoubanId = dynamicDoubanId; + const actualEpisodes = dynamicEpisodes; + const actualYear = year; const actualQuery = query || ''; const actualSearchType = isAggregate - ? aggregateData?.first.episodes?.length === 1 - ? 'movie' - : 'tv' + ? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv') : type; // 获取收藏状态(搜索结果页面不检查) @@ -194,10 +204,10 @@ function VideoCard({ ); const handleClick = useCallback(() => { - if (from === 'douban') { + if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { router.push( `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}` + }${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}` ); } else if (actualSource && actualId) { router.push( @@ -378,8 +388,8 @@ function VideoCard({ )} {/* 聚合播放源指示器 */} - {isAggregate && items && items.length > 0 && (() => { - const uniqueSources = Array.from(new Set(items.map(item => item.source_name))); + {isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { + const uniqueSources = Array.from(new Set(dynamicSourceNames)); const sourceCount = uniqueSources.length; return ( @@ -479,39 +489,6 @@ function VideoCard({ ); } -// 自定义 props 比较,避免不必要的重渲染,减少卡片闪烁 -function arePropsEqual(prev: VideoCardProps, next: VideoCardProps) { - // 基础字段对比 - const basicEqual = - prev.id === next.id && - prev.title === next.title && - prev.query === next.query && - prev.poster === next.poster && - prev.episodes === next.episodes && - prev.source === next.source && - prev.source_name === next.source_name && - prev.year === next.year && - prev.from === next.from && - prev.type === next.type && - prev.douban_id === next.douban_id; +); - if (!basicEqual) return false; - - // 聚合 items 仅对比长度与首个元素的关键标识,避免每次新数组导致重渲染 - const prevLen = prev.items?.length || 0; - const nextLen = next.items?.length || 0; - if (prevLen !== nextLen) return false; - if (prevLen === 0) return true; - - const prevFirst = prev.items![0]; - const nextFirst = next.items![0]; - return ( - prevFirst.id === nextFirst.id && - prevFirst.source === nextFirst.source && - prevFirst.title === nextFirst.title && - prevFirst.poster === nextFirst.poster && - prevFirst.year === nextFirst.year - ); -} - -export default memo(VideoCard, arePropsEqual); +export default memo(VideoCard); diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts index 647d567..509e613 100644 --- a/src/lib/downstream.ts +++ b/src/lib/downstream.ts @@ -1,6 +1,7 @@ import { API_CONFIG, ApiSite, getConfig } from '@/lib/config'; import { SearchResult } from '@/lib/types'; import { cleanHtmlTags } from '@/lib/utils'; +import { getCachedSearchPage, setCachedSearchPage } from '@/lib/search-cache'; interface ApiSearchItem { vod_id: string; @@ -15,21 +16,35 @@ interface ApiSearchItem { type_name?: string; } -export async function searchFromApi( +/** + * 通用的带缓存搜索函数 + */ +async function searchWithCache( apiSite: ApiSite, - query: string -): Promise { + query: string, + page: number, + url: string, + timeoutMs: number = 5000 +): Promise<{ results: SearchResult[]; pageCount?: number }> { + // 先查缓存 + const cached = getCachedSearchPage(apiSite.key, query, page); + if (cached) { + if (cached.status === 'ok') { + console.log(`🎯 缓存命中 [${apiSite.key}] query="${query}" page=${page} status=ok results=${cached.data.length}`); + return { results: cached.data, pageCount: cached.pageCount }; + } else { + console.log(`🚫 缓存命中 [${apiSite.key}] query="${query}" page=${page} status=${cached.status} - 返回空结果`); + // timeout / forbidden 命中缓存,直接返回空 + return { results: [] }; + } + } + + // 缓存未命中,发起网络请求 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { - const apiBaseUrl = apiSite.api; - const apiUrl = - apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); - const apiName = apiSite.name; - - // 添加超时处理 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 8000); - - const response = await fetch(apiUrl, { + const response = await fetch(url, { headers: API_CONFIG.search.headers, signal: controller.signal, }); @@ -37,7 +52,10 @@ export async function searchFromApi( clearTimeout(timeoutId); if (!response.ok) { - return []; + if (response.status === 403) { + setCachedSearchPage(apiSite.key, query, page, 'forbidden', []); + } + return { results: [] }; } const data = await response.json(); @@ -47,9 +65,11 @@ export async function searchFromApi( !Array.isArray(data.list) || data.list.length === 0 ) { - return []; + // 空结果不做负缓存要求,这里不写入缓存 + return { results: [] }; } - // 处理第一页结果 + + // 处理结果数据 const results = data.list.map((item: ApiSearchItem) => { let episodes: string[] = []; let titles: string[] = []; @@ -87,7 +107,7 @@ export async function searchFromApi( episodes, episodes_titles: titles, source: apiSite.key, - source_name: apiName, + source_name: apiSite.name, class: item.vod_class, year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || '' @@ -98,11 +118,40 @@ export async function searchFromApi( }; }); + const pageCount = page === 1 ? data.pagecount || 1 : undefined; + // 写入缓存(成功) + setCachedSearchPage(apiSite.key, query, page, 'ok', results, pageCount); + return { results, pageCount }; + } catch (error: any) { + clearTimeout(timeoutId); + // 识别被 AbortController 中止(超时) + const aborted = error?.name === 'AbortError' || error?.code === 20 || error?.message?.includes('aborted'); + if (aborted) { + setCachedSearchPage(apiSite.key, query, page, 'timeout', []); + } + return { results: [] }; + } +} + +export async function searchFromApi( + apiSite: ApiSite, + query: string +): Promise { + try { + const apiBaseUrl = apiSite.api; + const apiUrl = + apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); + + // 使用新的缓存搜索函数处理第一页 + const firstPageResult = await searchWithCache(apiSite, query, 1, apiUrl, 5000); + let results = firstPageResult.results; + const pageCountFromFirst = firstPageResult.pageCount; + const config = await getConfig(); const MAX_SEARCH_PAGES: number = config.SiteConfig.SearchDownstreamMaxPage; // 获取总页数 - const pageCount = data.pagecount || 1; + const pageCount = pageCountFromFirst || 1; // 确定需要获取的额外页数 const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1); @@ -118,77 +167,9 @@ export async function searchFromApi( .replace('{page}', page.toString()); const pagePromise = (async () => { - try { - const pageController = new AbortController(); - const pageTimeoutId = setTimeout( - () => pageController.abort(), - 8000 - ); - - const pageResponse = await fetch(pageUrl, { - headers: API_CONFIG.search.headers, - signal: pageController.signal, - }); - - clearTimeout(pageTimeoutId); - - if (!pageResponse.ok) return []; - - const pageData = await pageResponse.json(); - - if (!pageData || !pageData.list || !Array.isArray(pageData.list)) - return []; - - return pageData.list.map((item: ApiSearchItem) => { - let episodes: string[] = []; - let titles: string[] = []; - - // 使用正则表达式从 vod_play_url 提取 m3u8 链接 - if (item.vod_play_url) { - // 先用 $$$ 分割 - const vod_play_url_array = item.vod_play_url.split('$$$'); - // 分集之间#分割,标题和播放链接 $ 分割 - vod_play_url_array.forEach((url: string) => { - const matchEpisodes: string[] = []; - const matchTitles: string[] = []; - const title_url_array = url.split('#'); - title_url_array.forEach((title_url: string) => { - const episode_title_url = title_url.split('$'); - if ( - episode_title_url.length === 2 && - episode_title_url[1].endsWith('.m3u8') - ) { - matchTitles.push(episode_title_url[0]); - matchEpisodes.push(episode_title_url[1]); - } - }); - if (matchEpisodes.length > episodes.length) { - episodes = matchEpisodes; - titles = matchTitles; - } - }); - } - - return { - id: item.vod_id.toString(), - title: item.vod_name.trim().replace(/\s+/g, ' '), - poster: item.vod_pic, - episodes, - episodes_titles: titles, - source: apiSite.key, - source_name: apiName, - class: item.vod_class, - year: item.vod_year - ? item.vod_year.match(/\d{4}/)?.[0] || '' - : 'unknown', - desc: cleanHtmlTags(item.vod_content || ''), - type_name: item.type_name, - douban_id: item.vod_douban_id, - }; - }); - } catch (error) { - return []; - } + // 使用新的缓存搜索函数处理分页 + const pageResult = await searchWithCache(apiSite, query, page, pageUrl, 5000); + return pageResult.results; })(); additionalPagePromises.push(pagePromise); diff --git a/src/lib/search-cache.ts b/src/lib/search-cache.ts new file mode 100644 index 0000000..be3bb19 --- /dev/null +++ b/src/lib/search-cache.ts @@ -0,0 +1,151 @@ +import { SearchResult } from '@/lib/types'; + +// 缓存状态类型 +export type CachedPageStatus = 'ok' | 'timeout' | 'forbidden'; + +// 缓存条目接口 +export interface CachedPageEntry { + expiresAt: number; + status: CachedPageStatus; + data: SearchResult[]; + pageCount?: number; // 仅第一页可选存储 +} + +// 缓存配置 +const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000; // 10分钟 +const CACHE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5分钟清理一次 +const MAX_CACHE_SIZE = 1000; // 最大缓存条目数量 +const SEARCH_CACHE: Map = new Map(); + +// 自动清理定时器 +let cleanupTimer: NodeJS.Timeout | null = null; +let lastCleanupTime = 0; + +/** + * 生成搜索缓存键:source + query + page + */ +function makeSearchCacheKey(sourceKey: string, query: string, page: number): string { + return `${sourceKey}::${query.trim()}::${page}`; +} + +/** + * 获取缓存的搜索页面数据 + */ +export function getCachedSearchPage( + sourceKey: string, + query: string, + page: number +): CachedPageEntry | null { + const key = makeSearchCacheKey(sourceKey, query, page); + const entry = SEARCH_CACHE.get(key); + if (!entry) return null; + + // 检查是否过期 + if (entry.expiresAt <= Date.now()) { + SEARCH_CACHE.delete(key); + return null; + } + + return entry; +} + +/** + * 设置缓存的搜索页面数据 + */ +export function setCachedSearchPage( + sourceKey: string, + query: string, + page: number, + status: CachedPageStatus, + data: SearchResult[], + pageCount?: number +): void { + // 惰性启动自动清理 + ensureAutoCleanupStarted(); + + // 惰性清理:每次写入时检查是否需要清理 + const now = Date.now(); + if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) { + const stats = performCacheCleanup(); + if (stats.expired > 0 || stats.sizeLimited > 0) { + console.log(`🧹 惰性缓存清理: 删除过期${stats.expired}项,删除超限${stats.sizeLimited}项,剩余${stats.total}项`); + } + } + + const key = makeSearchCacheKey(sourceKey, query, page); + SEARCH_CACHE.set(key, { + expiresAt: now + SEARCH_CACHE_TTL_MS, + status, + data, + pageCount, + }); +} + +/** + * 确保自动清理已启动(惰性初始化) + */ +function ensureAutoCleanupStarted(): void { + if (!cleanupTimer) { + startAutoCleanup(); + console.log(`🚀 启动自动缓存清理,间隔${CACHE_CLEANUP_INTERVAL_MS / 1000}秒,最大缓存${MAX_CACHE_SIZE}项`); + } +} + +/** + * 智能清理过期的缓存条目 + */ +function performCacheCleanup(): { expired: number; total: number; sizeLimited: number } { + const now = Date.now(); + const keysToDelete: string[] = []; + let sizeLimitedDeleted = 0; + + // 1. 清理过期条目 + SEARCH_CACHE.forEach((entry, key) => { + if (entry.expiresAt <= now) { + keysToDelete.push(key); + } + }); + + const expiredCount = keysToDelete.length; + keysToDelete.forEach(key => SEARCH_CACHE.delete(key)); + + // 2. 如果缓存大小超限,清理最老的条目(LRU策略) + if (SEARCH_CACHE.size > MAX_CACHE_SIZE) { + const entries = Array.from(SEARCH_CACHE.entries()); + // 按照过期时间排序,最早过期的在前面 + entries.sort((a, b) => a[1].expiresAt - b[1].expiresAt); + + const toRemove = SEARCH_CACHE.size - MAX_CACHE_SIZE; + for (let i = 0; i < toRemove; i++) { + SEARCH_CACHE.delete(entries[i][0]); + sizeLimitedDeleted++; + } + } + + lastCleanupTime = now; + + return { + expired: expiredCount, + total: SEARCH_CACHE.size, + sizeLimited: sizeLimitedDeleted + }; +} + +/** + * 启动自动清理定时器 + */ +function startAutoCleanup(): void { + if (cleanupTimer) return; // 避免重复启动 + + cleanupTimer = setInterval(() => { + const stats = performCacheCleanup(); + if (stats.expired > 0 || stats.sizeLimited > 0) { + console.log(`🧹 自动缓存清理: 删除过期${stats.expired}项,删除超限${stats.sizeLimited}项,剩余${stats.total}项`); + } + }, CACHE_CLEANUP_INTERVAL_MS); + + // 在 Node.js 环境中避免阻止程序退出 + if (typeof process !== 'undefined' && cleanupTimer.unref) { + cleanupTimer.unref(); + } +}