/** * TPhimX - Cloudflare Worker HLS Media Proxy & R2 Auto-Cache CDN * * Features: * 1. CORS Bypass: Attaches Access-Control-Allow-Origin: * to all requests. * 2. Range Requests: Supports seeking/scrubbing in HLS players (206 Partial Content). * 3. Dynamic R2 Caching: Checks R2 first. On cache miss, fetches original video segment, * caches it into R2, and pipes it back to the client. * 4. Playlist Rewriter: Rewrites .m3u8 relative and absolute URLs to route all subsequent * .ts media segment requests through this Worker and cache them in R2 automatically. */ export default { async fetch(request, env, ctx) { const urlObj = new URL(request.url); const targetUrlStr = urlObj.searchParams.get('url'); const hasR2 = env.tphimx_movie !== undefined; if (!targetUrlStr) { // Direct R2 Path-based Request: e.g. /fbi-part-one/360p/index.m3u8 const pathname = urlObj.pathname; if (pathname && pathname !== '/') { if (!hasR2) { return new Response('R2 Cache storage is not enabled on this Worker account yet.', { status: 400, headers: { 'Access-Control-Allow-Origin': '*' } }); } const r2Key = pathname.replace(/^\/+/, ''); // Convert leading slash to relative key try { const object = await env.tphimx_movie.get(r2Key); if (object !== null) { // Cache HIT - Serve directly from R2 const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('Access-Control-Allow-Origin', '*'); headers.set('X-TPhimX-Cache', 'HIT (Direct R2 Path)'); // Handle Range Requests for seekability const rangeHeader = request.headers.get('Range'); if (rangeHeader && object.size > 0) { const parts = rangeHeader.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : object.size - 1; if (start >= 0 && end < object.size && start <= end) { const slicedObject = await env.tphimx_movie.get(r2Key, { range: { offset: start, length: end - start + 1 } }); headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`); headers.set('Content-Length', String(end - start + 1)); return new Response(slicedObject.body, { status: 206, headers }); } } return new Response(object.body, { status: 200, headers }); } else { return new Response('File not found in R2 bucket', { status: 404, headers: { 'Access-Control-Allow-Origin': '*' } }); } } catch (err) { return new Response(`R2 Path Error: ${err.message}`, { status: 500, headers: { 'Access-Control-Allow-Origin': '*' } }); } } return new Response(JSON.stringify({ error: 'Missing "url" search parameter.', usage: `${urlObj.origin}/?url=https://example.com/stream.m3u8` }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); } // Handle OPTIONS Preflight request if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': '*', 'Access-Control-Max-Age': '86400' } }); } let targetUrl; try { targetUrl = new URL(targetUrlStr); } catch (e) { return new Response('Invalid target URL format', { status: 400, headers: { 'Access-Control-Allow-Origin': '*' } }); } // Generate unique storage key for Cloudflare R2 // Scheme: cache// const cleanPath = targetUrl.pathname.replace(/^\/+/, ''); const r2Key = `cache/${targetUrl.hostname}/${cleanPath}`; // 1. Check if file already exists in Cloudflare R2 bucket (Only check if R2 is enabled) if (hasR2) { try { const object = await env.tphimx_movie.get(r2Key); if (object !== null) { // Cache HIT - Serve directly from R2 const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('Access-Control-Allow-Origin', '*'); headers.set('X-TPhimX-Cache', 'HIT (Cloudflare R2)'); // Handle Range Requests for seekability const rangeHeader = request.headers.get('Range'); if (rangeHeader && object.size > 0) { const parts = rangeHeader.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : object.size - 1; if (start >= 0 && end < object.size && start <= end) { // Slice the R2 object const slicedObject = await env.tphimx_movie.get(r2Key, { range: { offset: start, length: end - start + 1 } }); headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`); headers.set('Content-Length', String(end - start + 1)); return new Response(slicedObject.body, { status: 206, headers }); } } return new Response(object.body, { status: 200, headers }); } } catch (err) { // Log error but fallback to fetching from source to prevent playback interruption console.error('R2 read error:', err.message); } } // Cache MISS - Fetch from original media source server try { // Forward request headers like Range const fetchHeaders = new Headers(); // Bypass anti-hotlinking/Referer checks of media providers (e.g. s2.phim1280.tv) const targetOrigin = targetUrl.origin; fetchHeaders.set('Referer', targetOrigin + '/'); fetchHeaders.set('Origin', targetOrigin); fetchHeaders.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'); const range = request.headers.get('Range'); if (range) fetchHeaders.set('Range', range); const response = await fetch(targetUrlStr, { headers: fetchHeaders }); if (!response.ok && response.status !== 206) { const responseText = await response.text().catch(() => ''); return new Response(JSON.stringify({ error: `Failed to fetch original media source (HTTP ${response.status})`, targetUrl: targetUrlStr, sentHeaders: Object.fromEntries(fetchHeaders.entries()), responseHeaders: Object.fromEntries(response.headers.entries()), responseBody: responseText.substring(0, 500) }), { status: response.status, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); } const contentType = response.headers.get('Content-Type') || ''; const isM3U8 = targetUrl.pathname.endsWith('.m3u8') || contentType.includes('mpegurl') || contentType.includes('application/x-mpegURL'); if (isM3U8) { // Rewrite HLS playlist URLs to route through this Worker Proxy let playlistText = await response.text(); const baseTargetUrl = targetUrl.origin + targetUrl.pathname.substring(0, targetUrl.pathname.lastIndexOf('/') + 1); const lines = playlistText.split('\n'); const rewrittenLines = lines.map(line => { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith('#')) { return line; } // Resolve absolute URL let absoluteUrl = trimmed; if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { if (trimmed.startsWith('/')) { absoluteUrl = targetUrl.origin + trimmed; } else { absoluteUrl = baseTargetUrl + trimmed; } } // Rewritten relative segment URL to pipe through the worker return `${urlObj.origin}/?url=${encodeURIComponent(absoluteUrl)}`; }); const rewrittenPlaylist = rewrittenLines.join('\n'); const playlistBuffer = new TextEncoder().encode(rewrittenPlaylist); // Save the rewritten playlist into Cloudflare R2 bucket (only if R2 is enabled) if (hasR2) { ctx.waitUntil( env.tphimx_movie.put(r2Key, playlistBuffer, { httpMetadata: { contentType: 'application/x-mpegURL' } }).catch(e => console.error('R2 Put Error:', e.message)) ); } return new Response(rewrittenPlaylist, { status: 200, headers: { 'Content-Type': 'application/x-mpegURL', 'Access-Control-Allow-Origin': '*', 'X-TPhimX-Cache': hasR2 ? 'MISS (Rewritten & Cached)' : 'MISS (Rewritten)' } }); } else { // Fetch raw binary segment file (.ts) const segmentData = await response.arrayBuffer(); // Save the segment file into Cloudflare R2 bucket asynchronously (only if R2 is enabled) if (hasR2) { ctx.waitUntil( env.tphimx_movie.put(r2Key, segmentData, { httpMetadata: { contentType: contentType } }).catch(e => console.error('R2 Put Error:', e.message)) ); } // Build Response const headers = new Headers(); headers.set('Content-Type', contentType); headers.set('Access-Control-Allow-Origin', '*'); headers.set('X-TPhimX-Cache', 'MISS (Cached Segment)'); if (response.status === 206) { const contentRange = response.headers.get('Content-Range'); if (contentRange) headers.set('Content-Range', contentRange); return new Response(segmentData, { status: 206, headers }); } return new Response(segmentData, { status: 200, headers }); } } catch (fetchErr) { return new Response(`Proxy error: ${fetchErr.message}`, { status: 502, headers: { 'Access-Control-Allow-Origin': '*' } }); } } };