mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 04:03:42 +08:00
feat: Add video card component, desktop player controls, and introduce API utilities for search and data parsing.
This commit is contained in:
+3
-76
@@ -1,68 +1,17 @@
|
||||
/**
|
||||
* API Client for fetching video data from multiple sources
|
||||
* Handles parallel requests, timeouts, retries, and data normalization
|
||||
* Handles parallel requests and data normalization
|
||||
*/
|
||||
|
||||
import type {
|
||||
VideoSource,
|
||||
VideoItem,
|
||||
VideoDetail,
|
||||
Episode,
|
||||
ApiSearchResponse,
|
||||
ApiDetailResponse,
|
||||
} from '@/lib/types';
|
||||
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout support
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = REQUEST_TIMEOUT
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry logic wrapper
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = MAX_RETRIES
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (i < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
import { fetchWithTimeout, withRetry } from './http-utils';
|
||||
import { parseEpisodes } from './parsers';
|
||||
|
||||
/**
|
||||
* Search videos from a single source
|
||||
@@ -146,29 +95,7 @@ export async function searchVideos(
|
||||
return Promise.all(searchPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse episode URL string into structured array
|
||||
*/
|
||||
function parseEpisodes(playUrl: string): Episode[] {
|
||||
if (!playUrl) return [];
|
||||
|
||||
try {
|
||||
// Format: "Episode1$url1#Episode2$url2#..."
|
||||
const episodes = playUrl.split('#').filter(Boolean);
|
||||
|
||||
return episodes.map((episode, index) => {
|
||||
const [name, url] = episode.split('$');
|
||||
return {
|
||||
name: name || `Episode ${index + 1}`,
|
||||
url: url || '',
|
||||
index,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse episodes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video detail from a single source
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* HTTP Utilities for API calls
|
||||
* Handles timeouts and retries
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout support
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number = REQUEST_TIMEOUT
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry logic wrapper
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = MAX_RETRIES
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (i < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* API Response Parsers
|
||||
*/
|
||||
|
||||
import type { Episode } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Parse episode URL string into structured array
|
||||
*/
|
||||
export function parseEpisodes(playUrl: string): Episode[] {
|
||||
if (!playUrl) return [];
|
||||
|
||||
try {
|
||||
// Format: "Episode1$url1#Episode2$url2#..."
|
||||
const episodes = playUrl.split('#').filter(Boolean);
|
||||
|
||||
return episodes.map((episode, index) => {
|
||||
const [name, url] = episode.split('$');
|
||||
return {
|
||||
name: name || `Episode ${index + 1}`,
|
||||
url: url || '',
|
||||
index,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse episodes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from 'react';
|
||||
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
|
||||
import { calculateRelevanceScore } from '@/lib/utils/search';
|
||||
import { sortVideos } from '@/lib/utils/sort';
|
||||
import { binaryInsertVideos } from '@/lib/utils/sorted-insert';
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
|
||||
interface Video {
|
||||
@@ -119,44 +120,8 @@ export function useParallelSearch(
|
||||
}));
|
||||
|
||||
|
||||
|
||||
// Optimized: Insert new videos in sorted position instead of re-sorting entire array
|
||||
setResults((prev) => {
|
||||
if (prev.length === 0) return newVideos;
|
||||
|
||||
// Binary insert for better performance with combined sorting
|
||||
const combined = [...prev];
|
||||
for (const video of newVideos) {
|
||||
const relevanceScore = video.relevanceScore || 0;
|
||||
const latency = video.latency || 99999; // Default high latency for sorting
|
||||
|
||||
// Find insert position using binary search
|
||||
// Sort by: 1) relevance score (DESC), 2) latency (ASC)
|
||||
let left = 0;
|
||||
let right = combined.length;
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const midRelevance = combined[mid].relevanceScore || 0;
|
||||
const midLatency = combined[mid].latency || 99999;
|
||||
|
||||
// Compare by relevance first
|
||||
if (midRelevance > relevanceScore) {
|
||||
left = mid + 1;
|
||||
} else if (midRelevance < relevanceScore) {
|
||||
right = mid;
|
||||
} else {
|
||||
// Same relevance, compare by latency (lower is better)
|
||||
if (midLatency < latency) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
combined.splice(left, 0, video);
|
||||
}
|
||||
return combined;
|
||||
});
|
||||
// Optimized: Insert new videos in sorted position
|
||||
setResults((prev) => binaryInsertVideos(prev, newVideos));
|
||||
|
||||
// Update source stats
|
||||
if (!sourcesMap.has(data.source)) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
|
||||
|
||||
export type SortOption =
|
||||
| 'default'
|
||||
@@ -36,49 +37,7 @@ export const sortOptions: Record<SortOption, string> = {
|
||||
'name-desc': '按名称(Z-A)',
|
||||
};
|
||||
|
||||
export const getDefaultSources = (): VideoSource[] => {
|
||||
// Import from video-sources to get all 38 default sources
|
||||
return [
|
||||
{ id: 'feifan', name: '非凡资源', baseUrl: 'http://ffzy5.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 1 },
|
||||
{ id: 'wolong', name: '卧龙资源', baseUrl: 'https://wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 2 },
|
||||
{ id: 'zuida', name: '最大资源', baseUrl: 'https://api.zuidapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 3 },
|
||||
{ id: 'baiduyun', name: '百度云资源', baseUrl: 'https://api.apibdzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 4 },
|
||||
{ id: 'baofeng', name: '暴风资源', baseUrl: 'https://bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 5 },
|
||||
{ id: 'jisu', name: '极速资源', baseUrl: 'https://jszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 6 },
|
||||
{ id: 'tianya', name: '天涯资源', baseUrl: 'https://tyyszy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 7 },
|
||||
{ id: 'wujin', name: '无尽资源', baseUrl: 'https://api.wujinapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 8 },
|
||||
{ id: 'modu', name: '魔都资源', baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 9 },
|
||||
{ id: 'sanliuling', name: '360资源', baseUrl: 'https://360zy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 10 },
|
||||
{ id: 'dytt', name: '电影天堂', baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 11 },
|
||||
{ id: 'ruyi', name: '如意资源', baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 12 },
|
||||
{ id: 'wangwang', name: '旺旺资源', baseUrl: 'https://wwzy.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 13 },
|
||||
{ id: 'hongniu', name: '红牛资源', baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 14 },
|
||||
{ id: 'guangsu', name: '光速资源', baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 15 },
|
||||
{ id: 'ikun', name: 'iKun资源', baseUrl: 'https://ikunzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 16 },
|
||||
{ id: 'youku', name: '优酷资源', baseUrl: 'https://api.ukuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 17 },
|
||||
{ id: 'huya', name: '虎牙资源', baseUrl: 'https://www.huyaapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 18 },
|
||||
{ id: 'xinlang', name: '新浪资源', baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 19 },
|
||||
{ id: 'lezi', name: '乐子资源', baseUrl: 'https://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 20 },
|
||||
{ id: 'haihua', name: '海豚资源', baseUrl: 'https://hhzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 21 },
|
||||
{ id: 'jiangyu', name: '鲸鱼资源', baseUrl: 'https://jyzyapi.com/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 22 },
|
||||
{ id: 'yilingba', name: '1080资源', baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php', searchPath: '', detailPath: '', enabled: true, priority: 23 },
|
||||
{ id: 'aidan', name: '爱蛋资源', baseUrl: 'https://lovedan.net/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 24 },
|
||||
{ id: 'leba', name: '乐播资源', baseUrl: 'https://lbapi9.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 25 },
|
||||
{ id: 'moduzy', name: '魔都影视', baseUrl: 'https://www.moduzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 26 },
|
||||
{ id: 'feifanapi', name: '非凡API', baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 27 },
|
||||
{ id: 'feifancj', name: '非凡采集', baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 28 },
|
||||
{ id: 'feifancj2', name: '非凡采集HTTPS', baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 29 },
|
||||
{ id: 'feifan1', name: '非凡线路1', baseUrl: 'http://ffzy1.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 30 },
|
||||
{ id: 'wolong2', name: '卧龙采集', baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 31 },
|
||||
{ id: 'baofeng2', name: '暴风APP', baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 32 },
|
||||
{ id: 'wujin2', name: '无尽ME', baseUrl: 'https://api.wujinapi.me/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 33 },
|
||||
{ id: 'tianyazy', name: '天涯海角', baseUrl: 'https://tyyszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 34 },
|
||||
{ id: 'guangsu2', name: '光速HTTP', baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 35 },
|
||||
{ id: 'xinlang2', name: '新浪HTTPS', baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 36 },
|
||||
{ id: 'yilingba2', name: '1080JSON', baseUrl: 'https://api.1080zyku.com/inc/apijson.php', searchPath: '', detailPath: '', enabled: true, priority: 37 },
|
||||
{ id: 'lezi2', name: '乐子HTTP', baseUrl: 'http://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 38 },
|
||||
];
|
||||
};
|
||||
export const getDefaultSources = (): VideoSource[] => DEFAULT_SOURCES;
|
||||
|
||||
export const settingsStore = {
|
||||
getSettings(): AppSettings {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Binary insert utility for sorted arrays
|
||||
*/
|
||||
|
||||
interface Video {
|
||||
relevanceScore?: number;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert videos into sorted array using binary search
|
||||
* Sorts by: 1) relevance score (DESC), 2) latency (ASC)
|
||||
*/
|
||||
export function binaryInsertVideos<T extends Video>(existing: T[], newVideos: T[]): T[] {
|
||||
if (existing.length === 0) return newVideos;
|
||||
|
||||
const combined = [...existing];
|
||||
|
||||
for (const video of newVideos) {
|
||||
const relevanceScore = video.relevanceScore || 0;
|
||||
const latency = video.latency || 99999;
|
||||
|
||||
// Find insert position using binary search
|
||||
let left = 0;
|
||||
let right = combined.length;
|
||||
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const midRelevance = combined[mid].relevanceScore || 0;
|
||||
const midLatency = combined[mid].latency || 99999;
|
||||
|
||||
if (midRelevance > relevanceScore) {
|
||||
left = mid + 1;
|
||||
} else if (midRelevance < relevanceScore) {
|
||||
right = mid;
|
||||
} else {
|
||||
// Same relevance, compare by latency
|
||||
if (midLatency < latency) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
combined.splice(left, 0, video);
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
Reference in New Issue
Block a user