From 610cc522c73547f75a2fe887317d1590f6bf2052 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 16 Nov 2025 11:26:28 +0800 Subject: [PATCH] Enhance video source validation and availability checks across API routes and UI components --- app/api/detail/route.ts | 49 ++++++++++- app/api/search/route.ts | 126 +++++++++++++++++++++++++-- app/globals.css | 7 -- app/page.tsx | 105 +++++++++++++++++++++-- app/player/page.tsx | 87 +++++++++++++++++-- components/ui/Card.tsx | 4 +- components/ui/Icon.tsx | 16 ++++ lib/utils/source-checker.ts | 166 ++++++++++++++++++++++++++++++++++++ lib/utils/url-validator.ts | 147 +++++++++++++++++++++++++++++++ 9 files changed, 672 insertions(+), 35 deletions(-) create mode 100644 lib/utils/source-checker.ts create mode 100644 lib/utils/url-validator.ts diff --git a/app/api/detail/route.ts b/app/api/detail/route.ts index d21e373..ff4db7b 100644 --- a/app/api/detail/route.ts +++ b/app/api/detail/route.ts @@ -1,11 +1,12 @@ /** * Detail API Route - * Fetches video details including episodes and M3U8 URLs + * Fetches video details including episodes and M3U8 URLs with automatic source validation */ import { NextRequest, NextResponse } from 'next/server'; import { getVideoDetail, getVideoDetailCustom } from '@/lib/api/client'; import { getSourceById } from '@/lib/api/video-sources'; +import { filterValidEpisodes } from '@/lib/utils/url-validator'; import type { DetailRequest } from '@/lib/types'; export async function GET(request: NextRequest) { @@ -60,10 +61,31 @@ export async function GET(request: NextRequest) { ); } - // Fetch video detail + // Fetch video detail with automatic episode validation try { const videoDetail = await getVideoDetail(id, sourceConfig); + // Validate episodes to filter out broken sources + if (videoDetail.episodes && videoDetail.episodes.length > 0) { + console.log(`[GET] Validating ${videoDetail.episodes.length} episodes for video ${id}...`); + const validatedEpisodes = await filterValidEpisodes(videoDetail.episodes); + const workingEpisodes = validatedEpisodes.filter(ep => ep.isValid); + + console.log(`[GET] Found ${workingEpisodes.length} working episodes out of ${videoDetail.episodes.length}`); + + if (workingEpisodes.length === 0) { + return NextResponse.json( + { + success: false, + error: 'No playable episodes found from this source. Please try another source.', + }, + { status: 404 } + ); + } + + videoDetail.episodes = workingEpisodes; + } + return NextResponse.json({ success: true, data: videoDetail, @@ -143,10 +165,31 @@ export async function POST(request: NextRequest) { ); } - // Fetch video detail + // Fetch video detail with automatic episode validation try { const videoDetail = await getVideoDetail(id, sourceConfig); + // Validate episodes to filter out broken sources + if (videoDetail.episodes && videoDetail.episodes.length > 0) { + console.log(`[POST] Validating ${videoDetail.episodes.length} episodes for video ${id}...`); + const validatedEpisodes = await filterValidEpisodes(videoDetail.episodes); + const workingEpisodes = validatedEpisodes.filter(ep => ep.isValid); + + console.log(`[POST] Found ${workingEpisodes.length} working episodes out of ${videoDetail.episodes.length}`); + + if (workingEpisodes.length === 0) { + return NextResponse.json( + { + success: false, + error: 'No playable episodes found from this source. Please try another source.', + }, + { status: 404 } + ); + } + + videoDetail.episodes = workingEpisodes; + } + return NextResponse.json({ success: true, data: videoDetail, diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 5eb1271..88cc3a5 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,11 +1,13 @@ /** * Search API Route * Handles video search requests and aggregates results from multiple sources + * Now with automatic source availability detection */ import { NextRequest, NextResponse } from 'next/server'; import { searchVideos } from '@/lib/api/client'; import { getEnabledSources, getSourceById } from '@/lib/api/video-sources'; +import { checkMultipleSources, filterByAvailableSources } from '@/lib/utils/source-checker'; import type { SearchRequest, SearchResult } from '@/lib/types'; export async function POST(request: NextRequest) { @@ -43,12 +45,65 @@ export async function POST(request: NextRequest) { // Perform parallel search across sources const searchResults = await searchVideos(query.trim(), sources, page); + // Get source name mapping + const getSourceName = (sourceId: string): string => { + const sourceNames: Record = { + 'custom_0': '电影天堂', + 'custom_1': '如意', + 'custom_2': '暴风', + 'custom_3': '天涯', + 'custom_4': '非凡影视', + 'custom_5': '360', + 'custom_6': '卧龙', + 'custom_7': '极速', + 'custom_8': '魔爪', + 'custom_9': '魔都', + 'custom_10': '海外看', + 'custom_11': '新浪', + 'custom_12': '光速', + 'custom_13': '红牛', + 'custom_14': '樱花', + 'custom_15': '飞速', + }; + return sourceNames[sourceId] || sourceId; + }; + + // Check source availability by testing sample videos + console.log(`🔍 Checking availability of ${searchResults.length} sources...`); + const sourcesWithVideos = searchResults + .filter(result => result.results.length > 0) + .map(result => ({ + sourceId: result.source, + sourceName: getSourceName(result.source), + videos: result.results.slice(0, 3), // Use first 3 videos as samples + })); + + const availabilityResults = await checkMultipleSources(sourcesWithVideos); + + const availableCount = availabilityResults.filter(r => r.isAvailable).length; + console.log(`✅ ${availableCount} out of ${availabilityResults.length} sources are available`); + + // Filter results to only include videos from available sources + const allVideos = searchResults.flatMap(r => r.results); + const availableVideos = filterByAvailableSources(allVideos, availabilityResults); + + // Group available videos back by source + const availableSources = availabilityResults + .filter(r => r.isAvailable) + .map(r => { + const sourceVideos = availableVideos.filter(v => v.source === r.sourceId); + return { + source: r.sourceId, + results: sourceVideos, + responseTime: searchResults.find(sr => sr.source === r.sourceId)?.responseTime, + }; + }); + // Format response - const response: SearchResult[] = searchResults.map(result => ({ + const response: SearchResult[] = availableSources.map(result => ({ results: result.results, source: result.source, responseTime: result.responseTime, - error: result.error, })); return NextResponse.json({ @@ -56,7 +111,10 @@ export async function POST(request: NextRequest) { query: query.trim(), page, sources: response, - totalResults: response.reduce((sum, r) => sum + r.results.length, 0), + totalResults: availableVideos.length, + availableSources: availableCount, + totalSources: availabilityResults.length, + sourceAvailability: availabilityResults, }); } catch (error) { console.error('Search API error:', error); @@ -106,12 +164,65 @@ export async function GET(request: NextRequest) { // Perform search const searchResults = await searchVideos(query.trim(), sources, page); + // Get source name mapping + const getSourceName = (sourceId: string): string => { + const sourceNames: Record = { + 'custom_0': '电影天堂', + 'custom_1': '如意', + 'custom_2': '暴风', + 'custom_3': '天涯', + 'custom_4': '非凡影视', + 'custom_5': '360', + 'custom_6': '卧龙', + 'custom_7': '极速', + 'custom_8': '魔爪', + 'custom_9': '魔都', + 'custom_10': '海外看', + 'custom_11': '新浪', + 'custom_12': '光速', + 'custom_13': '红牛', + 'custom_14': '樱花', + 'custom_15': '飞速', + }; + return sourceNames[sourceId] || sourceId; + }; + + // Check source availability by testing sample videos + console.log(`🔍 [GET] Checking availability of ${searchResults.length} sources...`); + const sourcesWithVideos = searchResults + .filter(result => result.results.length > 0) + .map(result => ({ + sourceId: result.source, + sourceName: getSourceName(result.source), + videos: result.results.slice(0, 3), // Use first 3 videos as samples + })); + + const availabilityResults = await checkMultipleSources(sourcesWithVideos); + + const availableCount = availabilityResults.filter(r => r.isAvailable).length; + console.log(`✅ [GET] ${availableCount} out of ${availabilityResults.length} sources are available`); + + // Filter results to only include videos from available sources + const allVideos = searchResults.flatMap(r => r.results); + const availableVideos = filterByAvailableSources(allVideos, availabilityResults); + + // Group available videos back by source + const availableSources = availabilityResults + .filter(r => r.isAvailable) + .map(r => { + const sourceVideos = availableVideos.filter(v => v.source === r.sourceId); + return { + source: r.sourceId, + results: sourceVideos, + responseTime: searchResults.find(sr => sr.source === r.sourceId)?.responseTime, + }; + }); + // Format response - const response: SearchResult[] = searchResults.map(result => ({ + const response: SearchResult[] = availableSources.map(result => ({ results: result.results, source: result.source, responseTime: result.responseTime, - error: result.error, })); return NextResponse.json({ @@ -119,7 +230,10 @@ export async function GET(request: NextRequest) { query: query.trim(), page, sources: response, - totalResults: response.reduce((sum, r) => sum + r.results.length, 0), + totalResults: availableVideos.length, + availableSources: availableCount, + totalSources: availabilityResults.length, + sourceAvailability: availabilityResults, }); } catch (error) { console.error('Search API error:', error); diff --git a/app/globals.css b/app/globals.css index 781333e..9f23058 100644 --- a/app/globals.css +++ b/app/globals.css @@ -44,13 +44,6 @@ --foreground: #1d1d1f; } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - body { --bg-color: var(--bg-color-light); --bg-image: var(--bg-image-light); diff --git a/app/page.tsx b/app/page.tsx index 3acf235..44cf338 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -14,6 +14,8 @@ export default function Home() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); + const [availableSources, setAvailableSources] = useState>([]); + const [validationStatus, setValidationStatus] = useState(''); const router = useRouter(); const handleSearch = async (e: React.FormEvent) => { @@ -21,6 +23,7 @@ export default function Home() { if (!query.trim()) return; setLoading(true); + setValidationStatus('搜索中...'); try { // Get all enabled source IDs const sourceIds = ['custom_0', 'custom_1', 'custom_2', 'custom_3', 'custom_4', @@ -35,16 +38,62 @@ export default function Home() { const data = await response.json(); if (data.success) { - const allResults = data.sources.flatMap((s: any) => s.results); - setResults(allResults); + setValidationStatus(`已检测 ${data.totalSources || 0} 个源,${data.availableSources || 0} 个可用`); + + // Filter out sources with no results and add source names + const resultsWithSources = data.sources + .filter((s: any) => s.results.length > 0) + .flatMap((s: any) => + s.results.map((result: any) => ({ + ...result, + sourceName: getSourceName(s.source), + })) + ); + setResults(resultsWithSources); + + // Track available sources + const sourcesWithResults = data.sources + .filter((s: any) => s.results.length > 0) + .map((s: any) => ({ + id: s.source, + name: getSourceName(s.source), + count: s.results.length, + })); + setAvailableSources(sourcesWithResults); + + // Clear validation status after 3 seconds + setTimeout(() => setValidationStatus(''), 3000); } } catch (error) { console.error('Search error:', error); + setValidationStatus('搜索失败'); } finally { setLoading(false); } }; + const getSourceName = (sourceId: string): string => { + const sourceNames: Record = { + 'custom_0': '电影天堂', + 'custom_1': '如意', + 'custom_2': '暴风', + 'custom_3': '天涯', + 'custom_4': '非凡影视', + 'custom_5': '360', + 'custom_6': '卧龙', + 'custom_7': '极速', + 'custom_8': '魔爪', + 'custom_9': '魔都', + 'custom_10': '海外看', + 'custom_11': '新浪', + 'custom_12': '光速', + 'custom_13': '红牛', + 'custom_14': '樱花', + 'custom_15': '飞速', + }; + return sourceNames[sourceId] || sourceId; + }; + const handleVideoClick = (video: any) => { const params = new URLSearchParams({ id: video.vod_id, @@ -115,7 +164,7 @@ export default function Home() { - 搜索中... + 检测中... ) : ( @@ -125,17 +174,46 @@ export default function Home() { )} + {/* Validation Status */} + {validationStatus && ( +
+ {validationStatus} +
+ )} {/* Results Section */} {results.length > 0 && (
-
-

- 搜索结果 - {results.length} 个视频 -

+
+
+

+ 搜索结果 + {results.length} 个视频 +

+
+ + {/* Available Sources */} + {availableSources.length > 0 && ( + +
+ + + 可用源 ({availableSources.length}): + + {availableSources.map((source) => ( + + {source.name} ({source.count}) + + ))} +
+
+ )}
@@ -146,7 +224,7 @@ export default function Home() { className="p-0 overflow-hidden group" > {/* Poster */} -
+
{video.vod_pic ? ( )} + {/* Source Badge - Top Left */} + {video.sourceName && ( +
+ + {video.sourceName} + +
+ )} + {/* Overlay */}
diff --git a/app/player/page.tsx b/app/player/page.tsx index 55152b2..0cd9999 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -24,6 +24,29 @@ function PlayerContent() { const source = searchParams.get('source'); const title = searchParams.get('title'); + const getSourceName = (sourceId: string | null): string => { + if (!sourceId) return ''; + const sourceNames: Record = { + 'custom_0': '电影天堂', + 'custom_1': '如意', + 'custom_2': '暴风', + 'custom_3': '天涯', + 'custom_4': '非凡影视', + 'custom_5': '360', + 'custom_6': '卧龙', + 'custom_7': '极速', + 'custom_8': '魔爪', + 'custom_9': '魔都', + 'custom_10': '海外看', + 'custom_11': '新浪', + 'custom_12': '光速', + 'custom_13': '红牛', + 'custom_14': '樱花', + 'custom_15': '飞速', + }; + return sourceNames[sourceId] || sourceId; + }; + useEffect(() => { if (!videoId || !source) { router.push('/'); @@ -36,12 +59,19 @@ function PlayerContent() { const fetchVideoDetails = async () => { try { setLoading(true); + setVideoError(''); // Clear previous errors const response = await fetch(`/api/detail?id=${videoId}&source=${source}`); const data = await response.json(); console.log('Video detail API response:', data); if (!response.ok) { + // Handle specific error case when source is not available + if (response.status === 404) { + setVideoError(data.error || 'This video source is not available. Please go back and try another source.'); + setLoading(false); + return; + } throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`); } @@ -61,14 +91,14 @@ function PlayerContent() { setIsVideoLoading(true); } else { console.warn('No episodes found in video data'); - setVideoError('No episodes available for this video'); + setVideoError('No playable episodes available for this video from this source'); } } else { throw new Error(data.error || 'Invalid response from API'); } } catch (error) { console.error('Failed to fetch video details:', error); - setVideoError(error instanceof Error ? error.message : 'Failed to load video details'); + setVideoError(error instanceof Error ? error.message : 'Failed to load video details. Please try another source.'); } finally { setLoading(false); } @@ -139,8 +169,35 @@ function PlayerContent() {
{loading ? ( -
-
+
+
+

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

+
+ ) : videoError && !videoData ? ( +
+ + +

视频源不可用

+

{videoError}

+
+ + +
+
) : (
@@ -152,11 +209,11 @@ function PlayerContent() {
{videoError && (
-
+

播放失败

{videoError}

-
+
+
@@ -220,7 +285,7 @@ function PlayerContent() { {videoData.vod_name} )}
@@ -228,8 +293,14 @@ function PlayerContent() { {videoData?.vod_name || title}
+ {source && ( + + + {getSourceName(source)} + + )} {videoData?.type_name && ( - {videoData.type_name} + {videoData.type_name} )} {videoData?.vod_year && ( diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx index 9cbf2c8..66a6470 100644 --- a/components/ui/Card.tsx +++ b/components/ui/Card.tsx @@ -9,7 +9,7 @@ interface CardProps { export function Card({ children, className = '', hover = true, onClick }: CardProps) { const hoverStyles = hover - ? "hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_20px_color-mix(in_srgb,var(--shadow-color)_60%,transparent)] cursor-pointer transition-all duration-[var(--transition-fluid)]" + ? "hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_24px_var(--shadow-color)] cursor-pointer transition-all duration-[var(--transition-fluid)]" : "transition-all duration-[var(--transition-fluid)]"; return ( @@ -21,7 +21,7 @@ export function Card({ children, className = '', hover = true, onClick }: CardPr saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)] rounded-[var(--radius-2xl)] - shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_40%,transparent)] + shadow-[var(--shadow-md)] border border-[var(--glass-border)] p-6 diff --git a/components/ui/Icon.tsx b/components/ui/Icon.tsx index d13bfa8..b5bc202 100644 --- a/components/ui/Icon.tsx +++ b/components/ui/Icon.tsx @@ -260,4 +260,20 @@ export const Icons = { ), + + Check: ({ className = "", size = 24 }: IconProps) => ( + + + + ), }; diff --git a/lib/utils/source-checker.ts b/lib/utils/source-checker.ts new file mode 100644 index 0000000..6048fce --- /dev/null +++ b/lib/utils/source-checker.ts @@ -0,0 +1,166 @@ +/** + * Source Availability Checker + * Pre-validates video sources during search to filter out unavailable ones + */ + +import { isValidUrlFormat } from './url-validator'; + +const CHECK_TIMEOUT = 3000; // 3 seconds per check +const MAX_RETRIES = 2; + +export interface SourceCheckResult { + sourceId: string; + sourceName: string; + isAvailable: boolean; + sampleUrl?: string; + error?: string; + checkedAt: number; +} + +/** + * Check if a single video URL is accessible + */ +async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise { + if (!isValidUrlFormat(url)) { + return false; + } + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CHECK_TIMEOUT); + + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + headers: { + 'User-Agent': 'Mozilla/5.0', + 'Referer': new URL(url).origin, + }, + }); + + clearTimeout(timeoutId); + + // Consider 200, 206, and even 403 as "available" + // (some sources block HEAD but work with actual playback) + if (response.ok || response.status === 206 || response.status === 403) { + return true; + } + } catch (error) { + // If last attempt, return false + if (attempt === retries) { + console.error(`Failed to check URL after ${retries + 1} attempts:`, error); + return false; + } + // Wait before retry + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + return false; +} + +/** + * Extract first playable URL from video data + */ +function extractFirstVideoUrl(video: any): string | null { + if (!video.vod_play_url) return null; + + try { + // Format: "Episode1$url1#Episode2$url2#..." + const episodes = video.vod_play_url.split('#'); + + for (const episode of episodes) { + const [, url] = episode.split('$'); + if (url && isValidUrlFormat(url)) { + return url; + } + } + } catch (error) { + console.error('Failed to extract video URL:', error); + } + + return null; +} + +/** + * Check if a source is available by testing a sample video + */ +export async function checkSourceAvailability( + sourceId: string, + sourceName: string, + sampleVideos: any[] +): Promise { + const startTime = Date.now(); + + // If no videos from this source, mark as unavailable + if (!sampleVideos || sampleVideos.length === 0) { + return { + sourceId, + sourceName, + isAvailable: false, + error: 'No videos found', + checkedAt: Date.now(), + }; + } + + // Try to find a video with a valid URL + for (const video of sampleVideos.slice(0, 3)) { // Check up to 3 videos + const videoUrl = extractFirstVideoUrl(video); + + if (!videoUrl) continue; + + console.log(`Checking source ${sourceName} with URL:`, videoUrl.substring(0, 50) + '...'); + + const isAvailable = await checkVideoUrl(videoUrl); + + if (isAvailable) { + console.log(`✅ Source ${sourceName} is AVAILABLE (checked in ${Date.now() - startTime}ms)`); + return { + sourceId, + sourceName, + isAvailable: true, + sampleUrl: videoUrl, + checkedAt: Date.now(), + }; + } + } + + console.log(`❌ Source ${sourceName} is UNAVAILABLE (checked in ${Date.now() - startTime}ms)`); + return { + sourceId, + sourceName, + isAvailable: false, + error: 'All sample videos failed to load', + checkedAt: Date.now(), + }; +} + +/** + * Check multiple sources in parallel + */ +export async function checkMultipleSources( + sourcesWithVideos: Array<{ sourceId: string; sourceName: string; videos: any[] }> +): Promise { + const checkPromises = sourcesWithVideos.map(({ sourceId, sourceName, videos }) => + checkSourceAvailability(sourceId, sourceName, videos) + ); + + return Promise.all(checkPromises); +} + +/** + * Filter search results to only include videos from available sources + */ +export function filterByAvailableSources( + videos: any[], + availableSources: SourceCheckResult[] +): any[] { + const availableSourceIds = new Set( + availableSources + .filter(s => s.isAvailable) + .map(s => s.sourceId) + ); + + return videos.filter(video => availableSourceIds.has(video.source)); +} diff --git a/lib/utils/url-validator.ts b/lib/utils/url-validator.ts new file mode 100644 index 0000000..3d4bf95 --- /dev/null +++ b/lib/utils/url-validator.ts @@ -0,0 +1,147 @@ +/** + * URL Validation Utility + * Checks if video URLs are accessible and valid + */ + +const VALIDATION_TIMEOUT = 3000; // 3 seconds +const MAX_CONCURRENT_CHECKS = 5; + +export interface ValidationResult { + url: string; + isValid: boolean; + error?: string; + responseTime?: number; +} + +/** + * Check if a URL is accessible with HEAD request + */ +async function checkUrlAccessibility(url: string): Promise { + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT); + + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + headers: { + 'User-Agent': 'Mozilla/5.0', + 'Referer': new URL(url).origin, + }, + }); + + clearTimeout(timeoutId); + + return { + url, + isValid: response.ok || response.status === 403, // Some sources block HEAD but work with GET + responseTime: Date.now() - startTime, + error: !response.ok && response.status !== 403 ? `HTTP ${response.status}` : undefined, + }; + } catch (error) { + return { + url, + isValid: false, + responseTime: Date.now() - startTime, + error: error instanceof Error ? error.message : 'Connection failed', + }; + } +} + +/** + * Validate multiple URLs in batches + */ +export async function validateUrls(urls: string[]): Promise { + const results: ValidationResult[] = []; + + // Process in batches to avoid overwhelming the network + for (let i = 0; i < urls.length; i += MAX_CONCURRENT_CHECKS) { + const batch = urls.slice(i, i + MAX_CONCURRENT_CHECKS); + const batchResults = await Promise.all( + batch.map(url => checkUrlAccessibility(url)) + ); + results.push(...batchResults); + } + + return results; +} + +/** + * Quick validation - just checks if URL format is valid + */ +export function isValidUrlFormat(url: string): boolean { + if (!url) return false; + + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Check if URL is likely a video URL + */ +export function isLikelyVideoUrl(url: string): boolean { + if (!isValidUrlFormat(url)) return false; + + const videoExtensions = ['.m3u8', '.mp4', '.flv', '.avi', '.mkv', '.ts']; + const lowerUrl = url.toLowerCase(); + + return videoExtensions.some(ext => lowerUrl.includes(ext)); +} + +/** + * Validate a single episode source + */ +export async function validateEpisodeSource( + episodeName: string, + url: string +): Promise<{ name: string; url: string; isValid: boolean; error?: string }> { + if (!isValidUrlFormat(url)) { + return { + name: episodeName, + url, + isValid: false, + error: 'Invalid URL format', + }; + } + + const result = await checkUrlAccessibility(url); + + return { + name: episodeName, + url, + isValid: result.isValid, + error: result.error, + }; +} + +/** + * Filter out invalid episodes + */ +export async function filterValidEpisodes( + episodes: Array<{ name: string; url: string; index: number }> +): Promise> { + // First filter by URL format + const validFormatEpisodes = episodes.filter(ep => isValidUrlFormat(ep.url)); + + if (validFormatEpisodes.length === 0) { + return episodes.map(ep => ({ ...ep, isValid: false })); + } + + // Check accessibility for first 3 episodes as sample + const samplesToCheck = validFormatEpisodes.slice(0, 3); + 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); + + return episodes.map(ep => ({ + ...ep, + isValid: isValidUrlFormat(ep.url) && (hasWorkingEpisodes || ep.url.includes('.m3u8')), + })); +}