mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 16:53:42 +08:00
Enhance video source validation and availability checks across API routes and UI components
This commit is contained in:
+46
-3
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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')),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user