From 56e70eb1089cea7224047a88a8b046d60f9ae706 Mon Sep 17 00:00:00 2001 From: NezzDevs Date: Wed, 21 May 2025 16:48:54 -0400 Subject: [PATCH 1/4] Update m3u8-proxy.ts --- src/routes/m3u8-proxy.ts | 81 +++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/src/routes/m3u8-proxy.ts b/src/routes/m3u8-proxy.ts index 2d5bc5e..a49291e 100644 --- a/src/routes/m3u8-proxy.ts +++ b/src/routes/m3u8-proxy.ts @@ -16,9 +16,7 @@ function parseURL(req_url: string, baseUrl?: string) { return null; } - // Scheme is omitted if (req_url.lastIndexOf("//", 0) === -1) { - // "//" is omitted req_url = "//" + req_url; } req_url = (match[4] === "443" ? "https:" : "http:") + req_url; @@ -27,7 +25,6 @@ function parseURL(req_url: string, baseUrl?: string) { try { const parsed = new URL(req_url); if (!parsed.hostname) { - // "http://:1/" and "http:/notenoughslashes" could end up here return null; } return parsed.href; @@ -36,9 +33,49 @@ function parseURL(req_url: string, baseUrl?: string) { } } -/** - * Proxies m3u8 files and replaces the content to point to the proxy - */ +const segmentCache: Map }> = new Map(); + +async function prefetchSegment(url: string, headers: HeadersInit) { + if (segmentCache.has(url)) { + return; + } + + try { + const response = await globalThis.fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0', + ...(headers as HeadersInit), + } + }); + + if (!response.ok) { + console.error(`Failed to prefetch TS segment: ${response.status} ${response.statusText}`); + return; + } + + const data = new Uint8Array(await response.arrayBuffer()); + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + segmentCache.set(url, { + data, + headers: responseHeaders + }); + + console.log(`Prefetched and cached segment: ${url}`); + } catch (error) { + console.error(`Error prefetching segment ${url}:`, error); + } +} + +export function getCachedSegment(url: string) { + return segmentCache.get(url); +} + async function proxyM3U8(event: any) { const url = getQuery(event).url as string; const headersParam = getQuery(event).headers as string; @@ -63,7 +100,6 @@ async function proxyM3U8(event: any) { try { const response = await globalThis.fetch(url, { headers: { - // Default User-Agent (from src/utils/headers.ts) 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0', ...(headers as HeadersInit), } @@ -75,20 +111,17 @@ async function proxyM3U8(event: any) { const m3u8Content = await response.text(); - // Get the base URL for the host const host = getRequestHost(event); const proto = getRequestProtocol(event); const baseProxyUrl = `${proto}://${host}`; if (m3u8Content.includes("RESOLUTION=")) { - // This is a master playlist with multiple quality variants const lines = m3u8Content.split("\n"); const newLines: string[] = []; for (const line of lines) { if (line.startsWith("#")) { if (line.startsWith("#EXT-X-KEY:")) { - // Proxy the key URL const regex = /https?:\/\/[^\""\s]+/g; const keyUrl = regex.exec(line)?.[0]; if (keyUrl) { @@ -98,7 +131,6 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else if (line.startsWith("#EXT-X-MEDIA:")) { - // Proxy alternative media URLs (like audio streams) const regex = /https?:\/\/[^\""\s]+/g; const mediaUrl = regex.exec(line)?.[0]; if (mediaUrl) { @@ -111,7 +143,6 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else if (line.trim()) { - // This is a quality variant URL const variantUrl = parseURL(line, url); if (variantUrl) { newLines.push(`${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(variantUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`); @@ -119,12 +150,10 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else { - // Empty line, preserve it newLines.push(line); } } - // Set appropriate headers setResponseHeaders(event, { 'Content-Type': 'application/vnd.apple.mpegurl', 'Access-Control-Allow-Origin': '*', @@ -135,19 +164,21 @@ async function proxyM3U8(event: any) { return newLines.join("\n"); } else { - // This is a media playlist with segments const lines = m3u8Content.split("\n"); const newLines: string[] = []; + const segmentUrls: string[] = []; + for (const line of lines) { if (line.startsWith("#")) { if (line.startsWith("#EXT-X-KEY:")) { - // Proxy the key URL const regex = /https?:\/\/[^\""\s]+/g; const keyUrl = regex.exec(line)?.[0]; if (keyUrl) { const proxyKeyUrl = `${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(keyUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`; newLines.push(line.replace(keyUrl, proxyKeyUrl)); + + prefetchSegment(keyUrl, headers as HeadersInit); } else { newLines.push(line); } @@ -155,20 +186,29 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else if (line.trim() && !line.startsWith("#")) { - // This is a segment URL (.ts file) const segmentUrl = parseURL(line, url); if (segmentUrl) { + segmentUrls.push(segmentUrl); + newLines.push(`${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(segmentUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`); } else { newLines.push(line); } } else { - // Comment or empty line, preserve it newLines.push(line); } } - // Set appropriate headers + if (segmentUrls.length > 0) { + console.log(`Starting to prefetch ${segmentUrls.length} segments for ${url}`); + + Promise.all(segmentUrls.map(segmentUrl => + prefetchSegment(segmentUrl, headers as HeadersInit) + )).catch(error => { + console.error('Error prefetching segments:', error); + }); + } + setResponseHeaders(event, { 'Content-Type': 'application/vnd.apple.mpegurl', 'Access-Control-Allow-Origin': '*', @@ -189,8 +229,7 @@ async function proxyM3U8(event: any) { } export default defineEventHandler(async (event) => { - // Handle CORS preflight requests if (isPreflightRequest(event)) return handleCors(event, {}); return await proxyM3U8(event); -}); \ No newline at end of file +}); From 8387cea2cf4fee4091062a098fdc7963722b7d79 Mon Sep 17 00:00:00 2001 From: NezzDevs Date: Wed, 21 May 2025 16:49:37 -0400 Subject: [PATCH 2/4] Update ts-proxy.ts --- src/routes/ts-proxy.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/routes/ts-proxy.ts b/src/routes/ts-proxy.ts index 1715628..07f2bab 100644 --- a/src/routes/ts-proxy.ts +++ b/src/routes/ts-proxy.ts @@ -1,7 +1,7 @@ import { setResponseHeaders } from 'h3'; +import { getCachedSegment } from './m3u8-proxy'; export default defineEventHandler(async (event) => { - // Handle CORS preflight requests if (isPreflightRequest(event)) return handleCors(event, {}); const url = getQuery(event).url as string; @@ -25,10 +25,23 @@ export default defineEventHandler(async (event) => { } try { + const cachedSegment = getCachedSegment(url); + + if (cachedSegment) { + setResponseHeaders(event, { + 'Content-Type': cachedSegment.headers['content-type'] || 'video/mp2t', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Methods': '*', + 'Cache-Control': 'public, max-age=3600' + }); + + return cachedSegment.data; + } + const response = await globalThis.fetch(url, { method: 'GET', headers: { - // Default User-Agent (from src/utils/headers.ts) 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0', ...(headers as HeadersInit), } @@ -38,16 +51,14 @@ export default defineEventHandler(async (event) => { throw new Error(`Failed to fetch TS file: ${response.status} ${response.statusText}`); } - // Set appropriate headers for each video segment setResponseHeaders(event, { 'Content-Type': 'video/mp2t', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*', - 'Cache-Control': 'public, max-age=3600' // Allow caching of TS segments + 'Cache-Control': 'public, max-age=3600' }); - // Return the binary data directly return new Uint8Array(await response.arrayBuffer()); } catch (error: any) { console.error('Error proxying TS file:', error); @@ -56,4 +67,4 @@ export default defineEventHandler(async (event) => { statusMessage: error.message || 'Error proxying TS file' })); } -}); \ No newline at end of file +}); From 262bb6cb127c108f60eea6c579d44a89f1e96998 Mon Sep 17 00:00:00 2001 From: NezzDevs Date: Wed, 21 May 2025 17:26:36 -0400 Subject: [PATCH 3/4] Update m3u8-proxy.ts --- src/routes/m3u8-proxy.ts | 102 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/src/routes/m3u8-proxy.ts b/src/routes/m3u8-proxy.ts index a49291e..80f85ac 100644 --- a/src/routes/m3u8-proxy.ts +++ b/src/routes/m3u8-proxy.ts @@ -33,10 +33,64 @@ function parseURL(req_url: string, baseUrl?: string) { } } -const segmentCache: Map }> = new Map(); +interface CacheEntry { + data: Uint8Array; + headers: Record; + timestamp: number; +} + +const CACHE_MAX_SIZE = 2000; +const CACHE_EXPIRY_MS = 2 * 60 * 60 * 1000; +const segmentCache: Map = new Map(); + +function cleanupCache() { + const now = Date.now(); + let expiredCount = 0; + + for (const [url, entry] of segmentCache.entries()) { + if (now - entry.timestamp > CACHE_EXPIRY_MS) { + segmentCache.delete(url); + expiredCount++; + } + } + + if (segmentCache.size > CACHE_MAX_SIZE) { + const entries = Array.from(segmentCache.entries()) + .sort((a, b) => a[1].timestamp - b[1].timestamp); + + const toRemove = entries.slice(0, segmentCache.size - CACHE_MAX_SIZE); + for (const [url] of toRemove) { + segmentCache.delete(url); + } + + console.log(`Cache size limit reached. Removed ${toRemove.length} oldest entries. Current size: ${segmentCache.size}`); + } + + if (expiredCount > 0) { + console.log(`Cleaned up ${expiredCount} expired cache entries. Current size: ${segmentCache.size}`); + } + + return segmentCache.size; +} + +let cleanupInterval: any = null; +function startCacheCleanupInterval() { + if (!cleanupInterval) { + cleanupInterval = setInterval(cleanupCache, 30 * 60 * 1000); + console.log('Started periodic cache cleanup interval'); + } +} + +startCacheCleanupInterval(); async function prefetchSegment(url: string, headers: HeadersInit) { - if (segmentCache.has(url)) { + if (segmentCache.size >= CACHE_MAX_SIZE) { + cleanupCache(); + } + + const existing = segmentCache.get(url); + const now = Date.now(); + if (existing && (now - existing.timestamp <= CACHE_EXPIRY_MS)) { return; } @@ -63,7 +117,8 @@ async function prefetchSegment(url: string, headers: HeadersInit) { segmentCache.set(url, { data, - headers: responseHeaders + headers: responseHeaders, + timestamp: Date.now() }); console.log(`Prefetched and cached segment: ${url}`); @@ -73,7 +128,31 @@ async function prefetchSegment(url: string, headers: HeadersInit) { } export function getCachedSegment(url: string) { - return segmentCache.get(url); + const entry = segmentCache.get(url); + if (entry) { + if (Date.now() - entry.timestamp > CACHE_EXPIRY_MS) { + segmentCache.delete(url); + return undefined; + } + return entry; + } + return undefined; +} + +export function getCacheStats() { + const sizes = Array.from(segmentCache.values()) + .map(entry => entry.data.byteLength); + + const totalBytes = sizes.reduce((sum, size) => sum + size, 0); + const avgBytes = sizes.length > 0 ? totalBytes / sizes.length : 0; + + return { + entries: segmentCache.size, + totalSizeMB: (totalBytes / (1024 * 1024)).toFixed(2), + avgEntrySizeKB: (avgBytes / 1024).toFixed(2), + maxSize: CACHE_MAX_SIZE, + expiryHours: CACHE_EXPIRY_MS / (60 * 60 * 1000) + }; } async function proxyM3U8(event: any) { @@ -202,6 +281,8 @@ async function proxyM3U8(event: any) { if (segmentUrls.length > 0) { console.log(`Starting to prefetch ${segmentUrls.length} segments for ${url}`); + cleanupCache(); + Promise.all(segmentUrls.map(segmentUrl => prefetchSegment(segmentUrl, headers as HeadersInit) )).catch(error => { @@ -228,8 +309,21 @@ async function proxyM3U8(event: any) { } } +export function handleCacheStats(event: any) { + cleanupCache(); + setResponseHeaders(event, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }); + return getCacheStats(); +} + export default defineEventHandler(async (event) => { if (isPreflightRequest(event)) return handleCors(event, {}); + if (event.path === '/cache-stats') { + return handleCacheStats(event); + } + return await proxyM3U8(event); }); From 9ffd0cc58eeac1f1513452afc7926952ffcfb72f Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 21 May 2025 17:25:18 -0600 Subject: [PATCH 4/4] re add comments --- src/routes/m3u8-proxy.ts | 20 ++++++++++++++++++++ src/routes/ts-proxy.ts | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/routes/m3u8-proxy.ts b/src/routes/m3u8-proxy.ts index 80f85ac..4674ea4 100644 --- a/src/routes/m3u8-proxy.ts +++ b/src/routes/m3u8-proxy.ts @@ -16,7 +16,9 @@ function parseURL(req_url: string, baseUrl?: string) { return null; } + // Scheme is omitted if (req_url.lastIndexOf("//", 0) === -1) { + // "//" is omitted req_url = "//" + req_url; } req_url = (match[4] === "443" ? "https:" : "http:") + req_url; @@ -25,6 +27,7 @@ function parseURL(req_url: string, baseUrl?: string) { try { const parsed = new URL(req_url); if (!parsed.hostname) { + // "http://:1/" and "http:/notenoughslashes" could end up here return null; } return parsed.href; @@ -155,6 +158,9 @@ export function getCacheStats() { }; } +/** + * Proxies m3u8 files and replaces the content to point to the proxy + */ async function proxyM3U8(event: any) { const url = getQuery(event).url as string; const headersParam = getQuery(event).headers as string; @@ -179,6 +185,7 @@ async function proxyM3U8(event: any) { try { const response = await globalThis.fetch(url, { headers: { + // Default User-Agent (from src/utils/headers.ts) 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0', ...(headers as HeadersInit), } @@ -190,17 +197,20 @@ async function proxyM3U8(event: any) { const m3u8Content = await response.text(); + // Get the base URL for the host const host = getRequestHost(event); const proto = getRequestProtocol(event); const baseProxyUrl = `${proto}://${host}`; if (m3u8Content.includes("RESOLUTION=")) { + // This is a master playlist with multiple quality variants const lines = m3u8Content.split("\n"); const newLines: string[] = []; for (const line of lines) { if (line.startsWith("#")) { if (line.startsWith("#EXT-X-KEY:")) { + // Proxy the key URL const regex = /https?:\/\/[^\""\s]+/g; const keyUrl = regex.exec(line)?.[0]; if (keyUrl) { @@ -210,6 +220,7 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else if (line.startsWith("#EXT-X-MEDIA:")) { + // Proxy alternative media URLs (like audio streams) const regex = /https?:\/\/[^\""\s]+/g; const mediaUrl = regex.exec(line)?.[0]; if (mediaUrl) { @@ -222,6 +233,7 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else if (line.trim()) { + // This is a quality variant URL const variantUrl = parseURL(line, url); if (variantUrl) { newLines.push(`${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(variantUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`); @@ -229,10 +241,12 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else { + // Empty line, preserve it newLines.push(line); } } + // Set appropriate headers setResponseHeaders(event, { 'Content-Type': 'application/vnd.apple.mpegurl', 'Access-Control-Allow-Origin': '*', @@ -243,6 +257,7 @@ async function proxyM3U8(event: any) { return newLines.join("\n"); } else { + // This is a media playlist with segments const lines = m3u8Content.split("\n"); const newLines: string[] = []; @@ -251,6 +266,7 @@ async function proxyM3U8(event: any) { for (const line of lines) { if (line.startsWith("#")) { if (line.startsWith("#EXT-X-KEY:")) { + // Proxy the key URL const regex = /https?:\/\/[^\""\s]+/g; const keyUrl = regex.exec(line)?.[0]; if (keyUrl) { @@ -265,6 +281,7 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else if (line.trim() && !line.startsWith("#")) { + // This is a segment URL (.ts file) const segmentUrl = parseURL(line, url); if (segmentUrl) { segmentUrls.push(segmentUrl); @@ -274,6 +291,7 @@ async function proxyM3U8(event: any) { newLines.push(line); } } else { + // Comment or empty line, preserve it newLines.push(line); } } @@ -290,6 +308,7 @@ async function proxyM3U8(event: any) { }); } + // Set appropriate headers setResponseHeaders(event, { 'Content-Type': 'application/vnd.apple.mpegurl', 'Access-Control-Allow-Origin': '*', @@ -319,6 +338,7 @@ export function handleCacheStats(event: any) { } export default defineEventHandler(async (event) => { + // Handle CORS preflight requests if (isPreflightRequest(event)) return handleCors(event, {}); if (event.path === '/cache-stats') { diff --git a/src/routes/ts-proxy.ts b/src/routes/ts-proxy.ts index 07f2bab..b19c7c4 100644 --- a/src/routes/ts-proxy.ts +++ b/src/routes/ts-proxy.ts @@ -2,6 +2,7 @@ import { setResponseHeaders } from 'h3'; import { getCachedSegment } from './m3u8-proxy'; export default defineEventHandler(async (event) => { + // Handle CORS preflight requests if (isPreflightRequest(event)) return handleCors(event, {}); const url = getQuery(event).url as string; @@ -33,7 +34,7 @@ export default defineEventHandler(async (event) => { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*', - 'Cache-Control': 'public, max-age=3600' + 'Cache-Control': 'public, max-age=3600' // Allow caching of TS segments }); return cachedSegment.data; @@ -42,6 +43,7 @@ export default defineEventHandler(async (event) => { const response = await globalThis.fetch(url, { method: 'GET', headers: { + // Default User-Agent (from src/utils/headers.ts) 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0', ...(headers as HeadersInit), } @@ -56,9 +58,10 @@ export default defineEventHandler(async (event) => { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*', - 'Cache-Control': 'public, max-age=3600' + 'Cache-Control': 'public, max-age=3600' // Allow caching of TS segments }); + // Return the binary data directly return new Uint8Array(await response.arrayBuffer()); } catch (error: any) { console.error('Error proxying TS file:', error);