refactor: remove unused utility files for error handling, M3U8 filtering, progress tracking, source switching, URL validation, and contrast testing

- Deleted error-handler.ts, m3u8-filter.ts, progress-tracker.ts, source-switcher.ts, url-validator.ts, and test-contrast.ts as they are no longer needed in the project.
This commit is contained in:
kuekhaoyang
2025-11-18 16:14:32 +08:00
parent 73eeea7161
commit d80e4420b6
14 changed files with 0 additions and 2974 deletions
-273
View File
@@ -1,273 +0,0 @@
/**
* Episode Manager
* Handles episode navigation and URL parameter management
*/
import type { Episode } from '@/lib/types';
/**
* Episode navigation parameters
*/
export interface EpisodeNavParams {
videoId: string | number;
title: string;
source: string;
episodeIndex: number;
url: string;
}
/**
* Build player URL with episode parameters
*/
export function buildPlayerUrl(params: EpisodeNavParams): string {
const searchParams = new URLSearchParams();
searchParams.set('id', params.videoId.toString());
searchParams.set('source', params.source);
searchParams.set('index', params.episodeIndex.toString());
searchParams.set('url', encodeURIComponent(params.url));
searchParams.set('title', encodeURIComponent(params.title));
return `/player?${searchParams.toString()}`;
}
/**
* Parse episode parameters from URL
*/
export function parsePlayerParams(searchParams: URLSearchParams): EpisodeNavParams | null {
const id = searchParams.get('id');
const source = searchParams.get('source');
const indexStr = searchParams.get('index');
const url = searchParams.get('url');
const title = searchParams.get('title');
if (!id || !source || !indexStr || !url) {
return null;
}
return {
videoId: id,
title: title || 'Unknown',
source,
episodeIndex: parseInt(indexStr, 10),
url: decodeURIComponent(url),
};
}
/**
* Navigate to next episode
*/
export function getNextEpisodeParams(
currentParams: EpisodeNavParams,
episodes: Episode[]
): EpisodeNavParams | null {
const nextIndex = currentParams.episodeIndex + 1;
if (nextIndex >= episodes.length) {
return null; // No more episodes
}
const nextEpisode = episodes[nextIndex];
return {
...currentParams,
episodeIndex: nextIndex,
url: nextEpisode.url,
};
}
/**
* Navigate to previous episode
*/
export function getPrevEpisodeParams(
currentParams: EpisodeNavParams,
episodes: Episode[]
): EpisodeNavParams | null {
const prevIndex = currentParams.episodeIndex - 1;
if (prevIndex < 0) {
return null; // Already at first episode
}
const prevEpisode = episodes[prevIndex];
return {
...currentParams,
episodeIndex: prevIndex,
url: prevEpisode.url,
};
}
/**
* Get episode by index
*/
export function getEpisodeByIndex(
episodes: Episode[],
index: number
): Episode | null {
if (index < 0 || index >= episodes.length) {
return null;
}
return episodes[index];
}
/**
* Validate episode index
*/
export function isValidEpisodeIndex(index: number, episodes: Episode[]): boolean {
return index >= 0 && index < episodes.length;
}
/**
* Get episode range for pagination
*/
export function getEpisodeRange(
episodes: Episode[],
currentIndex: number,
rangeSize: number = 10
): Episode[] {
const halfRange = Math.floor(rangeSize / 2);
let start = Math.max(0, currentIndex - halfRange);
let end = Math.min(episodes.length, start + rangeSize);
// Adjust if we're near the end
if (end - start < rangeSize) {
start = Math.max(0, end - rangeSize);
}
return episodes.slice(start, end);
}
/**
* Group episodes into sections
*/
export interface EpisodeSection {
title: string;
episodes: Episode[];
startIndex: number;
endIndex: number;
}
export function groupEpisodesIntoSections(
episodes: Episode[],
sectionSize: number = 20
): EpisodeSection[] {
const sections: EpisodeSection[] = [];
for (let i = 0; i < episodes.length; i += sectionSize) {
const end = Math.min(i + sectionSize, episodes.length);
sections.push({
title: `Episodes ${i + 1}-${end}`,
episodes: episodes.slice(i, end),
startIndex: i,
endIndex: end - 1,
});
}
return sections;
}
/**
* Reverse episode order
*/
export function reverseEpisodes(episodes: Episode[]): Episode[] {
return episodes.map((episode, index) => ({
...episode,
index: episodes.length - 1 - index,
})).reverse();
}
/**
* Search episodes by name
*/
export function searchEpisodes(episodes: Episode[], query: string): Episode[] {
if (!query.trim()) return episodes;
const normalizedQuery = query.toLowerCase();
return episodes.filter(episode =>
episode.name.toLowerCase().includes(normalizedQuery)
);
}
/**
* Get episode progress percentage
*/
export function getEpisodeProgress(
episodeIndex: number,
totalEpisodes: number
): number {
if (totalEpisodes === 0) return 0;
return Math.round(((episodeIndex + 1) / totalEpisodes) * 100);
}
/**
* Format episode name
*/
export function formatEpisodeName(episode: Episode, format: 'short' | 'full' = 'full'): string {
if (format === 'short') {
// Extract episode number if available
const match = episode.name.match(/\d+/);
if (match) {
return `EP ${match[0]}`;
}
return `EP ${episode.index + 1}`;
}
return episode.name || `Episode ${episode.index + 1}`;
}
/**
* Check if episode is watched
*/
export function isEpisodeWatched(
episodeIndex: number,
watchedUpTo: number
): boolean {
return episodeIndex <= watchedUpTo;
}
/**
* Get unwatched episodes count
*/
export function getUnwatchedCount(
episodes: Episode[],
watchedUpTo: number
): number {
return Math.max(0, episodes.length - watchedUpTo - 1);
}
/**
* Episode order preference
*/
const EPISODE_ORDER_KEY = 'kvideo_episode_order';
export function saveEpisodeOrder(order: 'normal' | 'reversed'): void {
if (typeof window === 'undefined') return;
localStorage.setItem(EPISODE_ORDER_KEY, order);
}
export function getEpisodeOrder(): 'normal' | 'reversed' {
if (typeof window === 'undefined') return 'normal';
return (localStorage.getItem(EPISODE_ORDER_KEY) as 'normal' | 'reversed') || 'normal';
}
/**
* Apply episode order preference
*/
export function applyEpisodeOrder(episodes: Episode[]): Episode[] {
const order = getEpisodeOrder();
return order === 'reversed' ? reverseEpisodes(episodes) : episodes;
}
/**
* Toggle episode order
*/
export function toggleEpisodeOrder(): 'normal' | 'reversed' {
const current = getEpisodeOrder();
const newOrder = current === 'normal' ? 'reversed' : 'normal';
saveEpisodeOrder(newOrder);
return newOrder;
}
-320
View File
@@ -1,320 +0,0 @@
/**
* Error Handler Utility
* Comprehensive error handling and recovery strategies for video playback
*/
import type { ApiError } from '@/lib/types';
export enum ErrorType {
NETWORK_ERROR = 'NETWORK_ERROR',
MEDIA_ERROR = 'MEDIA_ERROR',
HLS_ERROR = 'HLS_ERROR',
API_ERROR = 'API_ERROR',
TIMEOUT = 'TIMEOUT',
UNKNOWN = 'UNKNOWN',
}
export interface VideoError {
type: ErrorType;
message: string;
originalError?: Error;
retryable: boolean;
retryCount?: number;
}
/**
* Create a standardized video error
*/
export function createVideoError(
type: ErrorType,
message: string,
originalError?: Error,
retryable: boolean = true
): VideoError {
return {
type,
message,
originalError,
retryable,
retryCount: 0,
};
}
/**
* Get user-friendly error message
*/
export function getUserFriendlyMessage(error: VideoError): string {
switch (error.type) {
case ErrorType.NETWORK_ERROR:
return 'Network connection error. Please check your internet connection and try again.';
case ErrorType.MEDIA_ERROR:
return 'Unable to play this video. The media format may not be supported.';
case ErrorType.HLS_ERROR:
return 'Video streaming error. Trying to recover...';
case ErrorType.API_ERROR:
return 'Failed to load video information. Please try again later.';
case ErrorType.TIMEOUT:
return 'Request timed out. The server may be slow or unreachable.';
default:
return 'An unexpected error occurred. Please try again.';
}
}
/**
* Handle HLS.js errors with recovery strategies
*/
export function handleHLSError(
hls: any,
errorData: any,
retryCount: number = 0
): {
shouldRetry: boolean;
action: 'recoverMedia' | 'startLoad' | 'destroy' | 'none';
error: VideoError;
} {
const maxRetries = 3;
// Network errors
if (errorData.type === 'networkError') {
if (retryCount < maxRetries) {
return {
shouldRetry: true,
action: 'startLoad',
error: createVideoError(
ErrorType.NETWORK_ERROR,
'Network error while loading video',
errorData,
true
),
};
}
}
// Media errors
if (errorData.type === 'mediaError') {
if (errorData.details === 'bufferAppendError') {
// Often recoverable
if (retryCount < maxRetries) {
return {
shouldRetry: true,
action: 'recoverMedia',
error: createVideoError(
ErrorType.MEDIA_ERROR,
'Buffer append error',
errorData,
true
),
};
}
}
if (errorData.details === 'bufferStalledError') {
return {
shouldRetry: true,
action: 'startLoad',
error: createVideoError(
ErrorType.MEDIA_ERROR,
'Buffer stalled error',
errorData,
true
),
};
}
// Try to recover
if (retryCount < maxRetries) {
return {
shouldRetry: true,
action: 'recoverMedia',
error: createVideoError(
ErrorType.MEDIA_ERROR,
'Media error occurred',
errorData,
true
),
};
}
}
// Fatal errors
if (errorData.fatal) {
return {
shouldRetry: false,
action: 'destroy',
error: createVideoError(
ErrorType.HLS_ERROR,
'Fatal HLS error',
errorData,
false
),
};
}
// Default: don't retry
return {
shouldRetry: false,
action: 'none',
error: createVideoError(
ErrorType.HLS_ERROR,
errorData.details || 'Unknown HLS error',
errorData,
false
),
};
}
/**
* Retry with exponential backoff
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
initialDelay: number = 1000
): Promise<T> {
let lastError: Error;
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < maxRetries) {
const delay = initialDelay * Math.pow(2, i);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}
/**
* Check if error is retryable
*/
export function isRetryableError(error: any): boolean {
if (!error) return false;
// Check for network-related errors
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
return true;
}
// Check for timeout errors
if (error.name === 'AbortError' || error.message?.includes('timeout')) {
return true;
}
// Check HTTP status codes
if (error.status) {
const retryableStatuses = [408, 429, 500, 502, 503, 504];
return retryableStatuses.includes(error.status);
}
return false;
}
/**
* Log error for debugging
*/
export function logError(error: VideoError, context?: Record<string, any>): void {
const errorInfo = {
timestamp: new Date().toISOString(),
type: error.type,
message: error.message,
retryable: error.retryable,
retryCount: error.retryCount,
context,
originalError: error.originalError?.message,
stack: error.originalError?.stack,
};
console.error('[KVideo Error]', errorInfo);
// In production, you might want to send this to an error tracking service
// e.g., Sentry, LogRocket, etc.
}
/**
* Handle API errors
*/
export function handleAPIError(error: any): ApiError {
if (error.name === 'AbortError') {
return {
code: 'TIMEOUT',
message: 'Request timed out',
retryable: true,
};
}
if (error.message?.includes('fetch')) {
return {
code: 'NETWORK_ERROR',
message: 'Network error occurred',
retryable: true,
};
}
return {
code: 'API_ERROR',
message: error.message || 'Unknown API error',
retryable: isRetryableError(error),
};
}
/**
* Error recovery strategies
*/
export const ErrorRecovery = {
/**
* Recover from network errors
*/
async recoverNetwork(
retryFn: () => Promise<void>,
maxAttempts: number = 3
): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
await retryFn();
return true;
} catch (error) {
if (i === maxAttempts - 1) {
return false;
}
await new Promise(resolve => setTimeout(resolve, 2000 * (i + 1)));
}
}
return false;
},
/**
* Recover from media errors
*/
async recoverMedia(hls: any): Promise<boolean> {
try {
hls.recoverMediaError();
return true;
} catch {
return false;
}
},
/**
* Reload video from scratch
*/
async reloadVideo(hls: any, url: string): Promise<boolean> {
try {
hls.destroy();
hls.loadSource(url);
hls.attachMedia(document.querySelector('video'));
return true;
} catch {
return false;
}
},
};
-366
View File
@@ -1,366 +0,0 @@
/**
* M3U8 Ad Filtering Utility
* Custom HLS loader with ad segment filtering
*/
// Ad detection patterns
const AD_PATTERNS = [
'/ad/',
'/ads/',
'/advertisement/',
'/advert/',
'_ad_',
'_ads_',
'-ad-',
'-ads-',
'ad.ts',
'ad.m3u8',
'ads.ts',
'ads.m3u8',
'advert',
'commercial',
'/promo/',
];
// Additional keywords to filter
const AD_KEYWORDS = [
'advertisement',
'commercial',
'sponsored',
'promo',
'banner',
];
/**
* Check if URL contains ad patterns
*/
function isAdSegment(url: string): boolean {
const lowerUrl = url.toLowerCase();
// Check URL patterns
if (AD_PATTERNS.some(pattern => lowerUrl.includes(pattern))) {
return true;
}
// Check keywords
if (AD_KEYWORDS.some(keyword => lowerUrl.includes(keyword))) {
return true;
}
return false;
}
/**
* Parse M3U8 playlist content
*/
interface M3U8Segment {
duration?: number;
url: string;
metadata: string[];
isAd: boolean;
}
function parseM3U8(content: string, baseUrl: string): {
header: string[];
segments: M3U8Segment[];
} {
const lines = content.split('\n');
const header: string[] = [];
const segments: M3U8Segment[] = [];
let currentMetadata: string[] = [];
let inHeader = true;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Header lines
if (line.startsWith('#EXTM3U')) {
header.push(line);
continue;
}
// Check if we're still in header
if (inHeader && line.startsWith('#EXT-X-')) {
header.push(line);
continue;
}
if (line.startsWith('#EXTINF')) {
inHeader = false;
currentMetadata.push(line);
// Extract duration
const durationMatch = line.match(/#EXTINF:([\d.]+)/);
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
// Next line should be the URL
if (i + 1 < lines.length) {
i++;
const urlLine = lines[i].trim();
if (urlLine && !urlLine.startsWith('#')) {
// Resolve URL
const resolvedUrl = resolveUrl(urlLine, baseUrl);
const isAd = isAdSegment(resolvedUrl);
segments.push({
duration,
url: urlLine, // Keep original URL
metadata: [...currentMetadata],
isAd,
});
currentMetadata = [];
}
}
} else if (line.startsWith('#')) {
if (inHeader) {
header.push(line);
} else {
currentMetadata.push(line);
}
}
}
return { header, segments };
}
/**
* Resolve relative URL
*/
function resolveUrl(url: string, baseUrl: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
try {
const base = new URL(baseUrl);
return new URL(url, base).href;
} catch {
return url;
}
}
/**
* Filter M3U8 playlist to remove ads
*/
export function filterM3U8Playlist(content: string, baseUrl: string): string {
const { header, segments } = parseM3U8(content, baseUrl);
// Filter out ad segments
const filteredSegments = segments.filter(segment => !segment.isAd);
// Rebuild playlist
const output: string[] = [...header];
let needsDiscontinuity = false;
for (let i = 0; i < filteredSegments.length; i++) {
const segment = filteredSegments[i];
const prevSegment = i > 0 ? filteredSegments[i - 1] : null;
// Check if we need discontinuity tag
if (prevSegment && needsDiscontinuity) {
// Find discontinuity in metadata
const hasDiscontinuity = segment.metadata.some(line =>
line.includes('DISCONTINUITY')
);
if (!hasDiscontinuity) {
// Add discontinuity if needed
output.push('#EXT-X-DISCONTINUITY');
}
needsDiscontinuity = false;
}
// Add segment metadata (excluding discontinuity tags)
segment.metadata.forEach(line => {
if (!line.includes('DISCONTINUITY')) {
output.push(line);
}
});
// Add segment URL
output.push(segment.url);
}
return output.join('\n');
}
/**
* Custom HLS loader with ad filtering
*/
export class AdFilteringHLSLoader {
private baseUrl: string = '';
load(
context: any,
config: any,
callbacks: any
): void {
const url = context.url;
// Store base URL for resolving relative URLs
if (url.includes('.m3u8')) {
this.baseUrl = url.substring(0, url.lastIndexOf('/'));
}
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
})
.then(content => {
// Check if it's a playlist
if (content.includes('#EXTM3U') && content.includes('#EXTINF')) {
// Filter ads from playlist
const filtered = filterM3U8Playlist(content, url);
// Convert to response format
const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' });
const reader = new FileReader();
reader.onload = () => {
callbacks.onSuccess(
{
url,
data: reader.result,
},
{
url,
},
context
);
};
reader.onerror = () => {
callbacks.onError(
{
code: 500,
text: 'Failed to process playlist',
},
context
);
};
reader.readAsText(blob);
} else {
// Not a playlist, pass through
callbacks.onSuccess(
{
url,
data: content,
},
{
url,
},
context
);
}
})
.catch(error => {
callbacks.onError(
{
code: 500,
text: error.message,
},
context
);
});
}
abort(): void {
// Implement abort logic if needed
}
}
/**
* Create HLS config with ad filtering
*/
export function createAdFilteringConfig(hlsConfig: any = {}): any {
return {
...hlsConfig,
loader: AdFilteringHLSLoader,
debug: false,
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90,
};
}
/**
* Detect if M3U8 contains ads
*/
export async function detectAdsInM3U8(url: string): Promise<{
hasAds: boolean;
adCount: number;
totalSegments: number;
}> {
try {
const response = await fetch(url);
const content = await response.text();
const { segments } = parseM3U8(content, url);
const adSegments = segments.filter(s => s.isAd);
return {
hasAds: adSegments.length > 0,
adCount: adSegments.length,
totalSegments: segments.length,
};
} catch (error) {
console.error('Failed to detect ads:', error);
return {
hasAds: false,
adCount: 0,
totalSegments: 0,
};
}
}
/**
* Add custom ad pattern
*/
const CUSTOM_AD_PATTERNS_KEY = 'kvideo_custom_ad_patterns';
export function addCustomAdPattern(pattern: string): void {
if (typeof window === 'undefined') return;
try {
const patterns = getCustomAdPatterns();
if (!patterns.includes(pattern)) {
patterns.push(pattern);
localStorage.setItem(CUSTOM_AD_PATTERNS_KEY, JSON.stringify(patterns));
}
} catch (error) {
console.error('Failed to add custom ad pattern:', error);
}
}
export function getCustomAdPatterns(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(CUSTOM_AD_PATTERNS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function removeCustomAdPattern(pattern: string): void {
if (typeof window === 'undefined') return;
try {
const patterns = getCustomAdPatterns();
const filtered = patterns.filter(p => p !== pattern);
localStorage.setItem(CUSTOM_AD_PATTERNS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to remove custom ad pattern:', error);
}
}
-225
View File
@@ -1,225 +0,0 @@
/**
* Progress Tracker Utility
* Manages video playback progress with localStorage persistence
*/
import type { VideoProgress } from '@/lib/types';
const STORAGE_PREFIX = 'kvideo_progress_';
const PROGRESS_SAVE_THRESHOLD = 10; // seconds
const RESUME_MIN_POSITION = 10; // seconds
const RESUME_MAX_REMAINING = 120; // seconds
/**
* Get progress key for a video
*/
function getProgressKey(videoId: string | number, source: string): string {
return `${STORAGE_PREFIX}${source}_${videoId}`;
}
/**
* Save video progress to localStorage
*/
export function saveProgress(
videoId: string | number,
source: string,
position: number,
duration: number,
episodeIndex: number = 0
): void {
if (typeof window === 'undefined') return;
// Don't save if position is too early or too late
if (position < PROGRESS_SAVE_THRESHOLD) return;
if (duration > 0 && duration - position < RESUME_MAX_REMAINING) {
// Video is almost finished, clear progress
clearProgress(videoId, source);
return;
}
try {
const progress: VideoProgress = {
videoId,
position,
duration,
timestamp: Date.now(),
episodeIndex,
};
const key = getProgressKey(videoId, source);
localStorage.setItem(key, JSON.stringify(progress));
} catch (error) {
console.error('Failed to save progress:', error);
}
}
/**
* Get video progress from localStorage
*/
export function getProgress(
videoId: string | number,
source: string
): VideoProgress | null {
if (typeof window === 'undefined') return null;
try {
const key = getProgressKey(videoId, source);
const stored = localStorage.getItem(key);
if (!stored) return null;
const progress: VideoProgress = JSON.parse(stored);
// Validate progress data
if (!progress.position || !progress.timestamp) {
return null;
}
return progress;
} catch (error) {
console.error('Failed to get progress:', error);
return null;
}
}
/**
* Check if progress should be resumed
*/
export function shouldResumeProgress(progress: VideoProgress | null): boolean {
if (!progress) return false;
const { position, duration } = progress;
// Don't resume if position is too early
if (position < RESUME_MIN_POSITION) return false;
// Don't resume if video is almost finished
if (duration > 0 && duration - position < RESUME_MAX_REMAINING) return false;
return true;
}
/**
* Clear video progress
*/
export function clearProgress(videoId: string | number, source: string): void {
if (typeof window === 'undefined') return;
try {
const key = getProgressKey(videoId, source);
localStorage.removeItem(key);
} catch (error) {
console.error('Failed to clear progress:', error);
}
}
/**
* Get all stored progress entries
*/
export function getAllProgress(): VideoProgress[] {
if (typeof window === 'undefined') return [];
const allProgress: VideoProgress[] = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(STORAGE_PREFIX)) {
const stored = localStorage.getItem(key);
if (stored) {
try {
const progress: VideoProgress = JSON.parse(stored);
allProgress.push(progress);
} catch {
// Invalid progress entry, skip
}
}
}
}
} catch (error) {
console.error('Failed to get all progress:', error);
}
return allProgress;
}
/**
* Clear old progress entries (older than 30 days)
*/
export function clearOldProgress(daysOld: number = 30): number {
if (typeof window === 'undefined') return 0;
const cutoffTime = Date.now() - daysOld * 24 * 60 * 60 * 1000;
let clearedCount = 0;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(STORAGE_PREFIX)) {
const stored = localStorage.getItem(key);
if (stored) {
try {
const progress: VideoProgress = JSON.parse(stored);
if (progress.timestamp < cutoffTime) {
keysToRemove.push(key);
}
} catch {
// Invalid entry, mark for removal
keysToRemove.push(key);
}
}
}
}
// Remove old entries
keysToRemove.forEach(key => {
localStorage.removeItem(key);
clearedCount++;
});
} catch (error) {
console.error('Failed to clear old progress:', error);
}
return clearedCount;
}
/**
* Throttle function for saving progress
*/
export function createProgressSaver(
saveInterval: number = 5000
): (
videoId: string | number,
source: string,
position: number,
duration: number,
episodeIndex?: number
) => void {
let lastSaveTime = 0;
let pendingSave: ReturnType<typeof setTimeout> | null = null;
return (videoId, source, position, duration, episodeIndex = 0) => {
const now = Date.now();
// Clear any pending save
if (pendingSave) {
clearTimeout(pendingSave);
}
// Save immediately if enough time has passed
if (now - lastSaveTime >= saveInterval) {
saveProgress(videoId, source, position, duration, episodeIndex);
lastSaveTime = now;
} else {
// Schedule a save for later
pendingSave = setTimeout(() => {
saveProgress(videoId, source, position, duration, episodeIndex);
lastSaveTime = Date.now();
}, saveInterval - (now - lastSaveTime));
}
};
}
-332
View File
@@ -1,332 +0,0 @@
/**
* Source Switcher Utility
* Tests source speeds and provides switching logic
*/
import type { VideoSource, VideoDetail, SourceSpeedResult } from '@/lib/types';
import { getVideoDetail, testVideoUrl } from '@/lib/api/client';
import { searchVideos } from '@/lib/api/client';
const SPEED_TEST_TIMEOUT = 10000;
/**
* Test source speed by fetching video detail
*/
async function testSourceSpeed(
videoTitle: string,
source: VideoSource
): Promise<SourceSpeedResult> {
const startTime = Date.now();
try {
// First, search for the video by title
const searchResults = await searchVideos(videoTitle, [source]);
if (searchResults.length === 0 || searchResults[0].results.length === 0) {
return {
source: source.id,
sourceName: source.name,
speed: Infinity,
available: false,
error: 'Video not found in this source',
};
}
const firstResult = searchResults[0].results[0];
// Fetch video detail
const videoDetail = await getVideoDetail(firstResult.vod_id, source);
if (!videoDetail.episodes || videoDetail.episodes.length === 0) {
return {
source: source.id,
sourceName: source.name,
speed: Infinity,
available: false,
error: 'No episodes available',
};
}
// Test first episode URL
const firstEpisodeUrl = videoDetail.episodes[0].url;
const urlTestStartTime = Date.now();
const isAccessible = await Promise.race([
testVideoUrl(firstEpisodeUrl),
new Promise<boolean>((resolve) =>
setTimeout(() => resolve(false), 5000)
),
]);
if (!isAccessible) {
return {
source: source.id,
sourceName: source.name,
speed: Infinity,
available: false,
error: 'Video URL not accessible',
videoDetail,
};
}
const urlTestTime = Date.now() - urlTestStartTime;
const totalTime = Date.now() - startTime;
return {
source: source.id,
sourceName: source.name,
speed: totalTime,
available: true,
videoDetail,
};
} catch (error) {
return {
source: source.id,
sourceName: source.name,
speed: Infinity,
available: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Test multiple sources in parallel
*/
export async function testAllSources(
videoTitle: string,
sources: VideoSource[],
currentSource?: string
): Promise<SourceSpeedResult[]> {
const testPromises = sources.map(source =>
Promise.race([
testSourceSpeed(videoTitle, source),
new Promise<SourceSpeedResult>((resolve) =>
setTimeout(
() =>
resolve({
source: source.id,
sourceName: source.name,
speed: Infinity,
available: false,
error: 'Timeout',
}),
SPEED_TEST_TIMEOUT
)
),
])
);
const results = await Promise.all(testPromises);
// Sort results: current source first, then by speed, errors last
return results.sort((a, b) => {
// Current source always first
if (currentSource) {
if (a.source === currentSource) return -1;
if (b.source === currentSource) return 1;
}
// Errors last
if (!a.available && b.available) return 1;
if (a.available && !b.available) return -1;
// Sort by speed
return a.speed - b.speed;
});
}
/**
* Get speed indicator
*/
export function getSpeedIndicator(
speed: number
): {
label: string;
color: string;
level: 'fast' | 'medium' | 'slow' | 'error';
} {
if (speed === Infinity) {
return {
label: 'Error',
color: 'red',
level: 'error',
};
}
if (speed < 1000) {
return {
label: 'Fast',
color: 'green',
level: 'fast',
};
}
if (speed < 2000) {
return {
label: 'Medium',
color: 'yellow',
level: 'medium',
};
}
return {
label: 'Slow',
color: 'red',
level: 'slow',
};
}
/**
* Format speed for display
*/
export function formatSpeed(speed: number): string {
if (speed === Infinity) {
return 'N/A';
}
if (speed < 1000) {
return `${speed}ms`;
}
return `${(speed / 1000).toFixed(2)}s`;
}
/**
* Find best source based on speed test results
*/
export function findBestSource(results: SourceSpeedResult[]): SourceSpeedResult | null {
const availableSources = results.filter(r => r.available);
if (availableSources.length === 0) {
return null;
}
// Return fastest available source
return availableSources.reduce((best, current) =>
current.speed < best.speed ? current : best
);
}
/**
* Get alternative sources
*/
export function getAlternativeSources(
results: SourceSpeedResult[],
currentSource: string
): SourceSpeedResult[] {
return results
.filter(r => r.source !== currentSource && r.available)
.sort((a, b) => a.speed - b.speed);
}
/**
* Check if source switch is recommended
*/
export function shouldSwitchSource(
currentResult: SourceSpeedResult,
bestResult: SourceSpeedResult
): boolean {
if (!currentResult.available) {
return true; // Current source not available
}
if (!bestResult.available) {
return false; // No better alternative
}
// Switch if best source is significantly faster (at least 50% faster)
const improvement = (currentResult.speed - bestResult.speed) / currentResult.speed;
return improvement > 0.5;
}
/**
* Build source switch URL
*/
export function buildSourceSwitchUrl(
currentUrl: string,
newSource: string,
videoDetail: VideoDetail,
episodeIndex: number = 0
): string {
const url = new URL(currentUrl, window.location.origin);
const searchParams = url.searchParams;
// Update source
searchParams.set('source', newSource);
searchParams.set('id', videoDetail.vod_id.toString());
// Keep same episode if available
if (videoDetail.episodes && videoDetail.episodes[episodeIndex]) {
searchParams.set('index', episodeIndex.toString());
searchParams.set('url', encodeURIComponent(videoDetail.episodes[episodeIndex].url));
}
return `${url.pathname}?${searchParams.toString()}`;
}
/**
* Cache speed test results
*/
const SPEED_TEST_CACHE_KEY = 'kvideo_speed_test_cache';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface SpeedTestCache {
[key: string]: {
results: SourceSpeedResult[];
timestamp: number;
};
}
export function getCachedSpeedTest(videoTitle: string): SourceSpeedResult[] | null {
if (typeof window === 'undefined') return null;
try {
const cache: SpeedTestCache = JSON.parse(
localStorage.getItem(SPEED_TEST_CACHE_KEY) || '{}'
);
const cached = cache[videoTitle];
if (!cached) return null;
// Check if cache is still valid
if (Date.now() - cached.timestamp > CACHE_DURATION) {
return null;
}
return cached.results;
} catch {
return null;
}
}
export function setCachedSpeedTest(
videoTitle: string,
results: SourceSpeedResult[]
): void {
if (typeof window === 'undefined') return;
try {
const cache: SpeedTestCache = JSON.parse(
localStorage.getItem(SPEED_TEST_CACHE_KEY) || '{}'
);
cache[videoTitle] = {
results,
timestamp: Date.now(),
};
// Keep only recent entries (max 10)
const entries = Object.entries(cache);
if (entries.length > 10) {
const sorted = entries.sort((a, b) => b[1].timestamp - a[1].timestamp);
const keep = Object.fromEntries(sorted.slice(0, 10));
localStorage.setItem(SPEED_TEST_CACHE_KEY, JSON.stringify(keep));
} else {
localStorage.setItem(SPEED_TEST_CACHE_KEY, JSON.stringify(cache));
}
} catch (error) {
console.error('Failed to cache speed test:', error);
}
}
-159
View File
@@ -1,159 +0,0 @@
/**
* 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 and contains video content
*/
async function checkUrlAccessibility(url: string): Promise<ValidationResult> {
const startTime = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT);
// Use GET with Range header to actually check video content
const response = await fetch(url, {
method: 'GET',
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': new URL(url).origin,
'Range': 'bytes=0-1024', // Only fetch first 1KB
},
});
clearTimeout(timeoutId);
// Check if response is successful and contains video content
const isSuccess = response.ok || response.status === 206;
const contentType = response.headers.get('content-type');
const isVideoContent = contentType && (
contentType.includes('video') ||
contentType.includes('mpegurl') ||
contentType.includes('m3u8') ||
contentType.includes('octet-stream')
);
return {
url,
isValid: isSuccess && !!isVideoContent,
responseTime: Date.now() - startTime,
error: !isSuccess ? `HTTP ${response.status}` : (!isVideoContent ? 'Not video content' : 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')),
}));
}