feat: Add fetchWithRetry utility, improve proxy M3U8 handling, and warn about HEVC codecs in HLS player.

This commit is contained in:
kuekhaoyang
2025-11-29 15:55:24 +08:00
parent bdae587bd5
commit f9cf4fdfb2
5 changed files with 266 additions and 76 deletions
+30 -53
View File
@@ -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',
},
});
}
+38 -4
View File
@@ -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 {
+81
View File
@@ -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;
}
+72 -5
View File
@@ -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
View File
@@ -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)}`;