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

正在加载视频详情...

-

准备播放器...

+

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

) : videoError && !videoData ? (
diff --git a/components/SearchLoadingAnimation.tsx b/components/SearchLoadingAnimation.tsx index 9991cdc..89c3653 100644 --- a/components/SearchLoadingAnimation.tsx +++ b/components/SearchLoadingAnimation.tsx @@ -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 (
{/* Loading Message with Icon */} @@ -87,20 +61,20 @@ export function SearchLoadingAnimation({ - {stageDescription}{dots} + {stage === 'searching' ? '正在搜索视频源' : '正在检测视频可用性'}{dots}
{/* Progress Bar - Unified 0-100% */}
@@ -110,9 +84,9 @@ export function SearchLoadingAnimation({
{/* Progress Info - Real-time count */} -
- {statusText} - {Math.round(displayProgress)}% +
+ {statusText} + {Math.round(progress)}%
diff --git a/lib/utils/client-video-validator.ts b/lib/utils/client-video-validator.ts deleted file mode 100644 index 278d4c9..0000000 --- a/lib/utils/client-video-validator.ts +++ /dev/null @@ -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 { - 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 477d4e1..e39a1c3 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 and stricter checks + * More accurate detection with multiple validation steps */ async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise { if (!isValidUrlFormat(url)) { @@ -95,42 +95,12 @@ async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise @@ -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')), })); }