export const OPTIONS = async () => { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Range', 'Access-Control-Max-Age': '86400', }, }); }; export const GET = async ({ request, url }) => { const targetUrl = url.searchParams.get('url'); if (!targetUrl) { return new Response(JSON.stringify({ error: 'Missing target URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } try { const headers = new Headers(); // Use the origin of the target URL as the referer to bypass simple checks const targetOrigin = new URL(targetUrl).origin; headers.set('Referer', targetOrigin + '/'); headers.set('Origin', targetOrigin); headers.set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); // Forward Range header for smooth scrubbing/seeking (HTTP 206 Partial Content support) const rangeHeader = request.headers.get('Range') || request.headers.get('range'); if (rangeHeader) { headers.set('Range', rangeHeader); } const response = await fetch(targetUrl, { headers }); if (!response.ok) { // If it's an image and it's not found, return a transparent 1x1 pixel // This prevents the player from retrying and avoids broken image icons in the UI const isImage = targetUrl.match(/\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i) || targetUrl.toLowerCase().includes('preview.jpg') || targetUrl.toLowerCase().includes('thumb.jpg'); if (isImage && response.status === 404) { // 1x1 transparent GIF const transparentPixel = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); return new Response(transparentPixel, { status: 200, headers: { 'Content-Type': 'image/gif', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=86400' } }); } console.warn(`[Proxy] Failed to fetch ${targetUrl}: ${response.status} ${response.statusText}`); return new Response(null, { status: response.status }); } const contentType = response.headers.get('Content-Type') || ''; // If it's a playlist (m3u8), we might need to rewrite relative paths to absolute ones // so that the player doesn't try to load them relative to the proxy URL. if (contentType.includes('mpegurl') || contentType.includes('x-mpegURL') || targetUrl.endsWith('.m3u8')) { let text = await response.text(); const urlObj = new URL(targetUrl); const baseUrl = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1); // Rewrite relative paths to absolute URLs pointing back to the proxy // This ensures that all segments also go through the proxy to bypass CORS const proxiedText = text.split('\n').map(line => { const trimmed = line.trim(); if (!trimmed) return line; // If it is a tag/comment line if (trimmed.startsWith('#')) { // Check for URI attribute in EXT tags (like EXT-X-KEY, EXT-X-MAP, EXT-X-MEDIA) if (trimmed.includes('URI=')) { return trimmed.replace(/URI=(["'])([^"'\r\n]+)\1/g, (match, quote, p1) => { let absolutePath = p1; if (!p1.startsWith('http')) { absolutePath = p1.startsWith('/') ? urlObj.origin + p1 : baseUrl + p1; } return `URI=${quote}/api/proxy-media?url=${encodeURIComponent(absolutePath)}${quote}`; }); } return line; } // If it is a segment URL (non-tag line) // If it's already an absolute URL, wrap it in proxy if (trimmed.startsWith('http')) { return `/api/proxy-media?url=${encodeURIComponent(trimmed)}`; } // If it's a relative path, make it absolute and wrap it const absolutePath = trimmed.startsWith('/') ? urlObj.origin + trimmed : baseUrl + trimmed; return `/api/proxy-media?url=${encodeURIComponent(absolutePath)}`; }).join('\n'); return new Response(proxiedText, { status: 200, headers: { 'Content-Type': 'application/x-mpegURL', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' } }); } // For other media (images, segments), stream the response directly to the browser // This achieves zero-latency start and supports seeking (206 Partial Content) const status = response.status; const responseHeaders = new Headers({ 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600' }); // Copy Range-related headers from destination response if they exist const contentRange = response.headers.get('Content-Range') || response.headers.get('content-range'); if (contentRange) { responseHeaders.set('Content-Range', contentRange); } const contentLength = response.headers.get('Content-Length') || response.headers.get('content-length'); if (contentLength) { responseHeaders.set('Content-Length', contentLength); } const acceptRanges = response.headers.get('Accept-Ranges') || response.headers.get('accept-ranges'); if (acceptRanges) { responseHeaders.set('Accept-Ranges', acceptRanges); } return new Response(response.body, { status: status, headers: responseHeaders }); } catch (error) { console.error(`[Proxy] Error:`, error); return new Response(null, { status: 500 }); } };