From d0790d13072d902dc5bcc4cee7e9ce1c82c796e8 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 16 Nov 2025 19:03:19 +0800 Subject: [PATCH] Implement streaming search API and enhance video availability checks with loading animation --- app/api/search-stream/route.ts | 172 ++++++++++++++++ app/api/search/route.ts | 132 ++++++------ app/globals.css | 80 ++++++++ app/page.tsx | 280 +++++++++++++++++++------- components/SearchLoadingAnimation.tsx | 94 +++++++++ lib/utils/source-checker.ts | 55 ++++- 6 files changed, 664 insertions(+), 149 deletions(-) create mode 100644 app/api/search-stream/route.ts create mode 100644 components/SearchLoadingAnimation.tsx diff --git a/app/api/search-stream/route.ts b/app/api/search-stream/route.ts new file mode 100644 index 0000000..e5b2f3a --- /dev/null +++ b/app/api/search-stream/route.ts @@ -0,0 +1,172 @@ +/** + * 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 = 5; // Smaller batches for faster response + + // Process videos in smaller 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 index 88cc3a5..4961e0b 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { searchVideos } from '@/lib/api/client'; import { getEnabledSources, getSourceById } from '@/lib/api/video-sources'; -import { checkMultipleSources, filterByAvailableSources } from '@/lib/utils/source-checker'; +import { checkMultipleVideos } from '@/lib/utils/source-checker'; import type { SearchRequest, SearchResult } from '@/lib/types'; export async function POST(request: NextRequest) { @@ -68,53 +68,46 @@ export async function POST(request: NextRequest) { return sourceNames[sourceId] || sourceId; }; - // Check source availability by testing sample videos - console.log(`🔍 Checking availability of ${searchResults.length} sources...`); - const sourcesWithVideos = searchResults - .filter(result => result.results.length > 0) - .map(result => ({ - sourceId: result.source, - sourceName: getSourceName(result.source), - videos: result.results.slice(0, 3), // Use first 3 videos as samples - })); - - const availabilityResults = await checkMultipleSources(sourcesWithVideos); - - const availableCount = availabilityResults.filter(r => r.isAvailable).length; - console.log(`✅ ${availableCount} out of ${availabilityResults.length} sources are available`); - - // Filter results to only include videos from available sources + // Get all videos from all sources const allVideos = searchResults.flatMap(r => r.results); - const availableVideos = filterByAvailableSources(allVideos, availabilityResults); - // Group available videos back by source - const availableSources = availabilityResults - .filter(r => r.isAvailable) - .map(r => { - const sourceVideos = availableVideos.filter(v => v.source === r.sourceId); - return { - source: r.sourceId, - results: sourceVideos, - responseTime: searchResults.find(sr => sr.source === r.sourceId)?.responseTime, - }; - }); + // Check each video individually + const availableVideos = await checkMultipleVideos(allVideos, 10); - // Format response - const response: SearchResult[] = availableSources.map(result => ({ - results: result.results, - source: result.source, - responseTime: result.responseTime, + // 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, - availableSources: availableCount, - totalSources: availabilityResults.length, - sourceAvailability: availabilityResults, + sourceStats, // Include real counts per source }); } catch (error) { console.error('Search API error:', error); @@ -187,53 +180,46 @@ export async function GET(request: NextRequest) { return sourceNames[sourceId] || sourceId; }; - // Check source availability by testing sample videos - console.log(`🔍 [GET] Checking availability of ${searchResults.length} sources...`); - const sourcesWithVideos = searchResults - .filter(result => result.results.length > 0) - .map(result => ({ - sourceId: result.source, - sourceName: getSourceName(result.source), - videos: result.results.slice(0, 3), // Use first 3 videos as samples - })); - - const availabilityResults = await checkMultipleSources(sourcesWithVideos); - - const availableCount = availabilityResults.filter(r => r.isAvailable).length; - console.log(`✅ [GET] ${availableCount} out of ${availabilityResults.length} sources are available`); - - // Filter results to only include videos from available sources + // Get all videos from all sources const allVideos = searchResults.flatMap(r => r.results); - const availableVideos = filterByAvailableSources(allVideos, availabilityResults); - // Group available videos back by source - const availableSources = availabilityResults - .filter(r => r.isAvailable) - .map(r => { - const sourceVideos = availableVideos.filter(v => v.source === r.sourceId); - return { - source: r.sourceId, - results: sourceVideos, - responseTime: searchResults.find(sr => sr.source === r.sourceId)?.responseTime, - }; - }); + // Check each video individually + const availableVideos = await checkMultipleVideos(allVideos, 10); - // Format response - const response: SearchResult[] = availableSources.map(result => ({ - results: result.results, - source: result.source, - responseTime: result.responseTime, + // 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, - availableSources: availableCount, - totalSources: availabilityResults.length, - sourceAvailability: availabilityResults, + sourceStats, // Include real counts per source }); } catch (error) { console.error('Search API error:', error); diff --git a/app/globals.css b/app/globals.css index 9f23058..f35f65a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -141,6 +141,57 @@ body.dark, } } +@keyframes spin-slow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes spin-reverse { + from { transform: rotate(360deg); } + to { transform: rotate(0deg); } +} + +@keyframes bounce-subtle { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-5px); } +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +@keyframes scale-in { + 0% { + opacity: 0; + transform: scale(0.9) translateY(10px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0) translateX(0); + opacity: 0.3; + } + 50% { + transform: translateY(-20px) translateX(10px); + opacity: 0.8; + } +} + +@keyframes gradient-x { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + .animate-fade-in { animation: fade-in 0.4s ease-out; } @@ -157,6 +208,35 @@ body.dark, animation: spin 1s linear infinite; } +.animate-spin-slow { + animation: spin-slow 3s linear infinite; +} + +.animate-spin-reverse { + animation: spin-reverse 2s linear infinite; +} + +.animate-bounce-subtle { + animation: bounce-subtle 2s ease-in-out infinite; +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} + +.animate-scale-in { + animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-gradient-x { + background-size: 200% 200%; + animation: gradient-x 3s ease infinite; +} + /* Liquid Glass Components */ .glass-card { background: var(--glass-bg); diff --git a/app/page.tsx b/app/page.tsx index 44cf338..301e501 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { Button } from '@/components/ui/Button'; @@ -8,67 +8,173 @@ import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; +import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation'; import Image from 'next/image'; export default function Home() { + const [loading, setLoading] = useState(false); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [availableSources, setAvailableSources] = useState>([]); - const [validationStatus, setValidationStatus] = useState(''); + const [hasSearched, setHasSearched] = useState(false); + const [availableSources, setAvailableSources] = useState([]); + const [currentSource, setCurrentSource] = useState(''); + const [checkedSources, setCheckedSources] = useState(0); + const [searchStage, setSearchStage] = useState<'searching' | 'checking'>('searching'); + const [checkedVideos, setCheckedVideos] = useState(0); + const [totalVideos, setTotalVideos] = useState(0); const router = useRouter(); + const abortControllerRef = useRef(null); const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); - if (!query.trim()) return; + if (!query.trim() || loading) return; // Prevent multiple searches + + // Abort any previous search + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller for this search + abortControllerRef.current = new AbortController(); setLoading(true); - setValidationStatus('搜索中...'); + setHasSearched(true); + setResults([]); + setAvailableSources([]); + setCheckedSources(0); + setSearchStage('searching'); + setCheckedVideos(0); + setTotalVideos(0); + try { // Get all enabled source IDs const sourceIds = ['custom_0', 'custom_1', 'custom_2', 'custom_3', 'custom_4', 'custom_5', 'custom_6', 'custom_7', 'custom_8', 'custom_9', 'custom_10', 'custom_11', 'custom_12', 'custom_13', 'custom_14', 'custom_15']; - const response = await fetch('/api/search', { + // Use streaming API + const response = await fetch('/api/search-stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, sources: sourceIds }), + signal: abortControllerRef.current.signal, }); - const data = await response.json(); - - if (data.success) { - setValidationStatus(`已检测 ${data.totalSources || 0} 个源,${data.availableSources || 0} 个可用`); - - // Filter out sources with no results and add source names - const resultsWithSources = data.sources - .filter((s: any) => s.results.length > 0) - .flatMap((s: any) => - s.results.map((result: any) => ({ - ...result, - sourceName: getSourceName(s.source), - })) - ); - setResults(resultsWithSources); - - // Track available sources - const sourcesWithResults = data.sources - .filter((s: any) => s.results.length > 0) - .map((s: any) => ({ - id: s.source, - name: getSourceName(s.source), - count: s.results.length, - })); - setAvailableSources(sourcesWithResults); - // Clear validation status after 3 seconds - setTimeout(() => setValidationStatus(''), 3000); + 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)); + + switch (data.type) { + case 'progress': + if (data.stage === 'searching') { + setSearchStage('searching'); + setCheckedSources(data.checkedSources); + } else if (data.stage === 'checking') { + setSearchStage('checking'); + setCheckedVideos(data.checkedVideos); + setTotalVideos(data.totalVideos); + } + break; + + case 'videos': + // Add new videos immediately - NO DELAY + const newVideos = data.videos.map((video: any) => ({ + ...video, + sourceName: getSourceName(video.source), + isNew: true, + addedAt: Date.now(), // Track when video was added + })); + + console.log('📹 收到新视频:', newVideos.length, '个'); + + // Add to allVideos array + allVideos.push(...newVideos); + + console.log('🎬 当前总视频数:', allVideos.length); + + // Update state with all videos + setResults([...allVideos]); + + // Update progress + 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); + }); + + // Update available sources display + const sourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({ + id: sourceId, + name: getSourceName(sourceId), + count, + })); + setAvailableSources(sourcesArray); + + // Remove animation flag only for these new videos after delay + setTimeout(() => { + setResults(prev => prev.map(v => { + // Only remove isNew flag from videos that were just added + const wasJustAdded = newVideos.some((nv: any) => + nv.vod_id === v.vod_id && nv.source === v.source && nv.addedAt === v.addedAt + ); + if (wasJustAdded) { + return { ...v, isNew: false }; + } + return v; + })); + }, 300); + break; + + case 'complete': + setCheckedVideos(data.totalVideos); + setLoading(false); + break; + + case 'error': + throw new Error(data.error); + } + } catch (err) { + // Skip invalid JSON lines + } + } + } + } catch (error: any) { + // Only show error if not aborted by user + if (error.name !== 'AbortError') { + console.error('Search error:', error); } - } catch (error) { - console.error('Search error:', error); - setValidationStatus('搜索失败'); - } finally { setLoading(false); + } finally { + setCurrentSource(''); } }; @@ -95,6 +201,12 @@ export default function Home() { }; const handleVideoClick = (video: any) => { + // Abort ongoing search when user clicks a video + if (abortControllerRef.current && loading) { + abortControllerRef.current.abort(); + setLoading(false); + } + const params = new URLSearchParams({ id: video.vod_id, source: video.source, @@ -107,7 +219,7 @@ export default function Home() {
{/* Glass Navbar */}