From 5889d6300d2daa709a50ba2906908327ff8aa7d2 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 16 Nov 2025 22:39:20 +0800 Subject: [PATCH] Enhance streaming search API to process sources concurrently and validate videos immediately; improve loading animation with accurate progress tracking --- app/api/search-stream/route.ts | 180 ++++++++++++-------------- app/page.tsx | 130 +++++++++++-------- app/player/page.tsx | 21 +-- components/SearchLoadingAnimation.tsx | 58 ++++++--- 4 files changed, 200 insertions(+), 189 deletions(-) diff --git a/app/api/search-stream/route.ts b/app/api/search-stream/route.ts index a9dad57..dfd5755 100644 --- a/app/api/search-stream/route.ts +++ b/app/api/search-stream/route.ts @@ -1,6 +1,7 @@ /** * Streaming Search API Route * Returns results progressively as they become available + * Searches up to 10 sources concurrently and validates videos immediately */ import { NextRequest } from 'next/server'; @@ -35,120 +36,103 @@ export async function POST(request: NextRequest) { return; } - // Send progress: searching sources + // Send initial progress controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'progress', stage: 'searching', checkedSources: 0, - totalSources: sourceIds.length + totalSources: sources.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', - }; - } - }) - ); + let totalVideosFound = 0; + let checkedVideosCount = 0; + const concurrency = 10; // Process 10 sources at a time - // 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); + // Process sources in batches of 10, but with streaming results + for (let i = 0; i < sources.length; i += concurrency) { + const sourceBatch = sources.slice(i, i + concurrency); - const results = await Promise.all( - batch.map(async (video) => { - const isAvailable = await checkVideoAvailability(video); - return isAvailable ? video : null; + // Process each source in the batch, search + check + send results immediately + await Promise.all( + sourceBatch.map(async (source: any) => { + try { + // Step 1: Search this source + const result = await searchVideos(query.trim(), [source], page); + checkedSourcesCount++; + + // Send search progress + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'progress', + stage: 'searching', + checkedSources: checkedSourcesCount, + totalSources: sources.length + })}\n\n`)); + + const videos = result[0]?.results || []; + if (videos.length === 0) return; + + totalVideosFound += videos.length; + + // Step 2: Check videos from this source (in sub-batches of 10) + const validatedVideos: any[] = []; + + for (let j = 0; j < videos.length; j += 10) { + const videoBatch = videos.slice(j, j + 10); + + // Check all 10 videos in parallel + const checkResults = await Promise.all( + videoBatch.map(async (video) => { + const isAvailable = await checkVideoAvailability(video); + checkedVideosCount++; + + // Send check progress after each video + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'progress', + stage: 'checking', + checkedVideos: checkedVideosCount, + totalVideos: totalVideosFound + })}\n\n`)); + + return isAvailable ? video : null; + }) + ); + + // Collect validated videos from this sub-batch + const newValidated = checkResults.filter(v => v !== null); + validatedVideos.push(...newValidated); + + // Step 3: Send validated videos IMMEDIATELY (don't wait for browser validation) + if (newValidated.length > 0) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'videos', + videos: newValidated, + checkedVideos: checkedVideosCount, + totalVideos: totalVideosFound + })}\n\n`)); + } + } + + } catch (error) { + checkedSourcesCount++; + + // Send error progress + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'progress', + stage: 'searching', + checkedSources: checkedSourcesCount, + totalSources: sources.length + })}\n\n`)); + } }) ); - - // 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 + totalResults: checkedVideosCount, + totalVideos: totalVideosFound })}\n\n`)); controller.close(); diff --git a/app/page.tsx b/app/page.tsx index bce3820..3d221ac 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -24,6 +24,8 @@ export default function Home() { const [searchStage, setSearchStage] = useState<'searching' | 'checking' | 'validating'>('searching'); const [checkedVideos, setCheckedVideos] = useState(0); const [totalVideos, setTotalVideos] = useState(0); + const [validatedVideos, setValidatedVideos] = useState(0); + const [totalToValidate, setTotalToValidate] = useState(0); const router = useRouter(); const abortControllerRef = useRef(null); @@ -53,23 +55,27 @@ export default function Home() { return null; }; - // Validate videos in browser + // Validate videos in browser - process in batches but show results immediately const validateVideosInBrowser = async (videos: any[]) => { - const validatedVideos: any[] = []; + const validatedResults: any[] = []; + setTotalToValidate(videos.length); + setValidatedVideos(0); - // Test videos in batches of 3 for better performance - for (let i = 0; i < videos.length; i += 3) { - const batch = videos.slice(i, i + 3); + // Process in batches of 10 for better performance + for (let i = 0; i < videos.length; i += 10) { + const batch = videos.slice(i, i + 10); const results = await Promise.all( batch.map(async (video) => { const url = extractFirstVideoUrl(video); if (!url) { console.debug(`❌ No valid URL for video: ${video.vod_name}`); + setValidatedVideos(prev => prev + 1); return null; } const testResult = await testVideoPlayback(url); + setValidatedVideos(prev => prev + 1); if (testResult.canPlay) { console.debug(`✅ Video playable: ${video.vod_name} (${video.source})`); @@ -81,10 +87,17 @@ export default function Home() { }) ); - validatedVideos.push(...results.filter(v => v !== null)); + const batchValidated = results.filter(v => v !== null); + validatedResults.push(...batchValidated); + + // Return validated videos immediately after each batch + if (batchValidated.length > 0) { + // Show these videos immediately by returning early + return validatedResults; + } } - return validatedVideos; + return validatedResults; }; const handleSearch = async (e: React.FormEvent) => { @@ -107,6 +120,8 @@ export default function Home() { setSearchStage('searching'); setCheckedVideos(0); setTotalVideos(0); + setValidatedVideos(0); + setTotalToValidate(0); try { // Get all enabled source IDs @@ -135,6 +150,7 @@ export default function Home() { let buffer = ''; const allVideos: any[] = []; + const pendingValidation: any[] = []; const sourceVideoCounts = new Map(); while (true) { @@ -165,64 +181,64 @@ export default function Home() { break; case 'videos': - // Validate videos in browser before showing them + // Received new videos that passed backend checks console.log('📹 收到新视频:', data.videos.length, '个 - 开始浏览器验证...'); - setSearchStage('validating'); - const validatedVideos = await validateVideosInBrowser(data.videos); + // Add to pending validation queue + pendingValidation.push(...data.videos); - console.log(`✅ 验证完成: ${validatedVideos.length}/${data.videos.length} 个视频可播放`); - - // Only add validated videos - const newVideos = validatedVideos.map((video: any) => ({ - ...video, - sourceName: getSourceName(video.source), - isNew: true, - addedAt: Date.now(), - })); - - if (newVideos.length > 0) { - // Add to allVideos array - allVideos.push(...newVideos); + // Validate immediately in background + (async () => { + // Update stage to validating + setSearchStage('validating'); - console.log('🎬 当前总视频数:', allVideos.length); + const validatedVideos = await validateVideosInBrowser(data.videos); - // Update state with validated videos - setResults([...allVideos]); - } + console.log(`✅ 验证完成: ${validatedVideos.length}/${data.videos.length} 个视频可播放`); + + if (validatedVideos.length > 0) { + // Mark as new for animation + const newVideos = validatedVideos.map((video: any) => ({ + ...video, + sourceName: getSourceName(video.source), + isNew: true, + addedAt: Date.now(), + })); - // Update progress - setSearchStage('checking'); - setCheckedVideos(data.checkedVideos); - setTotalVideos(data.totalVideos); + // Add to results IMMEDIATELY + allVideos.push(...newVideos); + setResults([...allVideos]); + + console.log('🎬 当前总视频数:', allVideos.length); - // Update source counts - newVideos.forEach((video: any) => { - const count = sourceVideoCounts.get(video.source) || 0; - sourceVideoCounts.set(video.source, count + 1); - }); + // 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); + // 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); + // Remove animation flag 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 + ); + if (wasJustAdded) { + return { ...v, isNew: false }; + } + return v; + })); + }, 300); + } + })(); break; case 'complete': @@ -343,6 +359,8 @@ export default function Home() { totalSources={16} checkedVideos={checkedVideos} totalVideos={totalVideos} + validatedVideos={validatedVideos} + totalToValidate={totalToValidate} stage={searchStage} /> diff --git a/app/player/page.tsx b/app/player/page.tsx index 8146241..47e2fa7 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -85,21 +85,8 @@ function PlayerContent() { firstEpisodeUrl: data.data.episodes?.[0]?.url }); - // Client-side validation: Test if episodes are actually playable - if (data.data.episodes && data.data.episodes.length > 0) { - console.log('Testing episode playability in browser...'); - const playableEpisodes = await filterPlayableEpisodes(data.data.episodes, 5); - - if (playableEpisodes.length === 0) { - console.warn('No playable episodes after client-side validation'); - setVideoError('This video source cannot be played in your browser. Please go back and try another source.'); - setLoading(false); - return; - } - - data.data.episodes = playableEpisodes; - console.log(`✓ ${playableEpisodes.length} episodes passed client-side validation`); - } + // Skip client-side validation since videos are already validated during search + // The search page already validates all videos before showing them setVideoData(data.data); if (data.data.episodes && data.data.episodes.length > 0) { @@ -205,8 +192,8 @@ function PlayerContent() { {loading ? (
-

