Enhance video validation process with client-side playback testing; filter out non-playable episodes and improve loading animation stages

This commit is contained in:
kuekhaoyang
2025-11-16 22:28:51 +08:00
parent c73cca570e
commit 5b4caf11c0
7 changed files with 338 additions and 35 deletions
+19 -2
View File
@@ -65,10 +65,27 @@ export async function GET(request: NextRequest) {
try {
const videoDetail = await getVideoDetail(id, sourceConfig);
// Skip validation - videos are already checked during search
// Just return the episodes as-is
// Validate episodes to filter out broken URLs
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,
+83 -13
View File
@@ -11,6 +11,7 @@ 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);
@@ -20,12 +21,72 @@ 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'>('searching');
const [searchStage, setSearchStage] = useState<'searching' | 'checking' | 'validating'>('searching');
const [checkedVideos, setCheckedVideos] = useState(0);
const [totalVideos, setTotalVideos] = 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
const validateVideosInBrowser = async (videos: any[]) => {
const validatedVideos: any[] = [];
// 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);
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}`);
return null;
}
const testResult = await testVideoPlayback(url);
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;
}
})
);
validatedVideos.push(...results.filter(v => v !== null));
}
return validatedVideos;
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim() || loading) return; // Prevent multiple searches
@@ -104,25 +165,34 @@ export default function Home() {
break;
case 'videos':
// Add new videos immediately - NO DELAY
const newVideos = data.videos.map((video: any) => ({
// Validate videos in browser before showing them
console.log('📹 收到新视频:', data.videos.length, '个 - 开始浏览器验证...');
setSearchStage('validating');
const validatedVideos = await validateVideosInBrowser(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(), // Track when video was added
addedAt: Date.now(),
}));
console.log('📹 收到新视频:', newVideos.length, '个');
// Add to allVideos array
allVideos.push(...newVideos);
console.log('🎬 当前总视频数:', allVideos.length);
// Update state with all videos
setResults([...allVideos]);
if (newVideos.length > 0) {
// Add to allVideos array
allVideos.push(...newVideos);
console.log('🎬 当前总视频数:', allVideos.length);
// Update state with validated videos
setResults([...allVideos]);
}
// Update progress
setSearchStage('checking');
setCheckedVideos(data.checkedVideos);
setTotalVideos(data.totalVideos);
+19 -1
View File
@@ -8,6 +8,7 @@ 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();
@@ -84,6 +85,22 @@ 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`);
}
setVideoData(data.data);
if (data.data.episodes && data.data.episodes.length > 0) {
const firstUrl = data.data.episodes[0].url;
@@ -188,7 +205,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)]">...</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">
+10 -6
View File
@@ -8,7 +8,7 @@ interface SearchLoadingAnimationProps {
totalSources?: number;
checkedVideos?: number;
totalVideos?: number;
stage?: 'searching' | 'checking';
stage?: 'searching' | 'checking' | 'validating';
}
export function SearchLoadingAnimation({
@@ -29,17 +29,21 @@ export function SearchLoadingAnimation({
}, []);
// Calculate unified progress (0-100%)
// Stage 1: Search sources (0-60%)
// Stage 2: Check videos (60-100%)
// Stage 1: Search sources (0-50%)
// Stage 2: Check videos (50-80%)
// Stage 3: Validate in browser (80-100%)
let progress = 0;
let statusText = '';
if (stage === 'searching') {
progress = totalSources > 0 ? (checkedSources / totalSources) * 60 : 0;
progress = totalSources > 0 ? (checkedSources / totalSources) * 50 : 0;
statusText = `${checkedSources}/${totalSources} 个源`;
} else if (stage === 'checking') {
progress = 60 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 40 : 0);
progress = 50 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 30 : 0);
statusText = `${checkedVideos}/${totalVideos} 个视频`;
} else if (stage === 'validating') {
progress = 80 + Math.min(20, Math.random() * 20); // Animated progress for validation
statusText = '验证播放能力';
}
return (
@@ -61,7 +65,7 @@ export function SearchLoadingAnimation({
</svg>
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
{stage === 'searching' ? '正在搜索视频源' : '正在检测视频可用性'}{dots}
{stage === 'searching' ? '正在搜索视频源' : stage === 'validating' ? '正在验证视频播放能力' : '正在检测视频可用性'}{dots}
</span>
</div>
+156
View File
@@ -0,0 +1,156 @@
/**
* 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;
}
+35 -5
View File
@@ -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
* More accurate detection with multiple validation steps and stricter checks
*/
async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise<boolean> {
if (!isValidUrlFormat(url)) {
@@ -95,12 +95,42 @@ 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 content type check AND either have valid length OR support ranges
if (hasValidContentType && (hasValidLength || supportsRanges)) {
return true;
// 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;
}
return false;
// 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;
} catch (error) {
// If last attempt, return false
if (attempt === retries) {
+16 -8
View File
@@ -133,7 +133,8 @@ export async function validateEpisodeSource(
}
/**
* Filter out invalid episodes
* Filter out invalid episodes and return only accessible ones
* Tests actual accessibility of video URLs
*/
export async function filterValidEpisodes(
episodes: Array<{ name: string; url: string; index: number }>
@@ -142,18 +143,25 @@ export async function filterValidEpisodes(
const validFormatEpisodes = episodes.filter(ep => isValidUrlFormat(ep.url));
if (validFormatEpisodes.length === 0) {
return episodes.map(ep => ({ ...ep, isValid: false }));
return []; // Return empty array if no valid formats
}
// Check accessibility for first 3 episodes as sample
const samplesToCheck = validFormatEpisodes.slice(0, 3);
// Check accessibility for first 5 episodes as sample (increased for better detection)
const samplesToCheck = validFormatEpisodes.slice(0, Math.min(5, validFormatEpisodes.length));
const validationResults = await validateUrls(samplesToCheck.map(ep => ep.url));
// If at least one sample works, assume all with valid format work
const hasWorkingEpisodes = validationResults.some(r => r.isValid);
// Count how many samples are actually working
const workingCount = validationResults.filter(r => r.isValid).length;
return episodes.map(ep => ({
// 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 => ({
...ep,
isValid: isValidUrlFormat(ep.url) && (hasWorkingEpisodes || ep.url.includes('.m3u8')),
isValid: true,
}));
}