mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-18 10:13:43 +08:00
Refactor video validation process by removing client-side playback testing; streamline episode filtering and enhance loading animation with accurate progress tracking
This commit is contained in:
+2
-19
@@ -65,27 +65,10 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const videoDetail = await getVideoDetail(id, sourceConfig);
|
||||
|
||||
// Validate episodes to filter out broken URLs
|
||||
// Skip validation - videos are already checked during search
|
||||
// Just return the episodes as-is
|
||||
console.log(`[GET] Fetching video details for ${id} from ${sourceConfig.name}`);
|
||||
|
||||
if (videoDetail.episodes && videoDetail.episodes.length > 0) {
|
||||
const originalCount = videoDetail.episodes.length;
|
||||
const validEpisodes = await filterValidEpisodes(videoDetail.episodes);
|
||||
|
||||
if (validEpisodes.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No valid episodes available for this video from this source',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
videoDetail.episodes = validEpisodes;
|
||||
console.log(`Filtered episodes: ${validEpisodes.length}/${originalCount} valid`);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: videoDetail,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -36,103 +35,120 @@ export async function POST(request: NextRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
// Send progress: searching sources
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'searching',
|
||||
checkedSources: 0,
|
||||
totalSources: sources.length
|
||||
totalSources: sourceIds.length
|
||||
})}\n\n`));
|
||||
|
||||
// Perform search with progress tracking for each source
|
||||
let checkedSourcesCount = 0;
|
||||
let totalVideosFound = 0;
|
||||
let checkedVideosCount = 0;
|
||||
const concurrency = 10; // Process 10 sources at a time
|
||||
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',
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 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);
|
||||
// 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 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`));
|
||||
}
|
||||
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: checkedVideosCount,
|
||||
totalVideos: totalVideosFound
|
||||
totalResults: availableVideos.length,
|
||||
checkedVideos: allVideos.length,
|
||||
totalVideos: allVideos.length
|
||||
})}\n\n`));
|
||||
|
||||
controller.close();
|
||||
|
||||
+44
-132
@@ -11,7 +11,6 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
|
||||
import Image from 'next/image';
|
||||
import { testVideoPlayback } from '@/lib/utils/client-video-validator';
|
||||
|
||||
export default function Home() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -21,85 +20,12 @@ export default function Home() {
|
||||
const [availableSources, setAvailableSources] = useState<any[]>([]);
|
||||
const [currentSource, setCurrentSource] = useState<string>('');
|
||||
const [checkedSources, setCheckedSources] = useState(0);
|
||||
const [searchStage, setSearchStage] = useState<'searching' | 'checking' | 'validating'>('searching');
|
||||
const [searchStage, setSearchStage] = useState<'searching' | 'checking'>('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);
|
||||
|
||||
// Extract first video URL from search result
|
||||
const extractFirstVideoUrl = (video: any): string | null => {
|
||||
if (!video.vod_play_url) return null;
|
||||
|
||||
try {
|
||||
const episodes = video.vod_play_url.split('#').filter((ep: string) => ep.trim());
|
||||
for (const episode of episodes) {
|
||||
const parts = episode.split('$');
|
||||
if (parts.length >= 2) {
|
||||
const url = parts[1].trim();
|
||||
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
return url;
|
||||
}
|
||||
} else if (parts.length === 1) {
|
||||
const url = parts[0].trim();
|
||||
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Validate videos in browser - process in batches but show results immediately
|
||||
const validateVideosInBrowser = async (videos: any[]) => {
|
||||
const validatedResults: any[] = [];
|
||||
setTotalToValidate(videos.length);
|
||||
setValidatedVideos(0);
|
||||
|
||||
// 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})`);
|
||||
return video;
|
||||
} else {
|
||||
console.debug(`❌ Video not playable: ${video.vod_name} - ${testResult.error}`);
|
||||
return 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 validatedResults;
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim() || loading) return; // Prevent multiple searches
|
||||
@@ -120,8 +46,6 @@ export default function Home() {
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
setValidatedVideos(0);
|
||||
setTotalToValidate(0);
|
||||
|
||||
try {
|
||||
// Get all enabled source IDs
|
||||
@@ -150,7 +74,6 @@ export default function Home() {
|
||||
|
||||
let buffer = '';
|
||||
const allVideos: any[] = [];
|
||||
const pendingValidation: any[] = [];
|
||||
const sourceVideoCounts = new Map<string, number>();
|
||||
|
||||
while (true) {
|
||||
@@ -181,64 +104,55 @@ export default function Home() {
|
||||
break;
|
||||
|
||||
case 'videos':
|
||||
// Received new videos that passed backend checks
|
||||
console.log('📹 收到新视频:', data.videos.length, '个 - 开始浏览器验证...');
|
||||
// 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);
|
||||
|
||||
// Add to pending validation queue
|
||||
pendingValidation.push(...data.videos);
|
||||
console.log('🎬 当前总视频数:', allVideos.length);
|
||||
|
||||
// Validate immediately in background
|
||||
(async () => {
|
||||
// Update stage to validating
|
||||
setSearchStage('validating');
|
||||
|
||||
const validatedVideos = await validateVideosInBrowser(data.videos);
|
||||
|
||||
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 state with all videos
|
||||
setResults([...allVideos]);
|
||||
|
||||
// Add to results IMMEDIATELY
|
||||
allVideos.push(...newVideos);
|
||||
setResults([...allVideos]);
|
||||
|
||||
console.log('🎬 当前总视频数:', allVideos.length);
|
||||
// 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 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 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);
|
||||
}
|
||||
})();
|
||||
// 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':
|
||||
@@ -359,8 +273,6 @@ export default function Home() {
|
||||
totalSources={16}
|
||||
checkedVideos={checkedVideos}
|
||||
totalVideos={totalVideos}
|
||||
validatedVideos={validatedVideos}
|
||||
totalToValidate={totalToValidate}
|
||||
stage={searchStage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-6
@@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import Image from 'next/image';
|
||||
import { filterPlayableEpisodes } from '@/lib/utils/client-video-validator';
|
||||
|
||||
function PlayerContent() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -85,9 +84,6 @@ function PlayerContent() {
|
||||
firstEpisodeUrl: data.data.episodes?.[0]?.url
|
||||
});
|
||||
|
||||
// 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) {
|
||||
const firstUrl = data.data.episodes[0].url;
|
||||
@@ -192,8 +188,7 @@ 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)]">正在检测视频源可用性...</p>
|
||||
</div>
|
||||
) : videoError && !videoData ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SearchLoadingAnimationProps {
|
||||
currentSource?: string;
|
||||
@@ -8,9 +8,7 @@ interface SearchLoadingAnimationProps {
|
||||
totalSources?: number;
|
||||
checkedVideos?: number;
|
||||
totalVideos?: number;
|
||||
validatedVideos?: number;
|
||||
totalToValidate?: number;
|
||||
stage?: 'searching' | 'checking' | 'validating';
|
||||
stage?: 'searching' | 'checking';
|
||||
}
|
||||
|
||||
export function SearchLoadingAnimation({
|
||||
@@ -19,13 +17,9 @@ 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(() => {
|
||||
@@ -34,40 +28,20 @@ export function SearchLoadingAnimation({
|
||||
return () => clearInterval(dotInterval);
|
||||
}, []);
|
||||
|
||||
// Calculate unified progress (0-100%) with accurate percentages
|
||||
// Calculate unified progress (0-100%)
|
||||
// Stage 1: Search sources (0-60%)
|
||||
// Stage 2: Check videos (60-100%)
|
||||
let progress = 0;
|
||||
let statusText = '';
|
||||
let stageDescription = '';
|
||||
|
||||
if (stage === 'searching') {
|
||||
// Stage 1: Search sources (0-33%)
|
||||
progress = totalSources > 0 ? (checkedSources / totalSources) * 33 : 0;
|
||||
statusText = `已搜索 ${checkedSources}/${totalSources} 个源`;
|
||||
stageDescription = '正在多源并行搜索';
|
||||
progress = totalSources > 0 ? (checkedSources / totalSources) * 60 : 0;
|
||||
statusText = `${checkedSources}/${totalSources} 个源`;
|
||||
} else if (stage === 'checking') {
|
||||
// Stage 2: Check videos (33-66%)
|
||||
progress = 33 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 33 : 0);
|
||||
statusText = `已检测 ${checkedVideos}/${totalVideos} 个视频`;
|
||||
stageDescription = '正在验证视频可用性';
|
||||
} else if (stage === 'validating') {
|
||||
// Stage 3: Validate in browser (66-100%)
|
||||
progress = 66 + (totalToValidate > 0 ? (validatedVideos / totalToValidate) * 34 : 0);
|
||||
statusText = `已验证 ${validatedVideos}/${totalToValidate} 个视频`;
|
||||
stageDescription = '正在浏览器测试播放';
|
||||
progress = 60 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 40 : 0);
|
||||
statusText = `${checkedVideos}/${totalVideos} 个视频`;
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
@@ -87,20 +61,20 @@ export function SearchLoadingAnimation({
|
||||
</svg>
|
||||
|
||||
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
|
||||
{stageDescription}{dots}
|
||||
{stage === 'searching' ? '正在搜索视频源' : '正在检测视频可用性'}{dots}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - Unified 0-100% */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="h-2 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden"
|
||||
className="h-1 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden"
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<div
|
||||
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"
|
||||
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative"
|
||||
style={{
|
||||
width: `${displayProgress}%`,
|
||||
width: `${progress}%`,
|
||||
borderRadius: 'var(--radius-full)'
|
||||
}}
|
||||
>
|
||||
@@ -110,9 +84,9 @@ export function SearchLoadingAnimation({
|
||||
</div>
|
||||
|
||||
{/* Progress Info - Real-time count */}
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Client-Side Video Validator
|
||||
* Tests actual video playback in the browser to catch MediaErrors
|
||||
* This runs on the client and detects issues that server-side checks miss
|
||||
*/
|
||||
|
||||
const TEST_TIMEOUT = 8000; // 8 seconds for video element testing
|
||||
|
||||
export interface VideoTestResult {
|
||||
url: string;
|
||||
canPlay: boolean;
|
||||
error?: string;
|
||||
errorCode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a video URL can actually be played in the browser
|
||||
* This catches MediaErrors that server-side validation misses
|
||||
*/
|
||||
export async function testVideoPlayback(url: string): Promise<VideoTestResult> {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
video.src = '';
|
||||
video.load();
|
||||
video.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({
|
||||
url,
|
||||
canPlay: false,
|
||||
error: 'Video loading timeout',
|
||||
});
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
// Handle video errors (MediaError)
|
||||
video.addEventListener('error', () => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
let errorMessage = 'Unknown playback error';
|
||||
let errorCode = 0;
|
||||
|
||||
if (video.error) {
|
||||
errorCode = video.error.code;
|
||||
|
||||
switch (video.error.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = 'Video loading was aborted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = 'Network error occurred while loading video';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = 'Video format is not supported or corrupted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMessage = 'Video source not supported or unavailable';
|
||||
break;
|
||||
default:
|
||||
errorMessage = video.error.message || 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
resolve({
|
||||
url,
|
||||
canPlay: false,
|
||||
error: errorMessage,
|
||||
errorCode,
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
// Handle successful loading
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup();
|
||||
resolve({
|
||||
url,
|
||||
canPlay: true,
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
// Also accept if video can play
|
||||
video.addEventListener('canplay', () => {
|
||||
if (!resolved) {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup();
|
||||
resolve({
|
||||
url,
|
||||
canPlay: true,
|
||||
});
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
// Configure video element
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.src = url;
|
||||
video.load();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test multiple video URLs in parallel (with concurrency limit)
|
||||
*/
|
||||
export async function testMultipleVideos(
|
||||
urls: string[],
|
||||
concurrency: number = 3
|
||||
): Promise<VideoTestResult[]> {
|
||||
const results: VideoTestResult[] = [];
|
||||
|
||||
for (let i = 0; i < urls.length; i += concurrency) {
|
||||
const batch = urls.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(url => testVideoPlayback(url))
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test and filter episodes to only include playable ones
|
||||
*/
|
||||
export async function filterPlayableEpisodes<T extends { url: string }>(
|
||||
episodes: T[],
|
||||
maxSamplesToTest: number = 5
|
||||
): Promise<T[]> {
|
||||
if (episodes.length === 0) return [];
|
||||
|
||||
// Test up to maxSamplesToTest episodes
|
||||
const samplesToTest = episodes.slice(0, Math.min(maxSamplesToTest, episodes.length));
|
||||
const testResults = await testMultipleVideos(samplesToTest.map(ep => ep.url), 3);
|
||||
|
||||
// Count successful tests
|
||||
const successfulTests = testResults.filter(r => r.canPlay).length;
|
||||
|
||||
// If less than 20% work, mark entire source as broken
|
||||
if (successfulTests === 0 || (successfulTests / samplesToTest.length) < 0.2) {
|
||||
console.warn(`Client-side validation: Only ${successfulTests}/${samplesToTest.length} episodes playable`);
|
||||
return []; // Return empty to indicate source is broken
|
||||
}
|
||||
|
||||
// If enough samples work, return all episodes (assume they work)
|
||||
console.log(`✓ Client-side validation passed: ${successfulTests}/${samplesToTest.length} episodes playable`);
|
||||
return episodes;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface SourceCheckResult {
|
||||
|
||||
/**
|
||||
* Check if a single video URL is accessible and actually contains video content
|
||||
* More accurate detection with multiple validation steps and stricter checks
|
||||
* More accurate detection with multiple validation steps
|
||||
*/
|
||||
async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise<boolean> {
|
||||
if (!isValidUrlFormat(url)) {
|
||||
@@ -95,42 +95,12 @@ async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise<boolea
|
||||
// Check 3: For video files, check if server supports range requests (good sign)
|
||||
const supportsRanges = acceptRanges === 'bytes' || response.status === 206;
|
||||
|
||||
// Video must pass ALL checks to be considered valid:
|
||||
// 1. Must have valid video content type
|
||||
// 2. Must have reasonable content length OR support range requests
|
||||
// 3. For better reliability, prefer sources that support ranges (streaming capability)
|
||||
if (!hasValidContentType) {
|
||||
console.debug(`Invalid content type for ${url.substring(0, 50)}...`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasValidLength && !supportsRanges) {
|
||||
console.debug(`Invalid content length and no range support for ${url.substring(0, 50)}...`);
|
||||
return false;
|
||||
// Video must pass content type check AND either have valid length OR support ranges
|
||||
if (hasValidContentType && (hasValidLength || supportsRanges)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Additional strict check: Try to fetch a small byte range to verify actual content
|
||||
try {
|
||||
const verifyResponse = await fetch(url, {
|
||||
method: 'GET',
|
||||
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-1024', // Fetch first 1KB
|
||||
},
|
||||
});
|
||||
|
||||
// If we can't even fetch the first 1KB, it's not a valid source
|
||||
if (!verifyResponse.ok && verifyResponse.status !== 206) {
|
||||
console.debug(`Failed to verify content for ${url.substring(0, 50)}...`);
|
||||
return false;
|
||||
}
|
||||
} catch (verifyError) {
|
||||
console.debug(`Verification fetch failed for ${url.substring(0, 50)}...`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
} catch (error) {
|
||||
// If last attempt, return false
|
||||
if (attempt === retries) {
|
||||
|
||||
@@ -133,8 +133,7 @@ export async function validateEpisodeSource(
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out invalid episodes and return only accessible ones
|
||||
* Tests actual accessibility of video URLs
|
||||
* Filter out invalid episodes
|
||||
*/
|
||||
export async function filterValidEpisodes(
|
||||
episodes: Array<{ name: string; url: string; index: number }>
|
||||
@@ -143,25 +142,18 @@ export async function filterValidEpisodes(
|
||||
const validFormatEpisodes = episodes.filter(ep => isValidUrlFormat(ep.url));
|
||||
|
||||
if (validFormatEpisodes.length === 0) {
|
||||
return []; // Return empty array if no valid formats
|
||||
return episodes.map(ep => ({ ...ep, isValid: false }));
|
||||
}
|
||||
|
||||
// Check accessibility for first 5 episodes as sample (increased for better detection)
|
||||
const samplesToCheck = validFormatEpisodes.slice(0, Math.min(5, validFormatEpisodes.length));
|
||||
// Check accessibility for first 3 episodes as sample
|
||||
const samplesToCheck = validFormatEpisodes.slice(0, 3);
|
||||
const validationResults = await validateUrls(samplesToCheck.map(ep => ep.url));
|
||||
|
||||
// Count how many samples are actually working
|
||||
const workingCount = validationResults.filter(r => r.isValid).length;
|
||||
// If at least one sample works, assume all with valid format work
|
||||
const hasWorkingEpisodes = validationResults.some(r => r.isValid);
|
||||
|
||||
// If less than 20% of samples work, this source is likely problematic
|
||||
if (workingCount === 0 || (workingCount / samplesToCheck.length) < 0.2) {
|
||||
console.warn(`Episode validation: Only ${workingCount}/${samplesToCheck.length} samples work - source likely broken`);
|
||||
return []; // Return empty to trigger source unavailable
|
||||
}
|
||||
|
||||
// If at least 20% work, filter to only include valid format episodes
|
||||
return validFormatEpisodes.map(ep => ({
|
||||
return episodes.map(ep => ({
|
||||
...ep,
|
||||
isValid: true,
|
||||
isValid: isValidUrlFormat(ep.url) && (hasWorkingEpisodes || ep.url.includes('.m3u8')),
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user