mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 04:03:42 +08:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user