mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
feat: Add fetchWithRetry utility, improve proxy M3U8 handling, and warn about HEVC codecs in HLS player.
This commit is contained in:
+30
-53
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processM3u8Content } from '@/lib/utils/proxy-utils';
|
||||
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -14,67 +15,43 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Beijing IP address to simulate request from China
|
||||
const chinaIP = '202.108.22.5';
|
||||
const MAX_RETRIES = 5;
|
||||
let lastError = null;
|
||||
let response = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'X-Forwarded-For': chinaIP,
|
||||
'Client-IP': chinaIP,
|
||||
'Referer': new URL(url).origin,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`✓ Proxy success on attempt ${attempt}: ${url}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.status === 503 && attempt < MAX_RETRIES) {
|
||||
console.warn(`⚠ Got 503 on attempt ${attempt}, retrying... (${url})`);
|
||||
lastError = `503 on attempt ${attempt}`;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.warn(`✗ Got ${response.status} on attempt ${attempt}: ${url}`);
|
||||
break;
|
||||
} catch (fetchError) {
|
||||
lastError = fetchError;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.warn(`⚠ Fetch error on attempt ${attempt}, retrying...`, fetchError);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(`Failed after ${MAX_RETRIES} attempts: ${response?.status || lastError}`);
|
||||
}
|
||||
const response = await fetchWithRetry({ url, request });
|
||||
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
|
||||
// Handle m3u8 playlists
|
||||
if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || url.endsWith('.m3u8'))) {
|
||||
const text = await response.text();
|
||||
const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
|
||||
// Better M3U8 detection: check both content-type and actual content
|
||||
const isM3u8ByHeader = contentType &&
|
||||
(contentType.includes('application/vnd.apple.mpegurl') ||
|
||||
contentType.includes('application/x-mpegurl')) ||
|
||||
url.endsWith('.m3u8');
|
||||
|
||||
return new NextResponse(modifiedText, {
|
||||
// For potential M3U8 files, check content
|
||||
if (isM3u8ByHeader || url.includes('.m3u8')) {
|
||||
const text = await response.text();
|
||||
|
||||
// Verify it's actually M3U8 content (starts with #EXTM3U or #EXT-X-)
|
||||
if (text.trim().startsWith('#EXTM3U') || text.trim().startsWith('#EXT-X-')) {
|
||||
const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
|
||||
|
||||
return new NextResponse(modifiedText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Not M3U8 content, return as-is
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Type': contentType || 'text/plain',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ export function useHlsPlayer({
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest parsed');
|
||||
|
||||
// Check for HEVC/H.265 codec (limited browser support)
|
||||
if (hls) {
|
||||
const levels = hls.levels;
|
||||
if (levels && levels.length > 0) {
|
||||
const hasHEVC = levels.some(level =>
|
||||
level.videoCodec?.toLowerCase().includes('hev') ||
|
||||
level.videoCodec?.toLowerCase().includes('h265')
|
||||
);
|
||||
if (hasHEVC) {
|
||||
console.warn('[HLS] ⚠️ HEVC/H.265 codec detected - may not play in all browsers');
|
||||
console.warn('[HLS] Supported: Safari with hardware acceleration, some Edge versions');
|
||||
console.warn('[HLS] Not supported: Most Chrome/Firefox versions');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
video.play().catch((err) => {
|
||||
console.warn('[HLS] Autoplay prevented:', err);
|
||||
@@ -62,22 +79,39 @@ export function useHlsPlayer({
|
||||
}
|
||||
});
|
||||
|
||||
let networkErrorRetries = 0;
|
||||
let mediaErrorRetries = 0;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.error('[HLS] Network error, trying to recover...');
|
||||
hls?.startLoad();
|
||||
networkErrorRetries++;
|
||||
console.error(`[HLS] Network error (${networkErrorRetries}/${MAX_RETRIES}), trying to recover...`, data);
|
||||
if (networkErrorRetries <= MAX_RETRIES) {
|
||||
hls?.startLoad();
|
||||
} else {
|
||||
console.error('[HLS] Too many network errors, giving up');
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('[HLS] Media error, trying to recover...');
|
||||
hls?.recoverMediaError();
|
||||
mediaErrorRetries++;
|
||||
console.error(`[HLS] Media error (${mediaErrorRetries}/${MAX_RETRIES}), trying to recover...`, data);
|
||||
if (mediaErrorRetries <= MAX_RETRIES) {
|
||||
hls?.recoverMediaError();
|
||||
} else {
|
||||
console.error('[HLS] Too many media errors, giving up');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.error('[HLS] Fatal error, cannot recover:', data);
|
||||
hls?.destroy();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Non-fatal errors
|
||||
console.warn('[HLS] Non-fatal error:', data.type, data.details);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
interface FetchWithRetryOptions {
|
||||
url: string;
|
||||
request: NextRequest;
|
||||
}
|
||||
|
||||
export async function fetchWithRetry({ url, request }: FetchWithRetryOptions): Promise<Response> {
|
||||
// User-Agent rotation for better compatibility
|
||||
const userAgents = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0'
|
||||
];
|
||||
const randomUA = userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||
|
||||
// Smart Referer: use provided or default to origin
|
||||
const referer = request.nextUrl.searchParams.get('referer') || new URL(url).origin;
|
||||
|
||||
// Optional IP forwarding (default: Beijing IP)
|
||||
const forwardedIP = request.nextUrl.searchParams.get('ip') || '202.108.22.5';
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const TIMEOUT_MS = 30000; // 30 seconds
|
||||
let lastError: unknown = null;
|
||||
let response: Response | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms
|
||||
const backoffDelay = attempt > 1 ? Math.pow(2, attempt - 2) * 100 : 0;
|
||||
if (backoffDelay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': randomUA,
|
||||
'X-Forwarded-For': forwardedIP,
|
||||
'Client-IP': forwardedIP,
|
||||
'Referer': referer,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`✓ Proxy success on attempt ${attempt}: ${url.substring(0, 100)}...`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.status === 503 && attempt < MAX_RETRIES) {
|
||||
console.warn(`⚠ Got 503 on attempt ${attempt}, retrying with backoff ${backoffDelay}ms...`);
|
||||
lastError = `503 on attempt ${attempt}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.warn(`✗ Got ${response.status} on attempt ${attempt}`);
|
||||
break;
|
||||
} catch (fetchError) {
|
||||
lastError = fetchError;
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
console.warn(`⚠ Timeout on attempt ${attempt}, retrying...`);
|
||||
} else if (attempt < MAX_RETRIES) {
|
||||
console.warn(`⚠ Fetch error on attempt ${attempt}, retrying...`, fetchError);
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(`Failed after ${MAX_RETRIES} attempts: ${response?.status || lastError}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -9,8 +9,22 @@ export interface Segment {
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export interface ManifestInfo {
|
||||
segments: Segment[];
|
||||
isEncrypted: boolean;
|
||||
keyUri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HLS manifest - routes through proxy to avoid CORS
|
||||
*/
|
||||
export async function parseHLSManifest(src: string): Promise<Segment[]> {
|
||||
const response = await fetch(src);
|
||||
// Route through proxy to ensure consistency and avoid CORS
|
||||
const proxyUrl = src.includes('/api/proxy')
|
||||
? src
|
||||
: `${getOrigin()}/api/proxy?url=${encodeURIComponent(src)}`;
|
||||
|
||||
const response = await fetch(proxyUrl);
|
||||
if (!response.ok) {
|
||||
const errorMsg = response.status === 503
|
||||
? `Network unavailable (Service Worker offline): ${src}`
|
||||
@@ -19,20 +33,69 @@ export async function parseHLSManifest(src: string): Promise<Segment[]> {
|
||||
}
|
||||
const manifestText = await response.text();
|
||||
|
||||
const lines = manifestText.split('\n');
|
||||
// Check if this is a master playlist
|
||||
if (manifestText.includes('#EXT-X-STREAM-INF')) {
|
||||
console.log('[HLS Parser] Master playlist detected, selecting variant...');
|
||||
return parseMasterPlaylist(manifestText, src);
|
||||
}
|
||||
|
||||
// Parse as media playlist
|
||||
return parseMediaPlaylist(manifestText, src);
|
||||
}
|
||||
|
||||
function getOrigin(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function parseMasterPlaylist(content: string, baseUrl: string): Promise<Segment[]> {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find first variant playlist URL
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('#EXT-X-STREAM-INF')) {
|
||||
// Next non-comment line is the variant URL
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const line = lines[j].trim();
|
||||
if (line && !line.startsWith('#')) {
|
||||
const variantUrl = new URL(line, baseUrl).toString();
|
||||
console.log(`[HLS Parser] Selected variant: ${variantUrl.substring(0, 100)}...`);
|
||||
// Recursively parse the variant playlist
|
||||
return parseHLSManifest(variantUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[HLS Parser] No valid variant found in master playlist');
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseMediaPlaylist(content: string, baseUrl: string): Segment[] {
|
||||
const lines = content.split('\n');
|
||||
const segments: Segment[] = [];
|
||||
let currentSegmentDuration = 0;
|
||||
let currentStartTime = 0;
|
||||
let isEncrypted = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for encryption
|
||||
if (trimmed.startsWith('#EXT-X-KEY:')) {
|
||||
isEncrypted = true;
|
||||
console.log('[HLS Parser] Encrypted stream detected');
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#EXTINF:')) {
|
||||
const durationStr = trimmed.substring(8).split(',')[0];
|
||||
currentSegmentDuration = parseFloat(durationStr);
|
||||
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||
// Use URL API to resolve relative paths correctly against the manifest URL
|
||||
// This handles cases where baseUrl might end with / and segment starts with /
|
||||
const segmentUrl = new URL(trimmed, src).toString();
|
||||
// Segment URLs are already proxied by the backend proxy
|
||||
// Just use them as-is
|
||||
const segmentUrl = trimmed;
|
||||
segments.push({
|
||||
url: segmentUrl,
|
||||
duration: currentSegmentDuration,
|
||||
@@ -42,5 +105,9 @@ export async function parseHLSManifest(src: string): Promise<Segment[]> {
|
||||
}
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
console.log(`[HLS Parser] Parsed ${segments.length} encrypted segments`);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
+45
-14
@@ -1,3 +1,24 @@
|
||||
/**
|
||||
* Extract and proxy URI from HLS tags like EXT-X-KEY, EXT-X-MAP, EXT-X-MEDIA
|
||||
*/
|
||||
function proxyUriInTag(line: string, base: URL, origin: string): string {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch && uriMatch[1]) {
|
||||
const uri = uriMatch[1];
|
||||
// Skip if already proxied
|
||||
if (uri.includes('/api/proxy')) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const absoluteUrl = new URL(uri, base).toString();
|
||||
const proxiedUrl = `${origin}/api/proxy?url=${encodeURIComponent(absoluteUrl)}`;
|
||||
return line.replace(/URI="[^"]+"/, `URI="${proxiedUrl}"`);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export async function processM3u8Content(
|
||||
content: string,
|
||||
@@ -10,20 +31,25 @@ export async function processM3u8Content(
|
||||
const processedLines = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Handle EXT-X-KEY encryption keys
|
||||
// Handle EXT-X-KEY (encryption keys)
|
||||
if (trimmed.startsWith('#EXT-X-KEY:')) {
|
||||
// Extract URI from the key tag
|
||||
const uriMatch = trimmed.match(/URI="([^"]+)"/);
|
||||
if (uriMatch && uriMatch[1]) {
|
||||
const keyUri = uriMatch[1];
|
||||
try {
|
||||
const absoluteUrl = new URL(keyUri, base).toString();
|
||||
const proxiedUrl = `${origin}/api/proxy?url=${encodeURIComponent(absoluteUrl)}`;
|
||||
return trimmed.replace(/URI="[^"]+"/, `URI="${proxiedUrl}"`);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return proxyUriInTag(trimmed, base, origin);
|
||||
}
|
||||
|
||||
// Handle EXT-X-MAP (fMP4 initialization segments)
|
||||
if (trimmed.startsWith('#EXT-X-MAP:')) {
|
||||
return proxyUriInTag(trimmed, base, origin);
|
||||
}
|
||||
|
||||
// Handle EXT-X-MEDIA (alternative audio/subtitle tracks)
|
||||
if (trimmed.startsWith('#EXT-X-MEDIA:')) {
|
||||
return proxyUriInTag(trimmed, base, origin);
|
||||
}
|
||||
|
||||
// Handle EXT-X-STREAM-INF (master playlist variants)
|
||||
// The URL is on the NEXT line after this tag
|
||||
if (trimmed.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// Skip other comments and empty lines
|
||||
@@ -31,7 +57,12 @@ export async function processM3u8Content(
|
||||
return line;
|
||||
}
|
||||
|
||||
// Resolve relative URLs for segments
|
||||
// Resolve relative URLs for segments and variant playlists
|
||||
// Skip if already proxied
|
||||
if (trimmed.includes('/api/proxy')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(trimmed, base).toString();
|
||||
return `${origin}/api/proxy?url=${encodeURIComponent(absoluteUrl)}`;
|
||||
|
||||
Reference in New Issue
Block a user