diff --git a/src/routes/m3u8-proxy.ts b/src/routes/m3u8-proxy.ts index 2d5bc5e..4674ea4 100644 --- a/src/routes/m3u8-proxy.ts +++ b/src/routes/m3u8-proxy.ts @@ -36,6 +36,128 @@ function parseURL(req_url: string, baseUrl?: string) { } } +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.size >= CACHE_MAX_SIZE) { + cleanupCache(); + } + + const existing = segmentCache.get(url); + const now = Date.now(); + if (existing && (now - existing.timestamp <= CACHE_EXPIRY_MS)) { + 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, + timestamp: Date.now() + }); + + console.log(`Prefetched and cached segment: ${url}`); + } catch (error) { + console.error(`Error prefetching segment ${url}:`, error); + } +} + +export function getCachedSegment(url: string) { + 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) + }; +} + /** * Proxies m3u8 files and replaces the content to point to the proxy */ @@ -139,6 +261,8 @@ async function proxyM3U8(event: any) { 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:")) { @@ -148,6 +272,8 @@ async function proxyM3U8(event: any) { 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); } @@ -158,6 +284,8 @@ async function proxyM3U8(event: any) { // 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); @@ -168,6 +296,18 @@ 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 => { + console.error('Error prefetching segments:', error); + }); + } + // Set appropriate headers setResponseHeaders(event, { 'Content-Type': 'application/vnd.apple.mpegurl', @@ -188,9 +328,22 @@ 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) => { // Handle CORS preflight requests if (isPreflightRequest(event)) return handleCors(event, {}); + if (event.path === '/cache-stats') { + return handleCacheStats(event); + } + return await proxyM3U8(event); -}); \ No newline at end of file +}); diff --git a/src/routes/ts-proxy.ts b/src/routes/ts-proxy.ts index 1715628..b19c7c4 100644 --- a/src/routes/ts-proxy.ts +++ b/src/routes/ts-proxy.ts @@ -1,4 +1,5 @@ import { setResponseHeaders } from 'h3'; +import { getCachedSegment } from './m3u8-proxy'; export default defineEventHandler(async (event) => { // Handle CORS preflight requests @@ -25,6 +26,20 @@ 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' // Allow caching of TS segments + }); + + return cachedSegment.data; + } + const response = await globalThis.fetch(url, { method: 'GET', headers: { @@ -38,13 +53,12 @@ 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' // Allow caching of TS segments }); // Return the binary data directly @@ -56,4 +70,4 @@ export default defineEventHandler(async (event) => { statusMessage: error.message || 'Error proxying TS file' })); } -}); \ No newline at end of file +});