From 88f14290b5ac1b580ce6369e32837b2693fc2b35 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 18 Nov 2025 16:52:41 +0800 Subject: [PATCH] refactor: remove unused accessibility utilities and hooks - Deleted accessibility index file and related exports. - Removed `useClickOutside` and `useClickOutsideMultiple` hooks for handling outside clicks. - Eliminated `useSearchStream` hook and its associated logic for managing search state. - Cleaned up `useMediaQuery` by removing predefined breakpoints and related utility hooks. - Removed `source-checker` utility for checking video source availability. - Refactored `client.ts` to simplify error handling and remove unused functions. - Streamlined `video-sources.ts` by removing custom source management functions. - Deleted `search-history-store.ts` and related search history management logic. - Removed unused types and interfaces from `types/index.ts`. - Cleaned up search utilities, focusing on relevance scoring and optimization. --- app/api/search-stream/route.ts | 172 ----------- app/api/search/route.ts | 235 -------------- app/page.tsx | 1 - components/search/EmptyState.tsx | 48 --- components/search/SearchForm.tsx | 34 -- components/search/SearchHistoryDropdown.tsx | 162 ---------- lib/accessibility/focus-management.ts | 36 +-- lib/accessibility/focus-trap.ts | 69 ----- lib/accessibility/index.ts | 9 - lib/api/client.ts | 88 +----- lib/api/video-sources.ts | 211 +------------ lib/hooks/useClickOutside.ts | 165 ---------- lib/hooks/useMediaQuery.ts | 38 --- lib/hooks/useSearchStream.ts | 182 ----------- lib/store/history-store.ts | 10 - lib/store/search-history-store.ts | 70 ----- lib/types/index.ts | 64 ---- lib/utils/search.ts | 259 +--------------- lib/utils/source-checker.ts | 326 -------------------- 19 files changed, 18 insertions(+), 2161 deletions(-) delete mode 100644 app/api/search-stream/route.ts delete mode 100644 app/api/search/route.ts delete mode 100644 components/search/EmptyState.tsx delete mode 100644 components/search/SearchHistoryDropdown.tsx delete mode 100644 lib/accessibility/focus-trap.ts delete mode 100644 lib/accessibility/index.ts delete mode 100644 lib/hooks/useClickOutside.ts delete mode 100644 lib/hooks/useSearchStream.ts delete mode 100644 lib/store/search-history-store.ts delete mode 100644 lib/utils/source-checker.ts diff --git a/app/api/search-stream/route.ts b/app/api/search-stream/route.ts deleted file mode 100644 index a9dad57..0000000 --- a/app/api/search-stream/route.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Streaming Search API Route - * Returns results progressively as they become available - */ - -import { NextRequest } from 'next/server'; -import { searchVideos } from '@/lib/api/client'; -import { getSourceById } from '@/lib/api/video-sources'; -import { checkVideoAvailability } from '@/lib/utils/source-checker'; - -export async function POST(request: NextRequest) { - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - async start(controller) { - try { - const body = await request.json(); - const { query, sources: sourceIds, page = 1 } = body; - - // Validate input - if (!query || typeof query !== 'string' || query.trim().length === 0) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: 'Invalid query' })}\n\n`)); - controller.close(); - return; - } - - // Get source configurations - const sources = sourceIds - .map((id: string) => getSourceById(id)) - .filter((source: any): source is NonNullable => source !== undefined); - - if (sources.length === 0) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: 'No valid sources' })}\n\n`)); - controller.close(); - return; - } - - // Send progress: searching sources - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'progress', - stage: 'searching', - checkedSources: 0, - totalSources: sourceIds.length - })}\n\n`)); - - // Perform search with progress tracking for each source - let checkedSourcesCount = 0; - const searchResults = await Promise.all( - sources.map(async (source: any) => { - try { - const result = await searchVideos(query.trim(), [source], page); - checkedSourcesCount++; - - // Send progress update after each source completes - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'progress', - stage: 'searching', - checkedSources: checkedSourcesCount, - totalSources: sourceIds.length - })}\n\n`)); - - return result[0]; - } catch (error) { - checkedSourcesCount++; - - // Still send progress even on error - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'progress', - stage: 'searching', - checkedSources: checkedSourcesCount, - totalSources: sourceIds.length - })}\n\n`)); - - return { - results: [], - source: source.id, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }) - ); - - // Get all videos from all sources - const allVideos = searchResults.flatMap(r => r.results); - - if (allVideos.length === 0) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'complete', - totalResults: 0 - })}\n\n`)); - controller.close(); - return; - } - - // Send progress: start checking videos - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'progress', - stage: 'checking', - checkedVideos: 0, - totalVideos: allVideos.length - })}\n\n`)); - - const availableVideos: any[] = []; - let checkedCount = 0; - const concurrency = 10; // Check 10 videos at a time - - // Process videos in batches for immediate feedback - for (let i = 0; i < allVideos.length; i += concurrency) { - const batch = allVideos.slice(i, i + concurrency); - - const results = await Promise.all( - batch.map(async (video) => { - const isAvailable = await checkVideoAvailability(video); - return isAvailable ? video : null; - }) - ); - - // Add available videos - const newAvailableVideos = results.filter(v => v !== null); - availableVideos.push(...newAvailableVideos); - - checkedCount += batch.length; - - // ALWAYS send update after each batch (even if no new videos) - if (newAvailableVideos.length > 0) { - // Send new videos immediately - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'videos', - videos: newAvailableVideos, - checkedVideos: checkedCount, - totalVideos: allVideos.length, - availableCount: availableVideos.length - })}\n\n`)); - } - - // Always send progress update - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'progress', - stage: 'checking', - checkedVideos: checkedCount, - totalVideos: allVideos.length, - availableCount: availableVideos.length - })}\n\n`)); - } - - // Send completion - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'complete', - totalResults: availableVideos.length, - checkedVideos: allVideos.length, - totalVideos: allVideos.length - })}\n\n`)); - - controller.close(); - } catch (error) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ - type: 'error', - error: error instanceof Error ? error.message : 'Unknown error' - })}\n\n`)); - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - }); -} diff --git a/app/api/search/route.ts b/app/api/search/route.ts deleted file mode 100644 index 10ef6d8..0000000 --- a/app/api/search/route.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Search API Route - * Handles video search requests and aggregates results from multiple sources - * Now with automatic source availability detection - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { searchVideos } from '@/lib/api/client'; -import { getEnabledSources, getSourceById } from '@/lib/api/video-sources'; -import { checkMultipleVideos } from '@/lib/utils/source-checker'; -import type { SearchRequest, SearchResult } from '@/lib/types'; - -export async function POST(request: NextRequest) { - try { - const body: SearchRequest = await request.json(); - const { query, sources: sourceIds, page = 1 } = body; - - // Validate input - if (!query || typeof query !== 'string' || query.trim().length === 0) { - return NextResponse.json( - { error: 'Invalid or missing query parameter' }, - { status: 400 } - ); - } - - if (!sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) { - return NextResponse.json( - { error: 'At least one source must be specified' }, - { status: 400 } - ); - } - - // Get source configurations - const sources = sourceIds - .map((id: string) => getSourceById(id)) - .filter((source): source is NonNullable => source !== undefined); - - if (sources.length === 0) { - return NextResponse.json( - { error: 'No valid sources found' }, - { status: 400 } - ); - } - - // Perform parallel search across sources - const searchResults = await searchVideos(query.trim(), sources, page); - - // Get source name mapping - const getSourceName = (sourceId: string): string => { - const sourceNames: Record = { - 'dytt': '电影天堂', - 'ruyi': '如意', - 'baofeng': '暴风', - 'tianya': '天涯', - 'feifan': '非凡影视', - 'sanliuling': '360', - 'wolong': '卧龙', - 'jisu': '极速', - 'mozhua': '魔爪', - 'modu': '魔都', - 'zuida': '最大', - 'yinghua': '樱花', - 'baiduyun': '百度云', - 'wujin': '无尽', - 'wangwang': '旺旺', - 'ikun': 'iKun', - }; - return sourceNames[sourceId] || sourceId; - }; - - // Get all videos from all sources - const allVideos = searchResults.flatMap(r => r.results); - - // Check each video individually with improved accuracy (reduced concurrency) - const availableVideos = await checkMultipleVideos(allVideos, 8); - - // Group available videos by source - const videosBySource = new Map(); - for (const video of availableVideos) { - const sourceId = video.source; - if (!videosBySource.has(sourceId)) { - videosBySource.set(sourceId, []); - } - videosBySource.get(sourceId)!.push(video); - } - - // Build response with actual video counts per source - const response: SearchResult[] = Array.from(videosBySource.entries()).map(([sourceId, videos]) => ({ - results: videos, - source: sourceId, - responseTime: searchResults.find(sr => sr.source === sourceId)?.responseTime, - })); - - // Calculate source statistics - const sourceStats = sourceIds.map(sourceId => { - const count = videosBySource.get(sourceId)?.length || 0; - return { - sourceId, - sourceName: getSourceName(sourceId), - count, - }; - }); - - return NextResponse.json({ - success: true, - query: query.trim(), - page, - sources: response, - totalResults: availableVideos.length, - sourceStats, // Include real counts per source - }); - } catch (error) { - console.error('Search API error:', error); - - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Internal server error', - }, - { status: 500 } - ); - } -} - -// Support GET method for simple queries -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const query = searchParams.get('q') || searchParams.get('query'); - const sourcesParam = searchParams.get('sources'); - const page = parseInt(searchParams.get('page') || '1', 10); - - if (!query) { - return NextResponse.json( - { error: 'Missing query parameter' }, - { status: 400 } - ); - } - - // Use all enabled sources if not specified - const sourceIds = sourcesParam - ? sourcesParam.split(',') - : getEnabledSources().map(s => s.id); - - // Get source configurations - const sources = sourceIds - .map((id: string) => getSourceById(id)) - .filter((source): source is NonNullable => source !== undefined); - - if (sources.length === 0) { - return NextResponse.json( - { error: 'No valid sources found' }, - { status: 400 } - ); - } - - // Perform search - const searchResults = await searchVideos(query.trim(), sources, page); - - // Get source name mapping - const getSourceName = (sourceId: string): string => { - const sourceNames: Record = { - 'dytt': '电影天堂', - 'ruyi': '如意', - 'baofeng': '暴风', - 'tianya': '天涯', - 'feifan': '非凡影视', - 'sanliuling': '360', - 'wolong': '卧龙', - 'jisu': '极速', - 'mozhua': '魔爪', - 'modu': '魔都', - 'zuida': '最大', - 'yinghua': '樱花', - 'baiduyun': '百度云', - 'wujin': '无尽', - 'wangwang': '旺旺', - 'ikun': 'iKun', - }; - return sourceNames[sourceId] || sourceId; - }; - - // Get all videos from all sources - const allVideos = searchResults.flatMap(r => r.results); - - // Check each video individually with improved accuracy (reduced concurrency) - const availableVideos = await checkMultipleVideos(allVideos, 8); - - // Group available videos by source - const videosBySource = new Map(); - for (const video of availableVideos) { - const sourceId = video.source; - if (!videosBySource.has(sourceId)) { - videosBySource.set(sourceId, []); - } - videosBySource.get(sourceId)!.push(video); - } - - // Build response with actual video counts per source - const response: SearchResult[] = Array.from(videosBySource.entries()).map(([sourceId, videos]) => ({ - results: videos, - source: sourceId, - responseTime: searchResults.find(sr => sr.source === sourceId)?.responseTime, - })); - - // Calculate source statistics - const sourceStats = sourceIds.map(sourceId => { - const count = videosBySource.get(sourceId)?.length || 0; - return { - sourceId, - sourceName: getSourceName(sourceId), - count, - }; - }); - - return NextResponse.json({ - success: true, - query: query.trim(), - page, - sources: response, - totalResults: availableVideos.length, - sourceStats, // Include real counts per source - }); - } catch (error) { - console.error('Search API error:', error); - - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Internal server error', - }, - { status: 500 } - ); - } -} diff --git a/app/page.tsx b/app/page.tsx index d2505b7..2e4f65d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,7 +7,6 @@ import Image from 'next/image'; import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { SearchForm } from '@/components/search/SearchForm'; import { VideoGrid } from '@/components/search/VideoGrid'; -import { EmptyState } from '@/components/search/EmptyState'; import { NoResults } from '@/components/search/NoResults'; import { ResultsHeader } from '@/components/search/ResultsHeader'; import { TypeBadges } from '@/components/search/TypeBadges'; diff --git a/components/search/EmptyState.tsx b/components/search/EmptyState.tsx deleted file mode 100644 index 8030015..0000000 --- a/components/search/EmptyState.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { Card } from '@/components/ui/Card'; -import { Icons } from '@/components/ui/Icon'; - -export function EmptyState() { - return ( -
-
-
- -
-

- 开始探索精彩内容 -

-

- 在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源 -

- -
- -
- -
-

极速搜索

-

多源并行,秒级响应

-
- -
- -
-

精准匹配

-

智能算法,结果精准

-
- -
- -
-

极致体验

-

流畅播放,完美适配

-
-
-
-
- ); -} diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx index 932f663..bae4948 100644 --- a/components/search/SearchForm.tsx +++ b/components/search/SearchForm.tsx @@ -5,8 +5,6 @@ import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Icons } from '@/components/ui/Icon'; import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation'; -import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown'; -import { useSearchHistoryStore } from '@/lib/store/search-history-store'; interface SearchFormProps { onSearch: (query: string) => void; @@ -28,10 +26,7 @@ export function SearchForm({ totalSources = 16, }: SearchFormProps) { const [query, setQuery] = useState(initialQuery); - const [showHistory, setShowHistory] = useState(false); - const [inputRect, setInputRect] = useState(null); const inputRef = useRef(null); - const { addSearchHistory } = useSearchHistoryStore(); // Update query when initialQuery changes useEffect(() => { @@ -41,9 +36,7 @@ export function SearchForm({ const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (query.trim() && !isLoading) { - addSearchHistory(query.trim()); onSearch(query); - setShowHistory(false); } }; @@ -52,20 +45,6 @@ export function SearchForm({ if (onClear) { onClear(); } - setShowHistory(false); - }; - - const handleInputFocus = () => { - if (inputRef.current) { - setInputRect(inputRef.current.getBoundingClientRect()); - setShowHistory(true); - } - }; - - const handleHistorySelect = (selectedQuery: string) => { - setQuery(selectedQuery); - setShowHistory(false); - onSearch(selectedQuery); }; return ( @@ -76,13 +55,8 @@ export function SearchForm({ type="text" value={query} onChange={(e) => setQuery(e.target.value)} - onFocus={handleInputFocus} placeholder="搜索电影、电视剧、综艺..." className="text-base sm:text-lg pr-24 md:pr-32 truncate" - role="combobox" - aria-expanded={showHistory && !isLoading} - aria-controls="search-history-listbox" - aria-autocomplete="list" aria-label="搜索视频内容" /> {query && ( @@ -107,14 +81,6 @@ export function SearchForm({ - - {/* Search History Dropdown */} - setShowHistory(false)} - inputRect={inputRect} - /> {/* Loading Animation */} {isLoading && ( diff --git a/components/search/SearchHistoryDropdown.tsx b/components/search/SearchHistoryDropdown.tsx deleted file mode 100644 index aad07c9..0000000 --- a/components/search/SearchHistoryDropdown.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Search History Dropdown Component - * 搜索历史下拉组件 - */ - -'use client'; - -import { useEffect, useState, useRef, useCallback } from 'react'; -import { useSearchHistoryStore } from '@/lib/store/search-history-store'; -import { Icons } from '@/components/ui/Icon'; -import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; - -interface SearchHistoryDropdownProps { - isVisible: boolean; - onSelect: (query: string) => void; - onClose: () => void; - inputRect: DOMRect | null; -} - -export function SearchHistoryDropdown({ - isVisible, - onSelect, - onClose, - inputRect, -}: SearchHistoryDropdownProps) { - const { searchHistory, removeSearchHistory, clearSearchHistory } = useSearchHistoryStore(); - const dropdownRef = useRef(null); - const [focusedIndex, setFocusedIndex] = useState(-1); - const itemRefs = useRef<(HTMLAnchorElement | null)[]>([]); - - // Keyboard navigation - useKeyboardNavigation({ - enabled: isVisible, - containerRef: dropdownRef, - currentIndex: focusedIndex, - itemCount: searchHistory.length, - orientation: 'vertical', - onNavigate: useCallback((index: number) => { - setFocusedIndex(index); - itemRefs.current[index]?.focus(); - }, []), - onSelect: useCallback((index: number) => { - if (searchHistory[index]) { - onSelect(searchHistory[index].query); - } - }, [searchHistory, onSelect]), - onEscape: useCallback(() => { - onClose(); - }, [onClose]), - }); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - onClose(); - } - }; - - if (isVisible) { - document.addEventListener('mousedown', handleClickOutside); - // Reset focus when dropdown opens - setFocusedIndex(-1); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isVisible, onClose]); - - if (!isVisible || searchHistory.length === 0) return null; - - const style = inputRect - ? { - position: 'fixed' as const, - top: `${inputRect.bottom + 8}px`, - left: `${inputRect.left}px`, - width: `${inputRect.width}px`, - } - : {}; - - const handleItemClick = (query: string, event: React.MouseEvent) => { - // Check if middle mouse button (opens in new tab) - if (event.button === 1 || event.ctrlKey || event.metaKey) { - event.preventDefault(); - window.open(`/?q=${encodeURIComponent(query)}`, '_blank'); - return; - } - onSelect(query); - }; - - return ( - - ); -} diff --git a/lib/accessibility/focus-management.ts b/lib/accessibility/focus-management.ts index 4a6e6e6..61a711f 100644 --- a/lib/accessibility/focus-management.ts +++ b/lib/accessibility/focus-management.ts @@ -7,7 +7,7 @@ * Get all focusable elements within a container * 获取容器内所有可聚焦的元素 */ -export function getFocusableElements(container: HTMLElement): HTMLElement[] { +function getFocusableElements(container: HTMLElement): HTMLElement[] { const selector = [ 'a[href]', 'button:not([disabled])', @@ -77,37 +77,3 @@ export function trapFocus(container: HTMLElement): () => void { container.removeEventListener('keydown', handleKeyDown); }; } - -/** - * Restore focus to a previously focused element - * 恢复焦点到之前聚焦的元素 - * - * @param element The element to restore focus to - */ -export function restoreFocus(element: HTMLElement | null): void { - if (!element) return; - - // Use requestAnimationFrame to ensure the element is ready - requestAnimationFrame(() => { - if (element && typeof element.focus === 'function') { - element.focus(); - } - }); -} - -/** - * Save and restore focus for a component lifecycle - * 保存并恢复组件生命周期的焦点 - * - * Usage: - * const focusManager = saveFocus(); - * // ... do something that changes focus - * focusManager.restore(); - */ -export function saveFocus() { - const previouslyFocused = document.activeElement as HTMLElement | null; - - return { - restore: () => restoreFocus(previouslyFocused), - }; -} diff --git a/lib/accessibility/focus-trap.ts b/lib/accessibility/focus-trap.ts deleted file mode 100644 index 4532a41..0000000 --- a/lib/accessibility/focus-trap.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Focus Trap Utility - * Traps keyboard focus within a specified container for accessibility - */ - -const FOCUSABLE_ELEMENTS = [ - 'a[href]', - 'button:not([disabled])', - 'textarea:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(','); - -export function getFocusableElements(container: HTMLElement): HTMLElement[] { - return Array.from(container.querySelectorAll(FOCUSABLE_ELEMENTS)).filter( - (el) => !el.hasAttribute('disabled') && el.offsetParent !== null - ); -} - -export function trapFocus(container: HTMLElement): () => void { - const focusableElements = getFocusableElements(container); - - if (focusableElements.length === 0) return () => {}; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - // Store the element that had focus before the trap - const previouslyFocusedElement = document.activeElement as HTMLElement; - - // Focus the first element - firstElement.focus(); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Tab') return; - - if (e.shiftKey) { - // Shift + Tab - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement.focus(); - } - } else { - // Tab - if (document.activeElement === lastElement) { - e.preventDefault(); - firstElement.focus(); - } - } - }; - - container.addEventListener('keydown', handleKeyDown); - - // Return cleanup function - return () => { - container.removeEventListener('keydown', handleKeyDown); - // Restore focus to the previously focused element - if (previouslyFocusedElement && typeof previouslyFocusedElement.focus === 'function') { - previouslyFocusedElement.focus(); - } - }; -} - -export function restoreFocus(element: HTMLElement | null) { - if (element && typeof element.focus === 'function') { - element.focus(); - } -} diff --git a/lib/accessibility/index.ts b/lib/accessibility/index.ts deleted file mode 100644 index d0ab027..0000000 --- a/lib/accessibility/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Accessibility Utilities Index - * 可访问性工具库索引 - Centralized exports for all accessibility utilities - */ - -// Focus Management -export { - trapFocus, -} from './focus-management'; diff --git a/lib/api/client.ts b/lib/api/client.ts index 56dab1e..b42fcff 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -10,7 +10,6 @@ import type { Episode, ApiSearchResponse, ApiDetailResponse, - ApiError, } from '@/lib/types'; const REQUEST_TIMEOUT = 15000; @@ -115,11 +114,12 @@ async function searchVideosBySource( }; } catch (error) { console.error(`Search failed for source ${source.name}:`, error); - throw createApiError( - 'SEARCH_FAILED', - `Failed to search from ${source.name}`, - source.id - ); + throw { + code: 'SEARCH_FAILED', + message: `Failed to search from ${source.name}`, + source: source.id, + retryable: true, + }; } } @@ -170,24 +170,6 @@ function parseEpisodes(playUrl: string): Episode[] { } } -/** - * Extract M3U8 URLs from various formats - */ -function extractM3U8Urls(playUrl: string): string[] { - const urls: string[] = []; - - // Split by common delimiters - const parts = playUrl.split(/[#$]/); - - for (const part of parts) { - if (part.includes('.m3u8') || part.startsWith('http')) { - urls.push(part.trim()); - } - } - - return urls; -} - /** * Get video detail from a single source */ @@ -259,11 +241,12 @@ export async function getVideoDetail( }; } catch (error) { console.error(`Detail fetch failed for source ${source.name}:`, error); - throw createApiError( - 'DETAIL_FAILED', - `Failed to fetch video detail from ${source.name}`, - source.id - ); + throw { + code: 'DETAIL_FAILED', + message: `Failed to fetch video detail from ${source.name}`, + source: source.id, + retryable: false, + }; } } @@ -284,50 +267,3 @@ export async function getVideoDetailCustom( return getVideoDetail(id, customSource); } - -/** - * Test if a video URL is accessible - */ -export async function testVideoUrl(url: string): Promise { - try { - const response = await fetchWithTimeout(url, { method: 'HEAD' }, 5000); - return response.ok; - } catch { - return false; - } -} - -/** - * Create standardized API error - */ -function createApiError( - code: string, - message: string, - source?: string -): ApiError { - return { - code, - message, - source, - retryable: code === 'TIMEOUT' || code === 'NETWORK_ERROR', - }; -} - -/** - * Normalize video data across different API formats - */ -export function normalizeVideoData(data: any, sourceId: string): VideoItem { - return { - vod_id: data.vod_id || data.id, - vod_name: data.vod_name || data.name || data.title, - vod_pic: data.vod_pic || data.pic || data.poster || data.image, - type_name: data.type_name || data.type || data.category, - vod_remarks: data.vod_remarks || data.remarks || data.note, - vod_year: data.vod_year || data.year, - vod_area: data.vod_area || data.area || data.region, - vod_actor: data.vod_actor || data.actor, - vod_director: data.vod_director || data.director, - vod_content: data.vod_content || data.content || data.description, - source: sourceId, - }; -} diff --git a/lib/api/video-sources.ts b/lib/api/video-sources.ts index 3e9a041..da6d0cf 100644 --- a/lib/api/video-sources.ts +++ b/lib/api/video-sources.ts @@ -3,10 +3,7 @@ * Handles third-party video API sources with validation and health checks */ -import type { VideoSource, CustomSourceConfig } from '@/lib/types'; - -const STORAGE_KEY = 'kvideo_custom_sources'; -const HEALTH_CHECK_TIMEOUT = 5000; +import type { VideoSource } from '@/lib/types'; // Default predefined video sources - Real Chinese video APIs const DEFAULT_SOURCES: VideoSource[] = [ @@ -156,214 +153,10 @@ const DEFAULT_SOURCES: VideoSource[] = [ }, ]; -/** - * Get all video sources (default + custom) - */ -export function getAllSources(): VideoSource[] { - const customSources = getCustomSources(); - return [...DEFAULT_SOURCES, ...customSources].filter(s => s.enabled); -} - -/** - * Get enabled sources sorted by priority - */ -export function getEnabledSources(): VideoSource[] { - return getAllSources() - .filter(source => source.enabled !== false) - .sort((a, b) => (a.priority || 999) - (b.priority || 999)); -} - /** * Get source by ID */ export function getSourceById(id: string): VideoSource | undefined { - return getAllSources().find(source => source.id === id); + return DEFAULT_SOURCES.find(source => source.id === id); } -/** - * Get custom sources from localStorage - */ -export function getCustomSources(): VideoSource[] { - if (typeof window === 'undefined') return []; - - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) return []; - - const config: CustomSourceConfig = JSON.parse(stored); - return config.sources || []; - } catch (error) { - console.error('Failed to load custom sources:', error); - return []; - } -} - -/** - * Save custom sources to localStorage - */ -export function saveCustomSources(sources: VideoSource[]): void { - if (typeof window === 'undefined') return; - - try { - const config: CustomSourceConfig = { - sources, - lastUpdated: Date.now(), - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); - } catch (error) { - console.error('Failed to save custom sources:', error); - } -} - -/** - * Add a new custom source - */ -export function addCustomSource(source: VideoSource): boolean { - try { - validateSource(source); - const customSources = getCustomSources(); - - // Check for duplicate ID - if (customSources.some(s => s.id === source.id)) { - throw new Error('Source with this ID already exists'); - } - - customSources.push(source); - saveCustomSources(customSources); - return true; - } catch (error) { - console.error('Failed to add custom source:', error); - return false; - } -} - -/** - * Update an existing custom source - */ -export function updateCustomSource(id: string, updates: Partial): boolean { - try { - const customSources = getCustomSources(); - const index = customSources.findIndex(s => s.id === id); - - if (index === -1) { - throw new Error('Source not found'); - } - - customSources[index] = { ...customSources[index], ...updates }; - validateSource(customSources[index]); - saveCustomSources(customSources); - return true; - } catch (error) { - console.error('Failed to update custom source:', error); - return false; - } -} - -/** - * Remove a custom source - */ -export function removeCustomSource(id: string): boolean { - try { - const customSources = getCustomSources(); - const filtered = customSources.filter(s => s.id !== id); - saveCustomSources(filtered); - return true; - } catch (error) { - console.error('Failed to remove custom source:', error); - return false; - } -} - -/** - * Validate source configuration - */ -export function validateSource(source: VideoSource): void { - if (!source.id || !source.name) { - throw new Error('Source must have id and name'); - } - - if (!source.baseUrl) { - throw new Error('Source must have baseUrl'); - } - - try { - new URL(source.baseUrl); - } catch { - throw new Error('Invalid baseUrl format'); - } - - if (!source.searchPath || !source.detailPath) { - throw new Error('Source must have searchPath and detailPath'); - } -} - -/** - * Perform health check on a source - */ -export async function healthCheckSource(source: VideoSource): Promise<{ - healthy: boolean; - responseTime?: number; - error?: string; -}> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT); - - try { - const startTime = Date.now(); - const url = `${source.baseUrl}${source.searchPath}?ac=list&pg=1`; - - const response = await fetch(url, { - method: 'GET', - headers: source.headers || {}, - signal: controller.signal, - }); - - clearTimeout(timeout); - const responseTime = Date.now() - startTime; - - if (!response.ok) { - return { - healthy: false, - responseTime, - error: `HTTP ${response.status}`, - }; - } - - const data = await response.json(); - - if (data.code !== 1 && data.code !== 0) { - return { - healthy: false, - responseTime, - error: 'Invalid API response format', - }; - } - - return { - healthy: true, - responseTime, - }; - } catch (error) { - clearTimeout(timeout); - return { - healthy: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } -} - -/** - * Health check multiple sources in parallel - */ -export async function healthCheckSources(sources: VideoSource[]): Promise< - Map -> { - const results = await Promise.all( - sources.map(async source => { - const result = await healthCheckSource(source); - return { sourceId: source.id, result }; - }) - ); - - return new Map(results.map(({ sourceId, result }) => [sourceId, result])); -} diff --git a/lib/hooks/useClickOutside.ts b/lib/hooks/useClickOutside.ts deleted file mode 100644 index 4400f65..0000000 --- a/lib/hooks/useClickOutside.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * useClickOutside Hook - * 点击外部关闭 Hook - 检测元素外部的点击事件 - * - * 遵循 Liquid Glass 设计系统原则: - * - 可访问性优先(支持 Escape 键关闭) - * - 性能优化(使用事件委托) - * - 触摸设备友好 - */ - -'use client'; - -import { RefObject, useEffect } from 'react'; - -/** - * 使用点击外部关闭 Hook - * @param ref - 要监听的元素引用 - * @param handler - 点击外部时触发的回调函数 - * @param enabled - 是否启用监听(默认为 true) - * - * @example - * ```tsx - * function Dropdown() { - * const [isOpen, setIsOpen] = useState(false); - * const dropdownRef = useRef(null); - * - * useClickOutside(dropdownRef, () => { - * setIsOpen(false); - * }); - * - * return ( - *
- * - * {isOpen &&
Dropdown Content
} - *
- * ); - * } - * ``` - */ -export function useClickOutside( - ref: RefObject, - handler: () => void, - enabled: boolean = true -): void { - useEffect(() => { - // 如果未启用或 ref 未挂载,则不执行 - if (!enabled || !ref.current) { - return; - } - - /** - * 处理点击事件 - * 检查点击目标是否在元素外部 - */ - const handleClickOutside = (event: MouseEvent | TouchEvent) => { - const target = event.target as Node; - - // 如果点击的目标不在 ref 元素内部,触发 handler - if (ref.current && !ref.current.contains(target)) { - handler(); - } - }; - - /** - * 处理 Escape 键 - * 增强可访问性,允许键盘用户关闭 - */ - const handleEscapeKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - handler(); - } - }; - - // 延迟添加事件监听器,避免立即触发 - // 这样可以防止触发 handler 的同时又注册监听器导致立即关闭 - const timeoutId = setTimeout(() => { - // 监听鼠标点击和触摸事件 - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); - // 监听 Escape 键 - document.addEventListener('keydown', handleEscapeKey); - }, 0); - - // 清理函数 - return () => { - clearTimeout(timeoutId); - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('touchstart', handleClickOutside); - document.removeEventListener('keydown', handleEscapeKey); - }; - }, [ref, handler, enabled]); -} - -/** - * 扩展版本:支持多个引用 - * 当需要排除多个元素时使用 - * - * @example - * ```tsx - * function DropdownWithTrigger() { - * const [isOpen, setIsOpen] = useState(false); - * const dropdownRef = useRef(null); - * const triggerRef = useRef(null); - * - * useClickOutsideMultiple([dropdownRef, triggerRef], () => { - * setIsOpen(false); - * }); - * - * return ( - * <> - * - * {isOpen && ( - *
Dropdown Content
- * )} - * - * ); - * } - * ``` - */ -export function useClickOutsideMultiple( - refs: RefObject[], - handler: () => void, - enabled: boolean = true -): void { - useEffect(() => { - if (!enabled) { - return; - } - - const handleClickOutside = (event: MouseEvent | TouchEvent) => { - const target = event.target as Node; - - // 检查点击是否在任何一个 ref 元素内部 - const isClickInside = refs.some(ref => - ref.current && ref.current.contains(target) - ); - - // 如果点击在所有元素外部,触发 handler - if (!isClickInside) { - handler(); - } - }; - - const handleEscapeKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - handler(); - } - }; - - const timeoutId = setTimeout(() => { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); - document.addEventListener('keydown', handleEscapeKey); - }, 0); - - return () => { - clearTimeout(timeoutId); - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('touchstart', handleClickOutside); - document.removeEventListener('keydown', handleEscapeKey); - }; - }, [refs, handler, enabled]); -} diff --git a/lib/hooks/useMediaQuery.ts b/lib/hooks/useMediaQuery.ts index ef15d27..b29157b 100644 --- a/lib/hooks/useMediaQuery.ts +++ b/lib/hooks/useMediaQuery.ts @@ -79,41 +79,3 @@ export function useMediaQuery(query: string): boolean { return matches; } - -/** - * 预定义的断点常量(基于 Tailwind CSS) - * 与 Liquid Glass 设计系统的响应式规范一致 - */ -export const BREAKPOINTS = { - sm: '(min-width: 640px)', - md: '(min-width: 768px)', - lg: '(min-width: 1024px)', - xl: '(min-width: 1280px)', - '2xl': '(min-width: 1536px)', - - // 反向查询(小于某个断点) - maxSm: '(max-width: 639px)', - maxMd: '(max-width: 767px)', - maxLg: '(max-width: 1023px)', - maxXl: '(max-width: 1279px)', - max2xl: '(max-width: 1535px)', - - // 特殊查询 - mobile: '(max-width: 768px)', - tablet: '(min-width: 768px) and (max-width: 1024px)', - desktop: '(min-width: 1024px)', - touch: '(hover: none) and (pointer: coarse)', - darkMode: '(prefers-color-scheme: dark)', - lightMode: '(prefers-color-scheme: light)', - reducedMotion: '(prefers-reduced-motion: reduce)', -} as const; - -/** - * 便捷的断点检测 Hooks - */ -export const useIsMobile = () => useMediaQuery(BREAKPOINTS.mobile); -export const useIsTablet = () => useMediaQuery(BREAKPOINTS.tablet); -export const useIsDesktop = () => useMediaQuery(BREAKPOINTS.desktop); -export const useIsTouchDevice = () => useMediaQuery(BREAKPOINTS.touch); -export const usePrefersDarkMode = () => useMediaQuery(BREAKPOINTS.darkMode); -export const usePrefersReducedMotion = () => useMediaQuery(BREAKPOINTS.reducedMotion); diff --git a/lib/hooks/useSearchStream.ts b/lib/hooks/useSearchStream.ts deleted file mode 100644 index 1c9541b..0000000 --- a/lib/hooks/useSearchStream.ts +++ /dev/null @@ -1,182 +0,0 @@ -'use client'; - -import { useState, useRef, useCallback } from 'react'; -import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names'; - -export interface SearchStreamResult { - loading: boolean; - results: any[]; - availableSources: any[]; - checkedSources: number; - searchStage: 'searching' | 'checking'; - checkedVideos: number; - totalVideos: number; - currentSource: string; - performSearch: (query: string, shouldClearCache?: boolean) => Promise; - resetSearch: () => void; -} - -export function useSearchStream( - onCacheUpdate: (query: string, results: any[], sources: any[]) => void, - onUrlUpdate: (query: string) => void -): SearchStreamResult { - const [loading, setLoading] = useState(false); - const [results, setResults] = useState([]); - const [availableSources, setAvailableSources] = useState([]); - const [checkedSources, setCheckedSources] = useState(0); - const [searchStage, setSearchStage] = useState<'searching' | 'checking'>('searching'); - const [checkedVideos, setCheckedVideos] = useState(0); - const [totalVideos, setTotalVideos] = useState(0); - const [currentSource, setCurrentSource] = useState(''); - const abortControllerRef = useRef(null); - - const performSearch = useCallback(async (searchQuery: string, shouldClearCache: boolean = true) => { - if (!searchQuery.trim() || loading) return; - - // Abort any ongoing search - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); - - // Reset state - setLoading(true); - setResults([]); - setAvailableSources([]); - setCheckedSources(0); - setSearchStage('searching'); - setCheckedVideos(0); - setTotalVideos(0); - - // Update URL - onUrlUpdate(searchQuery); - - try { - const response = await fetch('/api/search-stream', { - 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 allVideos: any[] = []; - const sourceVideoCounts = new Map(); - - 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 === 'progress') { - if (data.stage === 'searching') { - setSearchStage('searching'); - setCheckedSources(data.checkedSources); - } else if (data.stage === 'checking') { - setSearchStage('checking'); - setCheckedVideos(data.checkedVideos); - setTotalVideos(data.totalVideos); - } - } else if (data.type === 'videos') { - const newVideos = data.videos.map((video: any) => ({ - ...video, - sourceName: getSourceName(video.source), - isNew: true, - addedAt: Date.now(), - })); - - allVideos.push(...newVideos); - setResults([...allVideos]); - setCheckedVideos(data.checkedVideos); - setTotalVideos(data.totalVideos); - - // Update source counts - newVideos.forEach((video: any) => { - const count = sourceVideoCounts.get(video.source) || 0; - sourceVideoCounts.set(video.source, count + 1); - }); - - const sourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({ - id: sourceId, - name: getSourceName(sourceId), - count, - })); - setAvailableSources(sourcesArray); - - // Remove "new" animation after delay - setTimeout(() => { - setResults(prev => prev.map(v => { - const wasJustAdded = newVideos.some((nv: any) => - nv.vod_id === v.vod_id && nv.source === v.source && nv.addedAt === v.addedAt - ); - return wasJustAdded ? { ...v, isNew: false } : v; - })); - }, 300); - } else if (data.type === 'complete') { - setCheckedVideos(data.totalVideos); - setLoading(false); - - const finalSourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({ - id: sourceId, - name: getSourceName(sourceId), - count, - })); - - // Save to cache - onCacheUpdate(searchQuery, allVideos, finalSourcesArray); - } else if (data.type === 'error') { - throw new Error(data.error); - } - } catch (err) { - // Skip invalid JSON lines - } - } - } - } catch (error: any) { - if (error.name !== 'AbortError') { - console.error('Search error:', error); - } - setLoading(false); - } finally { - setCurrentSource(''); - } - }, [loading, onCacheUpdate, onUrlUpdate]); - - const resetSearch = useCallback(() => { - setResults([]); - setAvailableSources([]); - setCheckedSources(0); - setSearchStage('searching'); - setCheckedVideos(0); - setTotalVideos(0); - setCurrentSource(''); - }, []); - - return { - loading, - results, - availableSources, - checkedSources, - searchStage, - checkedVideos, - totalVideos, - currentSource, - performSearch, - resetSearch, - }; -} diff --git a/lib/store/history-store.ts b/lib/store/history-store.ts index 5996157..7857868 100644 --- a/lib/store/history-store.ts +++ b/lib/store/history-store.ts @@ -167,13 +167,3 @@ export const useHistoryStore = create()( } ) ); - -// Selector hooks -export const useViewingHistory = () => useHistoryStore((state) => state.viewingHistory); -export const useHistoryActions = () => - useHistoryStore((state) => ({ - addToHistory: state.addToHistory, - updateProgress: state.updateProgress, - removeFromHistory: state.removeFromHistory, - clearHistory: state.clearHistory, - })); diff --git a/lib/store/search-history-store.ts b/lib/store/search-history-store.ts deleted file mode 100644 index d4cc165..0000000 --- a/lib/store/search-history-store.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Search History Store - * 搜索历史记录存储 - */ - -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -const MAX_SEARCH_HISTORY = 20; - -export interface SearchHistoryItem { - query: string; - timestamp: number; -} - -interface SearchHistoryStore { - searchHistory: SearchHistoryItem[]; - - addSearchHistory: (query: string) => void; - removeSearchHistory: (query: string) => void; - clearSearchHistory: () => void; - getSearchHistory: () => SearchHistoryItem[]; -} - -export const useSearchHistoryStore = create()( - persist( - (set, get) => ({ - searchHistory: [], - - addSearchHistory: (query: string) => { - const trimmedQuery = query.trim(); - if (!trimmedQuery) return; - - set((state) => { - // 移除已存在的相同搜索 - const filtered = state.searchHistory.filter( - (item) => item.query !== trimmedQuery - ); - - // 添加新搜索到顶部 - const newHistory = [ - { query: trimmedQuery, timestamp: Date.now() }, - ...filtered, - ].slice(0, MAX_SEARCH_HISTORY); - - return { searchHistory: newHistory }; - }); - }, - - removeSearchHistory: (query: string) => { - set((state) => ({ - searchHistory: state.searchHistory.filter( - (item) => item.query !== query - ), - })); - }, - - clearSearchHistory: () => { - set({ searchHistory: [] }); - }, - - getSearchHistory: () => { - return get().searchHistory; - }, - }), - { - name: 'kvideo-search-history', - } - ) -); diff --git a/lib/types/index.ts b/lib/types/index.ts index d9b4a33..3e875de 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -53,24 +53,6 @@ export interface VideoDetail { source_code: string; } -// Playback State -export interface PlayerState { - currentVideo: { - id: string | number; - title: string; - url: string; - source: string; - episodeIndex: number; - } | null; - episodes: Episode[]; - playbackPosition: number; - duration: number; - isPlaying: boolean; - autoplayNext: boolean; - volume: number; - playbackRate: number; -} - // History Entry export interface VideoHistoryItem { videoId: string | number; @@ -116,20 +98,6 @@ export interface ApiDetailResponse { }>; } -// Search Request/Response Types -export interface SearchRequest { - query: string; - sources: string[]; - page?: number; -} - -export interface SearchResult { - results: VideoItem[]; - source: string; - responseTime?: number; - error?: string; -} - // Detail Request Types export interface DetailRequest { id: string | number; @@ -137,35 +105,3 @@ export interface DetailRequest { customApi?: string; } -// Source Speed Test Result -export interface SourceSpeedResult { - source: string; - sourceName: string; - speed: number; // milliseconds - available: boolean; - error?: string; - videoDetail?: VideoDetail; -} - -// Error Types -export interface ApiError { - code: string; - message: string; - source?: string; - retryable: boolean; -} - -// Progress Storage -export interface VideoProgress { - videoId: string | number; - position: number; - duration: number; - timestamp: number; - episodeIndex: number; -} - -// Custom Source Configuration -export interface CustomSourceConfig { - sources: VideoSource[]; - lastUpdated: number; -} diff --git a/lib/utils/search.ts b/lib/utils/search.ts index 09ff220..7c97d20 100644 --- a/lib/utils/search.ts +++ b/lib/utils/search.ts @@ -1,263 +1,9 @@ /** * Search Utilities - * Debouncing, result merging, and search optimization + * Search relevance scoring and optimization */ -import type { VideoItem, SearchResult } from '@/lib/types'; - -/** - * Debounce function for search input - */ -export function debounce any>( - func: T, - delay: number = 500 -): (...args: Parameters) => void { - let timeoutId: ReturnType; - - return function (this: any, ...args: Parameters) { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => func.apply(this, args), delay); - }; -} - -/** - * Throttle function for limiting function calls - */ -export function throttle any>( - func: T, - limit: number -): (...args: Parameters) => void { - let inThrottle: boolean; - - return function (this: any, ...args: Parameters) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => (inThrottle = false), limit); - } - }; -} - -/** - * Merge and deduplicate search results from multiple sources - */ -export function mergeSearchResults(results: SearchResult[]): VideoItem[] { - const seenTitles = new Set(); - const mergedResults: VideoItem[] = []; - - // Process results by response time (fastest first) - const sortedResults = [...results].sort((a, b) => { - const timeA = a.responseTime || Infinity; - const timeB = b.responseTime || Infinity; - return timeA - timeB; - }); - - for (const result of sortedResults) { - if (result.error) continue; - - for (const item of result.results) { - // Normalize title for comparison - const normalizedTitle = normalizeTitle(item.vod_name); - - if (!seenTitles.has(normalizedTitle)) { - seenTitles.add(normalizedTitle); - mergedResults.push(item); - } - } - } - - return mergedResults; -} - -/** - * Normalize title for deduplication - */ -function normalizeTitle(title: string): string { - return title - .toLowerCase() - .trim() - .replace(/[^\w\s]/g, '') // Remove special characters - .replace(/\s+/g, ' '); // Normalize whitespace -} - -/** - * Group search results by source - */ -export function groupResultsBySource( - results: SearchResult[] -): Map { - const grouped = new Map(); - - for (const result of results) { - if (!result.error && result.results.length > 0) { - grouped.set(result.source, result.results); - } - } - - return grouped; -} - -/** - * Filter search results by criteria - */ -export interface SearchFilters { - year?: string; - area?: string; - type?: string; - keyword?: string; -} - -export function filterResults( - results: VideoItem[], - filters: SearchFilters -): VideoItem[] { - return results.filter(item => { - if (filters.year && item.vod_year !== filters.year) { - return false; - } - - if (filters.area && item.vod_area !== filters.area) { - return false; - } - - if (filters.type && item.type_name !== filters.type) { - return false; - } - - if (filters.keyword) { - const keyword = filters.keyword.toLowerCase(); - const searchText = `${item.vod_name} ${item.vod_actor || ''} ${item.vod_director || ''}`.toLowerCase(); - if (!searchText.includes(keyword)) { - return false; - } - } - - return true; - }); -} - -/** - * Sort search results - */ -export type SortOption = 'relevance' | 'year' | 'name' | 'updated'; - -export function sortResults( - results: VideoItem[], - sortBy: SortOption = 'relevance' -): VideoItem[] { - const sorted = [...results]; - - switch (sortBy) { - case 'year': - sorted.sort((a, b) => { - const yearA = parseInt(a.vod_year || '0'); - const yearB = parseInt(b.vod_year || '0'); - return yearB - yearA; - }); - break; - - case 'name': - sorted.sort((a, b) => a.vod_name.localeCompare(b.vod_name)); - break; - - case 'updated': - // Assuming vod_remarks contains update info - sorted.sort((a, b) => { - const remarkA = a.vod_remarks || ''; - const remarkB = b.vod_remarks || ''; - return remarkB.localeCompare(remarkA); - }); - break; - - case 'relevance': - default: - // Keep original order (sorted by API) - break; - } - - return sorted; -} - -/** - * Highlight search query in text - */ -export function highlightQuery(text: string, query: string): string { - if (!query || !text) return text; - - const regex = new RegExp(`(${escapeRegex(query)})`, 'gi'); - return text.replace(regex, '$1'); -} - -/** - * Escape special regex characters - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Extract search suggestions from query - */ -export function getSearchSuggestions( - query: string, - history: string[] -): string[] { - if (!query) return []; - - const normalizedQuery = query.toLowerCase(); - - return history - .filter(item => item.toLowerCase().includes(normalizedQuery)) - .slice(0, 5); -} - -/** - * Save search query to history - */ -const SEARCH_HISTORY_KEY = 'kvideo_search_history'; -const MAX_SEARCH_HISTORY = 20; - -export function saveSearchQuery(query: string): void { - if (typeof window === 'undefined' || !query.trim()) return; - - try { - const history = getSearchHistory(); - - // Remove duplicate - const filtered = history.filter( - item => item.toLowerCase() !== query.toLowerCase() - ); - - // Add to front - const updated = [query, ...filtered].slice(0, MAX_SEARCH_HISTORY); - - localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); - } catch (error) { - console.error('Failed to save search query:', error); - } -} - -/** - * Get search history - */ -export function getSearchHistory(): string[] { - if (typeof window === 'undefined') return []; - - try { - const stored = localStorage.getItem(SEARCH_HISTORY_KEY); - return stored ? JSON.parse(stored) : []; - } catch { - return []; - } -} - -/** - * Clear search history - */ -export function clearSearchHistory(): void { - if (typeof window === 'undefined') return; - localStorage.removeItem(SEARCH_HISTORY_KEY); -} +import type { VideoItem } from '@/lib/types'; /** * Calculate search relevance score @@ -379,3 +125,4 @@ export function calculateRelevanceScore(item: VideoItem, query: string): number return Math.max(0, score); // Ensure non-negative } + diff --git a/lib/utils/source-checker.ts b/lib/utils/source-checker.ts deleted file mode 100644 index e39a1c3..0000000 --- a/lib/utils/source-checker.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Source Availability Checker - * Pre-validates video sources during search to filter out unavailable ones - */ - -import { isValidUrlFormat } from './url-validator'; - -const CHECK_TIMEOUT = 5000; // 5 seconds per check (increased for more reliability) -const MAX_RETRIES = 1; // Reduced retries to speed up detection -const MIN_CONTENT_LENGTH = 1024; // Minimum content size to consider valid video - -export interface SourceCheckResult { - sourceId: string; - sourceName: string; - isAvailable: boolean; - sampleUrl?: string; - error?: string; - checkedAt: number; -} - -/** - * Check if a single video URL is accessible and actually contains video content - * More accurate detection with multiple validation steps - */ -async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise { - if (!isValidUrlFormat(url)) { - return false; - } - - for (let attempt = 0; attempt <= retries; attempt++) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), CHECK_TIMEOUT); - - // First, try HEAD request to check if resource exists without downloading - let response: Response; - try { - response = await fetch(url, { - method: 'HEAD', - signal: controller.signal, - headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', - 'Referer': new URL(url).origin, - }, - }); - - // If HEAD request fails or returns bad status, try GET with Range - if (!response.ok && response.status !== 206) { - throw new Error('HEAD request failed'); - } - } catch (headError) { - // Fallback to GET with Range if HEAD fails - response = await fetch(url, { - method: 'GET', - signal: controller.signal, - headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', - 'Referer': new URL(url).origin, - 'Range': 'bytes=0-2048', // Fetch first 2KB to verify - }, - }); - } - - clearTimeout(timeoutId); - - // Check response status - // 200 OK: Full content - // 206 Partial Content: Range request successful - // 403 Forbidden: Not accessible (should fail) - // 404 Not Found: Video doesn't exist (should fail) - if (!response.ok && response.status !== 206) { - return false; - } - - // Additional validation checks - const contentType = response.headers.get('content-type'); - const contentLength = response.headers.get('content-length'); - const acceptRanges = response.headers.get('accept-ranges'); - - // Check 1: Content type validation - const hasValidContentType = contentType && ( - contentType.includes('video') || - contentType.includes('mpegurl') || - contentType.includes('m3u8') || - contentType.includes('application/vnd.apple.mpegurl') || - contentType.includes('octet-stream') - ); - - // Check 2: Content length validation (should have some content) - // For m3u8 files, length can be small, so we're more lenient - const hasValidLength = !contentLength || - parseInt(contentLength) >= MIN_CONTENT_LENGTH || - (contentType && (contentType.includes('m3u8') || contentType.includes('mpegurl'))); - - // Check 3: For video files, check if server supports range requests (good sign) - const supportsRanges = acceptRanges === 'bytes' || response.status === 206; - - // Video must pass content type check AND either have valid length OR support ranges - if (hasValidContentType && (hasValidLength || supportsRanges)) { - return true; - } - - return false; - } catch (error) { - // If last attempt, return false - if (attempt === retries) { - return false; - } - // Wait before retry - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - return false; -} - -/** - * Extract first playable URL from video data - * More robust parsing with better error handling - */ -function extractFirstVideoUrl(video: any): string | null { - if (!video.vod_play_url) return null; - - try { - // Format: "Episode1$url1#Episode2$url2#..." or sometimes "url1#url2#url3" - const episodes = video.vod_play_url.split('#').filter((ep: string) => ep.trim()); - - for (const episode of episodes) { - // Try different formats - const parts = episode.split('$'); - - // Format 1: "Episode$url" - if (parts.length >= 2) { - const url = parts[1].trim(); - if (url && isValidUrlFormat(url)) { - return url; - } - } - // Format 2: Just "url" without episode name - else if (parts.length === 1) { - const url = parts[0].trim(); - if (url && isValidUrlFormat(url)) { - return url; - } - } - } - } catch (error) { - console.error('Error extracting video URL:', error); - } - - return null; -} - -/** - * Check if a single video is playable - * Returns false if URL is invalid or video fails validation - */ -export async function checkVideoAvailability(video: any): Promise { - // First check if video has required data - if (!video || !video.vod_play_url) { - return false; - } - - const videoUrl = extractFirstVideoUrl(video); - - if (!videoUrl) { - return false; - } - - // Perform thorough URL check - const isAvailable = await checkVideoUrl(videoUrl); - - // Log failed checks for debugging - if (!isAvailable) { - console.debug(`Video unavailable: ${video.vod_name || 'Unknown'} (${videoUrl.substring(0, 50)}...)`); - } - - return isAvailable; -} - -/** - * Check multiple videos in parallel with concurrency limit - * Improved error handling and progress reporting - */ -export async function checkMultipleVideos( - videos: any[], - concurrency: number = 8, // Reduced default concurrency for better accuracy - onProgress?: (checked: number, total: number) => void -): Promise { - if (!videos || videos.length === 0) { - return []; - } - - const availableVideos: any[] = []; - let checkedCount = 0; - - // Process videos in batches to avoid overwhelming the system - for (let i = 0; i < videos.length; i += concurrency) { - const batch = videos.slice(i, i + concurrency); - - try { - const results = await Promise.all( - batch.map(async (video) => { - try { - const isAvailable = await checkVideoAvailability(video); - checkedCount++; - - // Report progress - if (onProgress) { - onProgress(checkedCount, videos.length); - } - - return isAvailable ? video : null; - } catch (error) { - // If individual check fails, mark as unavailable - checkedCount++; - if (onProgress) { - onProgress(checkedCount, videos.length); - } - return null; - } - }) - ); - - // Add available videos to result - availableVideos.push(...results.filter(v => v !== null)); - } catch (error) { - console.error('Batch check error:', error); - // Continue with next batch even if this one fails - } - } - - console.log(`Checked ${videos.length} videos, ${availableVideos.length} available`); - - return availableVideos; -} - -/** - * Check if a source is available by testing a sample video - * Now checks more videos for better accuracy - */ -export async function checkSourceAvailability( - sourceId: string, - sourceName: string, - sampleVideos: any[] -): Promise { - const startTime = Date.now(); - - // If no videos from this source, mark as unavailable - if (!sampleVideos || sampleVideos.length === 0) { - return { - sourceId, - sourceName, - isAvailable: false, - error: 'No videos found', - checkedAt: Date.now(), - }; - } - - // Try to find at least one working video from up to 5 samples - const samplesToCheck = Math.min(5, sampleVideos.length); - let checkedCount = 0; - - for (const video of sampleVideos.slice(0, samplesToCheck)) { - checkedCount++; - const videoUrl = extractFirstVideoUrl(video); - - if (!videoUrl) { - console.debug(`Source ${sourceName}: Video ${checkedCount} has no valid URL`); - continue; - } - - const isAvailable = await checkVideoUrl(videoUrl); - - if (isAvailable) { - console.log(`✓ Source ${sourceName} is available (verified with ${videoUrl.substring(0, 50)}...)`); - return { - sourceId, - sourceName, - isAvailable: true, - sampleUrl: videoUrl, - checkedAt: Date.now(), - }; - } else { - console.debug(`Source ${sourceName}: Video ${checkedCount}/${samplesToCheck} unavailable`); - } - } - - console.warn(`✗ Source ${sourceName} is unavailable (checked ${checkedCount} videos)`); - return { - sourceId, - sourceName, - isAvailable: false, - error: `All ${checkedCount} sample videos failed to load`, - checkedAt: Date.now(), - }; -} - -/** - * Check multiple sources in parallel - */ -export async function checkMultipleSources( - sourcesWithVideos: Array<{ sourceId: string; sourceName: string; videos: any[] }> -): Promise { - const checkPromises = sourcesWithVideos.map(({ sourceId, sourceName, videos }) => - checkSourceAvailability(sourceId, sourceName, videos) - ); - - return Promise.all(checkPromises); -} - -/** - * Filter search results to only include videos from available sources - */ -export function filterByAvailableSources( - videos: any[], - availableSources: SourceCheckResult[] -): any[] { - const availableSourceIds = new Set( - availableSources - .filter(s => s.isAvailable) - .map(s => s.sourceId) - ); - - return videos.filter(video => availableSourceIds.has(video.source)); -}