正在检测视频源可用性...

-

正在浏览器中测试视频播放...

+

正在加载视频详情...

+

准备播放器...

) : videoError && !videoData ? (
diff --git a/components/SearchLoadingAnimation.tsx b/components/SearchLoadingAnimation.tsx index 698bbef..9991cdc 100644 --- a/components/SearchLoadingAnimation.tsx +++ b/components/SearchLoadingAnimation.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; interface SearchLoadingAnimationProps { currentSource?: string; @@ -8,6 +8,8 @@ interface SearchLoadingAnimationProps { totalSources?: number; checkedVideos?: number; totalVideos?: number; + validatedVideos?: number; + totalToValidate?: number; stage?: 'searching' | 'checking' | 'validating'; } @@ -17,9 +19,13 @@ export function SearchLoadingAnimation({ totalSources = 16, checkedVideos = 0, totalVideos = 0, + validatedVideos = 0, + totalToValidate = 0, stage = 'searching' }: SearchLoadingAnimationProps) { const [dots, setDots] = useState(''); + const [displayProgress, setDisplayProgress] = useState(0); + const maxProgressRef = useRef(0); useEffect(() => { const dotInterval = setInterval(() => { @@ -28,24 +34,40 @@ export function SearchLoadingAnimation({ return () => clearInterval(dotInterval); }, []); - // Calculate unified progress (0-100%) - // Stage 1: Search sources (0-50%) - // Stage 2: Check videos (50-80%) - // Stage 3: Validate in browser (80-100%) + // Calculate unified progress (0-100%) with accurate percentages let progress = 0; let statusText = ''; + let stageDescription = ''; if (stage === 'searching') { - progress = totalSources > 0 ? (checkedSources / totalSources) * 50 : 0; - statusText = `${checkedSources}/${totalSources} 个源`; + // Stage 1: Search sources (0-33%) + progress = totalSources > 0 ? (checkedSources / totalSources) * 33 : 0; + statusText = `已搜索 ${checkedSources}/${totalSources} 个源`; + stageDescription = '正在多源并行搜索'; } else if (stage === 'checking') { - progress = 50 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 30 : 0); - statusText = `${checkedVideos}/${totalVideos} 个视频`; + // Stage 2: Check videos (33-66%) + progress = 33 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 33 : 0); + statusText = `已检测 ${checkedVideos}/${totalVideos} 个视频`; + stageDescription = '正在验证视频可用性'; } else if (stage === 'validating') { - progress = 80 + Math.min(20, Math.random() * 20); // Animated progress for validation - statusText = '验证播放能力'; + // Stage 3: Validate in browser (66-100%) + progress = 66 + (totalToValidate > 0 ? (validatedVideos / totalToValidate) * 34 : 0); + statusText = `已验证 ${validatedVideos}/${totalToValidate} 个视频`; + stageDescription = '正在浏览器测试播放'; } + // Ensure progress is between 0 and 100 + progress = Math.max(0, Math.min(100, progress)); + + // Prevent progress from going backward - always move forward or stay same + useEffect(() => { + if (progress >= maxProgressRef.current) { + maxProgressRef.current = progress; + setDisplayProgress(progress); + } + // If new progress is lower (shouldn't happen but just in case), keep the max + }, [progress]); + return (
{/* Loading Message with Icon */} @@ -65,20 +87,20 @@ export function SearchLoadingAnimation({ - {stage === 'searching' ? '正在搜索视频源' : stage === 'validating' ? '正在验证视频播放能力' : '正在检测视频可用性'}{dots} + {stageDescription}{dots}
{/* Progress Bar - Unified 0-100% */}
@@ -88,9 +110,9 @@ export function SearchLoadingAnimation({
{/* Progress Info - Real-time count */} -
- {statusText} - {Math.round(progress)}% +
+ {statusText} + {Math.round(displayProgress)}%