Enhance video source validation and availability checks across API routes and UI components

This commit is contained in:
kuekhaoyang
2025-11-16 11:26:28 +08:00
parent 11a5055b76
commit 610cc522c7
9 changed files with 672 additions and 35 deletions
+46 -3
View File
@@ -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,
+120 -6
View File
@@ -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<string, string> = {
'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<string, string> = {
'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);
-7
View File
@@ -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);
+96 -9
View File
@@ -14,6 +14,8 @@ export default function Home() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [availableSources, setAvailableSources] = useState<Array<{id: string, name: string, count: number}>>([]);
const [validationStatus, setValidationStatus] = useState<string>('');
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<string, string> = {
'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() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
...
...
</span>
) : (
<span className="flex items-center gap-2">
@@ -125,17 +174,46 @@ export default function Home() {
)}
</Button>
</div>
{/* Validation Status */}
{validationStatus && (
<div className="mt-3 text-sm text-[var(--text-color-secondary)] animate-fade-in">
{validationStatus}
</div>
)}
</form>
</div>
{/* Results Section */}
{results.length > 0 && (
<div className="animate-fade-in">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
<span></span>
<Badge variant="primary">{results.length} </Badge>
</h3>
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
<span></span>
<Badge variant="primary">{results.length} </Badge>
</h3>
</div>
{/* Available Sources */}
{availableSources.length > 0 && (
<Card hover={false} className="p-4">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--text-color)] flex items-center gap-2">
<Icons.Check size={16} className="text-[var(--accent-color)]" />
({availableSources.length}):
</span>
{availableSources.map((source) => (
<Badge
key={source.id}
variant="secondary"
className="text-xs"
>
{source.name} ({source.count})
</Badge>
))}
</div>
</Card>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
@@ -146,7 +224,7 @@ export default function Home() {
className="p-0 overflow-hidden group"
>
{/* Poster */}
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-t-[var(--radius-2xl)]">
{video.vod_pic ? (
<img
src={video.vod_pic}
@@ -160,6 +238,15 @@ export default function Home() {
</div>
)}
{/* Source Badge - Top Left */}
{video.sourceName && (
<div className="absolute top-2 left-2 z-10">
<Badge variant="primary" className="text-xs backdrop-blur-md bg-[var(--accent-color)]/90">
{video.sourceName}
</Badge>
</div>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="absolute bottom-0 left-0 right-0 p-3">
+79 -8
View File
@@ -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<string, string> = {
'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() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{loading ? (
<div className="flex 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"></div>
<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>
</div>
) : videoError && !videoData ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Card className="max-w-2xl">
<Icons.AlertTriangle size={64} className="mx-auto mb-4 text-red-500" />
<h2 className="text-2xl font-bold text-[var(--text-color)] mb-4"></h2>
<p className="text-[var(--text-color-secondary)] mb-6">{videoError}</p>
<div className="flex gap-3 justify-center">
<Button
variant="primary"
onClick={() => router.push('/')}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={20} />
<span></span>
</Button>
<Button
variant="secondary"
onClick={fetchVideoDetails}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={20} />
<span></span>
</Button>
</div>
</Card>
</div>
) : (
<div className="grid lg:grid-cols-3 gap-6">
@@ -152,11 +209,11 @@ function PlayerContent() {
<div className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden">
{videoError && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-80 z-10 p-4">
<div className="text-center text-white">
<div className="text-center text-white max-w-md">
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
<div className="flex gap-2 justify-center">
<div className="flex gap-2 justify-center flex-wrap">
<Button
variant="primary"
onClick={() => {
@@ -170,6 +227,14 @@ function PlayerContent() {
<Icons.RefreshCw size={16} />
<span></span>
</Button>
<Button
variant="secondary"
onClick={() => router.push('/')}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={16} />
<span></span>
</Button>
</div>
</div>
</div>
@@ -220,7 +285,7 @@ function PlayerContent() {
<img
src={videoData.vod_pic}
alt={videoData.vod_name}
className="w-32 h-48 object-cover rounded-[var(--radius-2xl)]"
className="w-32 h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
/>
)}
<div className="flex-1">
@@ -228,8 +293,14 @@ function PlayerContent() {
{videoData?.vod_name || title}
</h1>
<div className="flex flex-wrap gap-2 mb-4">
{source && (
<Badge variant="primary" className="backdrop-blur-md">
<Icons.Check size={14} className="mr-1" />
{getSourceName(source)}
</Badge>
)}
{videoData?.type_name && (
<Badge variant="primary">{videoData.type_name}</Badge>
<Badge variant="secondary">{videoData.type_name}</Badge>
)}
{videoData?.vod_year && (
<Badge variant="secondary">
+2 -2
View File
@@ -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
+16
View File
@@ -260,4 +260,20 @@ export const Icons = {
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
),
Check: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polyline points="20 6 9 17 4 12"/>
</svg>
),
};
+166
View File
@@ -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<boolean> {
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<SourceCheckResult> {
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<SourceCheckResult[]> {
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));
}
+147
View File
@@ -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<ValidationResult> {
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<ValidationResult[]> {
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<Array<{ name: string; url: string; index: number; isValid: boolean }>> {
// 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')),
}));
}