Enhance streaming search API to process sources concurrently and validate videos immediately; improve loading animation with accurate progress tracking

This commit is contained in:
kuekhaoyang
2025-11-16 22:39:20 +08:00
parent 5b4caf11c0
commit 5889d6300d
4 changed files with 200 additions and 189 deletions
+82 -98
View File
@@ -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();
+74 -56
View File
@@ -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<AbortController | null>(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<string, number>();
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}
/>
</div>
+4 -17
View File
@@ -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 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent mb-4"></div>
<p className="text-[var(--text-color-secondary)] mb-2">...</p>
<p className="text-[var(--text-color-tertiary)] text-sm">...</p>
<p className="text-[var(--text-color-secondary)] mb-2">...</p>
<p className="text-[var(--text-color-tertiary)] text-sm">...</p>
</div>
) : videoError && !videoData ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
+40 -18
View File
@@ -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 (
<div className="w-full space-y-3 animate-fade-in">
{/* Loading Message with Icon */}
@@ -65,20 +87,20 @@ export function SearchLoadingAnimation({
</svg>
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
{stage === 'searching' ? '正在搜索视频源' : stage === 'validating' ? '正在验证视频播放能力' : '正在检测视频可用性'}{dots}
{stageDescription}{dots}
</span>
</div>
{/* Progress Bar - Unified 0-100% */}
<div className="w-full">
<div
className="h-1 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden"
className="h-2 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden"
style={{ borderRadius: 'var(--radius-full)' }}
>
<div
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative"
className="h-full bg-gradient-to-r from-[var(--accent-color)] to-[color-mix(in_srgb,var(--accent-color)_120%,white)] transition-all duration-300 ease-out relative"
style={{
width: `${progress}%`,
width: `${displayProgress}%`,
borderRadius: 'var(--radius-full)'
}}
>
@@ -88,9 +110,9 @@ export function SearchLoadingAnimation({
</div>
{/* Progress Info - Real-time count */}
<div className="flex items-center justify-between mt-2 text-xs text-[var(--text-color-secondary)]">
<span>{statusText}</span>
<span className="font-medium">{Math.round(progress)}%</span>
<div className="flex items-center justify-between mt-2 text-xs">
<span className="text-[var(--text-color-secondary)]">{statusText}</span>
<span className="font-semibold text-[var(--accent-color)]">{Math.round(displayProgress)}%</span>
</div>
</div>
</div>