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.
This commit is contained in:
kuekhaoyang
2025-11-18 16:52:41 +08:00
parent d80e4420b6
commit 88f14290b5
19 changed files with 18 additions and 2161 deletions
-172
View File
@@ -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<typeof source> => 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',
},
});
}
-235
View File
@@ -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<typeof source> => 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<string, string> = {
'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<string, any[]>();
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<typeof source> => 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<string, string> = {
'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<string, any[]>();
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 }
);
}
}
-1
View File
@@ -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';
-48
View File
@@ -1,48 +0,0 @@
'use client';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
export function EmptyState() {
return (
<div className="text-center py-20 animate-fade-in">
<div className="mb-8">
<div
className="inline-flex items-center justify-center w-32 h-32 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6 rounded-[var(--radius-full)]"
>
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
</div>
<h3 className="text-3xl font-bold text-[var(--text-color)] mb-4">
</h3>
<p className="text-lg text-[var(--text-color-secondary)] max-w-2xl mx-auto mb-8">
16
</p>
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-12">
<Card hover={false} className="text-center p-6">
<div className="flex items-center justify-center mb-4">
<Icons.Zap size={48} className="text-[var(--accent-color)]" />
</div>
<h4 className="font-semibold text-[var(--text-color)] mb-2"></h4>
<p className="text-sm text-[var(--text-color-secondary)]"></p>
</Card>
<Card hover={false} className="text-center p-6">
<div className="flex items-center justify-center mb-4">
<Icons.Target size={48} className="text-[var(--accent-color)]" />
</div>
<h4 className="font-semibold text-[var(--text-color)] mb-2"></h4>
<p className="text-sm text-[var(--text-color-secondary)]"></p>
</Card>
<Card hover={false} className="text-center p-6">
<div className="flex items-center justify-center mb-4">
<Icons.Sparkles size={48} className="text-[var(--accent-color)]" />
</div>
<h4 className="font-semibold text-[var(--text-color)] mb-2"></h4>
<p className="text-sm text-[var(--text-color-secondary)]"></p>
</Card>
</div>
</div>
</div>
);
}
-34
View File
@@ -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<DOMRect | null>(null);
const inputRef = useRef<HTMLInputElement>(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({
</span>
</Button>
</div>
{/* Search History Dropdown */}
<SearchHistoryDropdown
isVisible={showHistory && !isLoading}
onSelect={handleHistorySelect}
onClose={() => setShowHistory(false)}
inputRect={inputRect}
/>
{/* Loading Animation */}
{isLoading && (
-162
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={dropdownRef}
id="search-history-listbox"
role="listbox"
aria-label="搜索历史建议"
style={style}
className="z-[9999] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] border border-[var(--glass-border)] p-2 opacity-0 animate-[slideIn_0.2s_ease-out_forwards]"
>
<div className="flex items-center justify-between px-3 py-2 mb-1">
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
<button
onClick={clearSearchHistory}
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors"
>
</button>
</div>
<div className="max-h-[300px] overflow-y-auto space-y-1">
{searchHistory.map((item, index) => (
<div
key={item.timestamp}
role="option"
aria-selected={focusedIndex === index}
className={`group flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-2xl)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] transition-all cursor-pointer ${
focusedIndex === index ? 'bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] ring-2 ring-[var(--accent-color)] ring-inset' : ''
}`}
>
<Icons.Clock
size={16}
className="text-[var(--text-color-secondary)] flex-shrink-0"
/>
<a
ref={(el) => { itemRefs.current[index] = el; }}
href={`/?q=${encodeURIComponent(item.query)}`}
onClick={(e) => {
e.preventDefault();
handleItemClick(item.query, e as any);
}}
onAuxClick={(e) => handleItemClick(item.query, e as any)}
onFocus={() => setFocusedIndex(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(item.query);
}
}}
tabIndex={0}
className="flex-1 text-sm text-[var(--text-color)] hover:text-[var(--accent-color)] transition-colors truncate focus:outline-none"
title={item.query}
>
{item.query}
</a>
<button
onClick={(e) => {
e.stopPropagation();
removeSearchHistory(item.query);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-[var(--glass-bg)] rounded-full"
aria-label="删除"
>
<Icons.X size={14} className="text-[var(--text-color-secondary)]" />
</button>
</div>
))}
</div>
</div>
);
}
+1 -35
View File
@@ -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),
};
}
-69
View File
@@ -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<HTMLElement>(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();
}
}
-9
View File
@@ -1,9 +0,0 @@
/**
* Accessibility Utilities Index
* 可访问性工具库索引 - Centralized exports for all accessibility utilities
*/
// Focus Management
export {
trapFocus,
} from './focus-management';
+12 -76
View File
@@ -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<boolean> {
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,
};
}
+2 -209
View File
@@ -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<VideoSource>): 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<string, { healthy: boolean; responseTime?: number; error?: string }>
> {
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]));
}
-165
View File
@@ -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<HTMLDivElement>(null);
*
* useClickOutside(dropdownRef, () => {
* setIsOpen(false);
* });
*
* return (
* <div ref={dropdownRef}>
* <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
* {isOpen && <div>Dropdown Content</div>}
* </div>
* );
* }
* ```
*/
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
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<HTMLDivElement>(null);
* const triggerRef = useRef<HTMLButtonElement>(null);
*
* useClickOutsideMultiple([dropdownRef, triggerRef], () => {
* setIsOpen(false);
* });
*
* return (
* <>
* <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
* Toggle
* </button>
* {isOpen && (
* <div ref={dropdownRef}>Dropdown Content</div>
* )}
* </>
* );
* }
* ```
*/
export function useClickOutsideMultiple<T extends HTMLElement = HTMLElement>(
refs: RefObject<T>[],
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]);
}
-38
View File
@@ -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);
-182
View File
@@ -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<void>;
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<any[]>([]);
const [availableSources, setAvailableSources] = useState<any[]>([]);
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<string>('');
const abortControllerRef = useRef<AbortController | null>(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<string, number>();
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,
};
}
-10
View File
@@ -167,13 +167,3 @@ export const useHistoryStore = create<HistoryStore>()(
}
)
);
// 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,
}));
-70
View File
@@ -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<SearchHistoryStore>()(
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',
}
)
);
-64
View File
@@ -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;
}
+3 -256
View File
@@ -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<T extends (...args: any[]) => any>(
func: T,
delay: number = 500
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: Parameters<T>) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* Throttle function for limiting function calls
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function (this: any, ...args: Parameters<T>) {
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<string>();
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<string, VideoItem[]> {
const grouped = new Map<string, VideoItem[]>();
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, '<mark>$1</mark>');
}
/**
* 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
}
-326
View File
@@ -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<boolean> {
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<boolean> {
// 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<any[]> {
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<SourceCheckResult> {
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<SourceCheckResult[]> {
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));
}