diff --git a/app/api/detail/route.ts b/app/api/detail/route.ts index 41fdf9a..aebbf26 100644 --- a/app/api/detail/route.ts +++ b/app/api/detail/route.ts @@ -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, diff --git a/app/page.tsx b/app/page.tsx index be1cdd8..bce3820 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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([]); const [currentSource, setCurrentSource] = useState(''); 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(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); diff --git a/app/player/page.tsx b/app/player/page.tsx index c3f8cd0..8146241 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -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 ? (
-

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

+

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

+

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

) : videoError && !videoData ? (
diff --git a/components/SearchLoadingAnimation.tsx b/components/SearchLoadingAnimation.tsx index 89c3653..698bbef 100644 --- a/components/SearchLoadingAnimation.tsx +++ b/components/SearchLoadingAnimation.tsx @@ -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({ - {stage === 'searching' ? '正在搜索视频源' : '正在检测视频可用性'}{dots} + {stage === 'searching' ? '正在搜索视频源' : stage === 'validating' ? '正在验证视频播放能力' : '正在检测视频可用性'}{dots}
diff --git a/lib/utils/client-video-validator.ts b/lib/utils/client-video-validator.ts new file mode 100644 index 0000000..278d4c9 --- /dev/null +++ b/lib/utils/client-video-validator.ts @@ -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 { + 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 { + 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( + episodes: T[], + maxSamplesToTest: number = 5 +): Promise { + 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; +} diff --git a/lib/utils/source-checker.ts b/lib/utils/source-checker.ts index e39a1c3..477d4e1 100644 --- a/lib/utils/source-checker.ts +++ b/lib/utils/source-checker.ts @@ -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 { if (!isValidUrlFormat(url)) { @@ -95,12 +95,42 @@ async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise @@ -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, })); }