mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
- Added AddSourceModal for adding new video sources with validation. - Introduced ExportModal for exporting settings and history options. - Created ImportModal for importing settings from JSON files. - Developed SourceManager for managing video sources, including enabling/disabling, deleting, and reordering. - Established settings store to manage application settings, including default sources and sorting options. - Implemented sorting utility functions for video search results based on various criteria.
266 lines
9.0 KiB
TypeScript
266 lines
9.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useCallback } from 'react';
|
|
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
|
|
import { calculateRelevanceScore } from '@/lib/utils/search';
|
|
import { sortVideos } from '@/lib/utils/sort';
|
|
import type { SortOption } from '@/lib/store/settings-store';
|
|
|
|
interface Video {
|
|
vod_id: string;
|
|
vod_name: string;
|
|
vod_pic?: string;
|
|
vod_remarks?: string;
|
|
vod_year?: string;
|
|
type_name?: string;
|
|
source: string;
|
|
sourceName?: string;
|
|
isNew?: boolean;
|
|
vod_play_url?: string;
|
|
vod_actor?: string;
|
|
vod_director?: string;
|
|
relevanceScore?: number;
|
|
latency?: number; // Response time in milliseconds
|
|
}
|
|
|
|
export interface ParallelSearchResult {
|
|
loading: boolean;
|
|
results: Video[];
|
|
availableSources: any[];
|
|
completedSources: number;
|
|
totalSources: number;
|
|
totalVideosFound: number;
|
|
performSearch: (query: string, sortBy?: SortOption) => Promise<void>;
|
|
resetSearch: () => void;
|
|
loadCachedResults: (results: Video[], sources: any[]) => void;
|
|
applySorting: (sortBy: SortOption) => void;
|
|
}
|
|
|
|
export function useParallelSearch(
|
|
onCacheUpdate: (query: string, results: any[], sources: any[]) => void,
|
|
onUrlUpdate: (query: string) => void
|
|
): ParallelSearchResult {
|
|
const [loading, setLoading] = useState(false);
|
|
const [results, setResults] = useState<Video[]>([]);
|
|
const [availableSources, setAvailableSources] = useState<any[]>([]);
|
|
const [completedSources, setCompletedSources] = useState(0);
|
|
const [totalSources, setTotalSources] = useState(0);
|
|
const [totalVideosFound, setTotalVideosFound] = useState(0);
|
|
const currentQueryRef = useRef<string>('');
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
/**
|
|
* Perform parallel search with streaming results
|
|
*/
|
|
const performSearch = useCallback(async (searchQuery: string, sortBy: SortOption = 'default') => {
|
|
if (!searchQuery.trim()) return;
|
|
|
|
// Abort any ongoing search
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
// Reset state
|
|
setLoading(true);
|
|
setResults([]);
|
|
setAvailableSources([]);
|
|
setCompletedSources(0);
|
|
setTotalSources(0);
|
|
setTotalVideosFound(0);
|
|
currentQueryRef.current = searchQuery.trim();
|
|
|
|
// Update URL
|
|
onUrlUpdate(searchQuery);
|
|
|
|
try {
|
|
const response = await fetch('/api/search-parallel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query: searchQuery, sources: SOURCE_IDS }),
|
|
signal: abortControllerRef.current.signal,
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Search failed');
|
|
|
|
const reader = response.body?.getReader();
|
|
const decoder = new TextDecoder();
|
|
if (!reader) throw new Error('No response stream');
|
|
|
|
let buffer = '';
|
|
const sourcesMap = new Map<string, { count: number; name: string }>();
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue;
|
|
|
|
try {
|
|
const data = JSON.parse(line.slice(6));
|
|
|
|
if (data.type === 'start') {
|
|
setTotalSources(data.totalSources);
|
|
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,
|
|
relevanceScore: calculateRelevanceScore(video, currentQuery),
|
|
}));
|
|
|
|
console.log(`[useParallelSearch] Received ${newVideos.length} videos from source ${data.source}`);
|
|
|
|
// Optimized: Insert new videos in sorted position instead of re-sorting entire array
|
|
setResults((prev) => {
|
|
if (prev.length === 0) return newVideos;
|
|
|
|
// Binary insert for better performance with combined sorting
|
|
const combined = [...prev];
|
|
for (const video of newVideos) {
|
|
const relevanceScore = video.relevanceScore || 0;
|
|
const latency = video.latency || 99999; // Default high latency for sorting
|
|
|
|
// Find insert position using binary search
|
|
// Sort by: 1) relevance score (DESC), 2) latency (ASC)
|
|
let left = 0;
|
|
let right = combined.length;
|
|
while (left < right) {
|
|
const mid = Math.floor((left + right) / 2);
|
|
const midRelevance = combined[mid].relevanceScore || 0;
|
|
const midLatency = combined[mid].latency || 99999;
|
|
|
|
// Compare by relevance first
|
|
if (midRelevance > relevanceScore) {
|
|
left = mid + 1;
|
|
} else if (midRelevance < relevanceScore) {
|
|
right = mid;
|
|
} else {
|
|
// Same relevance, compare by latency (lower is better)
|
|
if (midLatency < latency) {
|
|
left = mid + 1;
|
|
} else {
|
|
right = mid;
|
|
}
|
|
}
|
|
}
|
|
combined.splice(left, 0, video);
|
|
}
|
|
return combined;
|
|
});
|
|
|
|
// Update source stats
|
|
if (!sourcesMap.has(data.source)) {
|
|
sourcesMap.set(data.source, {
|
|
count: newVideos.length,
|
|
name: newVideos[0]?.sourceName || data.source,
|
|
});
|
|
}
|
|
}
|
|
else if (data.type === 'progress') {
|
|
setCompletedSources(data.completedSources);
|
|
setTotalVideosFound(data.totalVideosFound);
|
|
}
|
|
else if (data.type === 'complete') {
|
|
setLoading(false);
|
|
|
|
console.log(`[useParallelSearch] Search complete: ${data.totalVideosFound} videos found`);
|
|
console.log(`[useParallelSearch] Sources with videos: ${sourcesMap.size}`);
|
|
|
|
// Update available sources with correct property names
|
|
const sources = Array.from(sourcesMap.entries()).map(([id, info]) => ({
|
|
id: id, // Changed from sourceId to id
|
|
name: info.name, // Changed from sourceName to name
|
|
count: info.count,
|
|
}));
|
|
setAvailableSources(sources);
|
|
|
|
console.log('[useParallelSearch] Available sources:', sources);
|
|
|
|
// Apply final sorting after all results are received
|
|
setResults((currentResults) => {
|
|
const sorted = sortVideos(currentResults, sortBy);
|
|
|
|
// Cache results
|
|
setTimeout(() => {
|
|
onCacheUpdate(searchQuery, sorted, sources);
|
|
}, 100);
|
|
|
|
return sorted;
|
|
});
|
|
}
|
|
else if (data.type === 'error') {
|
|
console.error('Search error:', data.message);
|
|
setLoading(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing stream data:', error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
console.log('Search aborted');
|
|
} else {
|
|
console.error('Search error:', error);
|
|
}
|
|
setLoading(false);
|
|
}
|
|
}, [loading, onUrlUpdate, onCacheUpdate]);
|
|
|
|
/**
|
|
* Reset search state
|
|
*/
|
|
const resetSearch = useCallback(() => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
setLoading(false);
|
|
setResults([]);
|
|
setAvailableSources([]);
|
|
setCompletedSources(0);
|
|
setTotalSources(0);
|
|
setTotalVideosFound(0);
|
|
currentQueryRef.current = '';
|
|
}, []);
|
|
|
|
/**
|
|
* Load cached results
|
|
*/
|
|
const loadCachedResults = useCallback((cachedResults: Video[], cachedSources: any[]) => {
|
|
console.log('[useParallelSearch] Loading cached results:', cachedResults.length, 'videos');
|
|
setResults(cachedResults);
|
|
setAvailableSources(cachedSources);
|
|
setTotalVideosFound(cachedResults.length);
|
|
}, []);
|
|
|
|
/**
|
|
* Apply sorting to current results
|
|
*/
|
|
const applySorting = useCallback((sortBy: SortOption) => {
|
|
setResults((currentResults) => sortVideos(currentResults, sortBy));
|
|
}, []);
|
|
|
|
return {
|
|
loading,
|
|
results,
|
|
availableSources,
|
|
completedSources,
|
|
totalSources,
|
|
totalVideosFound,
|
|
performSearch,
|
|
resetSearch,
|
|
loadCachedResults,
|
|
applySorting,
|
|
};
|
|
}
|