diff --git a/lib/hooks/useParallelSearch.ts b/lib/hooks/useParallelSearch.ts index 1fec3e9..b859e48 100644 --- a/lib/hooks/useParallelSearch.ts +++ b/lib/hooks/useParallelSearch.ts @@ -3,6 +3,7 @@ import { useState, useRef, useCallback } from 'react'; import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names'; import { checkVideoAvailability } from '@/lib/utils/source-checker'; +import { calculateRelevanceScore } from '@/lib/utils/search'; interface Video { vod_id: string; @@ -16,6 +17,9 @@ interface Video { isNew?: boolean; isVerifying?: boolean; vod_play_url?: string; + vod_actor?: string; + vod_director?: string; + relevanceScore?: number; } export interface ParallelSearchResult { @@ -40,6 +44,7 @@ export function useParallelSearch( const [completedSources, setCompletedSources] = useState(0); const [totalSources, setTotalSources] = useState(0); const [totalVideosFound, setTotalVideosFound] = useState(0); + const currentQueryRef = useRef(''); const abortControllerRef = useRef(null); const verificationQueueRef = useRef([]); @@ -65,12 +70,14 @@ export function useParallelSearch( setResults((prev) => { if (isValid) { - // Remove verifying badge - return prev.map((v) => + // Remove verifying badge, maintain sort order + const updated = prev.map((v) => v.vod_id === video.vod_id && v.source === video.source ? { ...v, isVerifying: false } : v ); + // Re-sort by relevance to maintain order + return updated.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); } else { // Remove invalid video const removedVideo = prev.find( @@ -88,9 +95,11 @@ export function useParallelSearch( ); } - return prev.filter( + const filtered = prev.filter( (v) => !(v.vod_id === video.vod_id && v.source === video.source) ); + // Maintain sort order + return filtered.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); } }); @@ -118,9 +127,11 @@ export function useParallelSearch( ); } - return prev.filter( + const filtered = prev.filter( (v) => !(v.vod_id === video.vod_id && v.source === video.source) ); + // Maintain sort order + return filtered.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); }); // Continue processing queue @@ -159,6 +170,7 @@ export function useParallelSearch( verificationQueueRef.current = []; verifyingCountRef.current = 0; allVideosReceivedRef.current = false; + currentQueryRef.current = searchQuery.trim(); // Update URL onUrlUpdate(searchQuery); @@ -199,17 +211,23 @@ export function useParallelSearch( console.log(`[useParallelSearch] Started search for ${data.totalSources} sources`); } else if (data.type === 'videos') { + const currentQuery = currentQueryRef.current; const newVideos: Video[] = data.videos.map((video: any) => ({ ...video, sourceName: video.sourceDisplayName || getSourceName(video.source), isNew: true, isVerifying: true, // All videos start as verifying + relevanceScore: calculateRelevanceScore(video, currentQuery), })); console.log(`[useParallelSearch] Received ${newVideos.length} videos from source ${data.source}`); - // Add videos to display immediately - setResults((prev) => [...prev, ...newVideos]); + // Add videos and sort by relevance + setResults((prev) => { + const combined = [...prev, ...newVideos]; + // Sort by relevance score (highest first) + return combined.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); + }); // Queue videos for verification queueVideosForVerification(newVideos); @@ -286,6 +304,7 @@ export function useParallelSearch( verificationQueueRef.current = []; verifyingCountRef.current = 0; allVideosReceivedRef.current = false; + currentQueryRef.current = ''; }, []); /** diff --git a/lib/utils/search.ts b/lib/utils/search.ts index 5709512..09ff220 100644 --- a/lib/utils/search.ts +++ b/lib/utils/search.ts @@ -261,40 +261,121 @@ export function clearSearchHistory(): void { /** * Calculate search relevance score + * Higher score = more relevant to the search query */ export function calculateRelevanceScore(item: VideoItem, query: string): number { let score = 0; - const normalizedQuery = query.toLowerCase(); + const normalizedQuery = query.toLowerCase().trim(); + const normalizedTitle = item.vod_name.toLowerCase(); - // Exact title match - if (item.vod_name.toLowerCase() === normalizedQuery) { + // Split query into words for partial matching + const queryWords = normalizedQuery.split(/\s+/); + + // 1. Exact title match (highest priority) + if (normalizedTitle === normalizedQuery) { + score += 1000; + return score; // Early return for perfect match + } + + // 2. Title starts with query (very high priority) + if (normalizedTitle.startsWith(normalizedQuery)) { + score += 500; + } + + // 3. Title contains full query as substring + if (normalizedTitle.includes(normalizedQuery)) { + score += 200; + + // Bonus for query appearing earlier in title + const position = normalizedTitle.indexOf(normalizedQuery); + score += Math.max(0, 50 - position * 2); + } + + // 4. All query words present in title + const allWordsPresent = queryWords.every(word => + normalizedTitle.includes(word) + ); + if (allWordsPresent && queryWords.length > 1) { score += 100; } - // Title starts with query - else if (item.vod_name.toLowerCase().startsWith(normalizedQuery)) { - score += 50; - } - // Title contains query - else if (item.vod_name.toLowerCase().includes(normalizedQuery)) { - score += 25; + + // 5. Individual word matches + queryWords.forEach(word => { + if (word.length < 2) return; // Skip very short words + + if (normalizedTitle.includes(word)) { + score += 30; + + // Bonus if word is at the start + if (normalizedTitle.startsWith(word)) { + score += 20; + } + } + }); + + // 6. Actor match + if (item.vod_actor) { + const normalizedActor = item.vod_actor.toLowerCase(); + if (normalizedActor.includes(normalizedQuery)) { + score += 80; + } + queryWords.forEach(word => { + if (word.length >= 2 && normalizedActor.includes(word)) { + score += 15; + } + }); } - // Actor match - if (item.vod_actor?.toLowerCase().includes(normalizedQuery)) { - score += 10; + // 7. Director match + if (item.vod_director) { + const normalizedDirector = item.vod_director.toLowerCase(); + if (normalizedDirector.includes(normalizedQuery)) { + score += 60; + } + queryWords.forEach(word => { + if (word.length >= 2 && normalizedDirector.includes(word)) { + score += 10; + } + }); } - // Director match - if (item.vod_director?.toLowerCase().includes(normalizedQuery)) { - score += 10; + // 8. Content/description match (if available) + if (item.vod_content) { + const normalizedContent = item.vod_content.toLowerCase(); + if (normalizedContent.includes(normalizedQuery)) { + score += 20; + } } - // Recent year bonus + // 9. Recent year bonus (favor newer content) const currentYear = new Date().getFullYear(); const itemYear = parseInt(item.vod_year || '0'); - if (itemYear >= currentYear - 2) { - score += 5; + if (itemYear > 0) { + const yearDiff = currentYear - itemYear; + if (yearDiff === 0) { + score += 15; // Current year + } else if (yearDiff === 1) { + score += 10; // Last year + } else if (yearDiff <= 3) { + score += 5; // Within 3 years + } } - return score; + // 10. Penalty for very long titles (might be less relevant) + if (item.vod_name.length > 50) { + score -= 5; + } + + // 11. Bonus for HD/quality indicators in remarks + if (item.vod_remarks) { + const remarks = item.vod_remarks.toLowerCase(); + if (remarks.includes('hd') || remarks.includes('1080') || remarks.includes('4k')) { + score += 5; + } + if (remarks.includes('完结') || remarks.includes('全集')) { + score += 3; + } + } + + return Math.max(0, score); // Ensure non-negative }