From f9cf4fdfb2bfa92a69b1247b78959b7efa424290 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sat, 29 Nov 2025 15:55:24 +0800 Subject: [PATCH] feat: Add `fetchWithRetry` utility, improve proxy M3U8 handling, and warn about HEVC codecs in HLS player. --- app/api/proxy/route.ts | 83 +++++++++---------------- components/player/hooks/useHlsPlayer.ts | 42 +++++++++++-- lib/utils/fetch-with-retry.ts | 81 ++++++++++++++++++++++++ lib/utils/hlsManifestParser.ts | 77 +++++++++++++++++++++-- lib/utils/proxy-utils.ts | 59 +++++++++++++----- 5 files changed, 266 insertions(+), 76 deletions(-) create mode 100644 lib/utils/fetch-with-retry.ts diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts index a4e5f61..77582ea 100644 --- a/app/api/proxy/route.ts +++ b/app/api/proxy/route.ts @@ -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', }, }); } diff --git a/components/player/hooks/useHlsPlayer.ts b/components/player/hooks/useHlsPlayer.ts index 98001df..d13c46d 100644 --- a/components/player/hooks/useHlsPlayer.ts +++ b/components/player/hooks/useHlsPlayer.ts @@ -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 { diff --git a/lib/utils/fetch-with-retry.ts b/lib/utils/fetch-with-retry.ts new file mode 100644 index 0000000..4ccc724 --- /dev/null +++ b/lib/utils/fetch-with-retry.ts @@ -0,0 +1,81 @@ +import { NextRequest } from 'next/server'; + +interface FetchWithRetryOptions { + url: string; + request: NextRequest; +} + +export async function fetchWithRetry({ url, request }: FetchWithRetryOptions): Promise { + // 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; +} diff --git a/lib/utils/hlsManifestParser.ts b/lib/utils/hlsManifestParser.ts index 490f947..a239b2e 100644 --- a/lib/utils/hlsManifestParser.ts +++ b/lib/utils/hlsManifestParser.ts @@ -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 { - 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 { } 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 { + 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 { } } + if (isEncrypted) { + console.log(`[HLS Parser] Parsed ${segments.length} encrypted segments`); + } + return segments; } diff --git a/lib/utils/proxy-utils.ts b/lib/utils/proxy-utils.ts index afc1afc..8a275b4 100644 --- a/lib/utils/proxy-utils.ts +++ b/lib/utils/proxy-utils.ts @@ -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)}`;