| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| const MEDIA_FILE_EXTENSIONS = [ |
| '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', |
| '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', |
| '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' |
| ]; |
| const MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/']; |
| |
|
|
|
|
| |
| |
| |
| |
| export async function onRequest(context) { |
| const { request, env, next, waitUntil } = context; |
| const url = new URL(request.url); |
|
|
| |
| const DEBUG_ENABLED = (env.DEBUG === 'true'); |
| const CACHE_TTL = parseInt(env.CACHE_TTL || '86400'); |
| const MAX_RECURSION = parseInt(env.MAX_RECURSION || '5'); |
| |
| let USER_AGENTS = [ |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' |
| ]; |
| try { |
| |
| const agentsJson = env.USER_AGENTS_JSON; |
| if (agentsJson) { |
| const parsedAgents = JSON.parse(agentsJson); |
| if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { |
| USER_AGENTS = parsedAgents; |
| } else { |
| logDebug("环境变量 USER_AGENTS_JSON 格式无效或为空,使用默认值"); |
| } |
| } |
| } catch (e) { |
| logDebug(`解析环境变量 USER_AGENTS_JSON 失败: ${e.message},使用默认值`); |
| } |
| |
|
|
|
|
| |
|
|
| |
| function logDebug(message) { |
| if (DEBUG_ENABLED) { |
| console.log(`[Proxy Func] ${message}`); |
| } |
| } |
|
|
| |
| function getTargetUrlFromPath(pathname) { |
| |
| |
| const encodedUrl = pathname.replace(/^\/proxy\//, ''); |
| if (!encodedUrl) return null; |
| try { |
| |
| let decodedUrl = decodeURIComponent(encodedUrl); |
|
|
| |
| if (!decodedUrl.match(/^https?:\/\//i)) { |
| |
| if (encodedUrl.match(/^https?:\/\//i)) { |
| decodedUrl = encodedUrl; |
| logDebug(`Warning: Path was not encoded but looks like URL: ${decodedUrl}`); |
| } else { |
| logDebug(`无效的目标URL格式 (解码后): ${decodedUrl}`); |
| return null; |
| } |
| } |
| return decodedUrl; |
|
|
| } catch (e) { |
| logDebug(`解码目标URL时出错: ${encodedUrl} - ${e.message}`); |
| return null; |
| } |
| } |
|
|
| |
| function createResponse(body, status = 200, headers = {}) { |
| const responseHeaders = new Headers(headers); |
| |
| responseHeaders.set("Access-Control-Allow-Origin", "*"); |
| responseHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); |
| responseHeaders.set("Access-Control-Allow-Headers", "*"); |
|
|
| |
| if (request.method === "OPTIONS") { |
| |
| return new Response(null, { |
| status: 204, |
| headers: responseHeaders |
| }); |
| } |
|
|
| return new Response(body, { status, headers: responseHeaders }); |
| } |
|
|
| |
| function createM3u8Response(content) { |
| return createResponse(content, 200, { |
| "Content-Type": "application/vnd.apple.mpegurl", |
| "Cache-Control": `public, max-age=${CACHE_TTL}` |
| }); |
| } |
|
|
| |
| function getRandomUserAgent() { |
| return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; |
| } |
|
|
| |
| function getBaseUrl(urlStr) { |
| try { |
| const parsedUrl = new URL(urlStr); |
| |
| if (!parsedUrl.pathname || parsedUrl.pathname === '/') { |
| return `${parsedUrl.origin}/`; |
| } |
| const pathParts = parsedUrl.pathname.split('/'); |
| pathParts.pop(); |
| return `${parsedUrl.origin}${pathParts.join('/')}/`; |
| } catch (e) { |
| logDebug(`获取 BaseUrl 时出错: ${urlStr} - ${e.message}`); |
| |
| const lastSlashIndex = urlStr.lastIndexOf('/'); |
| |
| return lastSlashIndex > urlStr.indexOf('://') + 2 ? urlStr.substring(0, lastSlashIndex + 1) : urlStr + '/'; |
| } |
| } |
|
|
|
|
| |
| function resolveUrl(baseUrl, relativeUrl) { |
| |
| if (relativeUrl.match(/^https?:\/\//i)) { |
| return relativeUrl; |
| } |
| try { |
| |
| return new URL(relativeUrl, baseUrl).toString(); |
| } catch (e) { |
| logDebug(`解析 URL 失败: baseUrl=${baseUrl}, relativeUrl=${relativeUrl}, error=${e.message}`); |
| |
| if (relativeUrl.startsWith('/')) { |
| |
| const urlObj = new URL(baseUrl); |
| return `${urlObj.origin}${relativeUrl}`; |
| } |
| |
| return `${baseUrl.replace(/\/[^/]*$/, '/')}${relativeUrl}`; |
| } |
| } |
|
|
| |
| function rewriteUrlToProxy(targetUrl) { |
| |
| return `/proxy/${encodeURIComponent(targetUrl)}`; |
| } |
|
|
| |
| async function fetchContentWithType(targetUrl) { |
| const headers = new Headers({ |
| 'User-Agent': getRandomUserAgent(), |
| 'Accept': '*/*', |
| |
| 'Accept-Language': request.headers.get('Accept-Language') || 'zh-CN,zh;q=0.9,en;q=0.8', |
| |
| 'Referer': request.headers.get('Referer') || new URL(targetUrl).origin |
| }); |
|
|
| try { |
| |
| logDebug(`开始直接请求: ${targetUrl}`); |
| |
| const response = await fetch(targetUrl, { headers, redirect: 'follow' }); |
|
|
| if (!response.ok) { |
| const errorBody = await response.text().catch(() => ''); |
| logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`); |
| throw new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 150)}`); |
| } |
|
|
| |
| const content = await response.text(); |
| const contentType = response.headers.get('Content-Type') || ''; |
| logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`); |
| return { content, contentType, responseHeaders: response.headers }; |
|
|
| } catch (error) { |
| logDebug(`请求彻底失败: ${targetUrl}: ${error.message}`); |
| |
| throw new Error(`请求目标URL失败 ${targetUrl}: ${error.message}`); |
| } |
| } |
|
|
| |
| function isM3u8Content(content, contentType) { |
| |
| if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { |
| return true; |
| } |
| |
| return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); |
| } |
|
|
| |
| function isMediaFile(url, contentType) { |
| if (contentType) { |
| for (const mediaType of MEDIA_CONTENT_TYPES) { |
| if (contentType.toLowerCase().startsWith(mediaType)) { |
| return true; |
| } |
| } |
| } |
| const urlLower = url.toLowerCase(); |
| for (const ext of MEDIA_FILE_EXTENSIONS) { |
| if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { |
| return true; |
| } |
| } |
| return false; |
| } |
|
|
| |
| function processKeyLine(line, baseUrl) { |
| return line.replace(/URI="([^"]+)"/, (match, uri) => { |
| const absoluteUri = resolveUrl(baseUrl, uri); |
| logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`); |
| return `URI="${rewriteUrlToProxy(absoluteUri)}"`; |
| }); |
| } |
|
|
| |
| function processMapLine(line, baseUrl) { |
| return line.replace(/URI="([^"]+)"/, (match, uri) => { |
| const absoluteUri = resolveUrl(baseUrl, uri); |
| logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`); |
| return `URI="${rewriteUrlToProxy(absoluteUri)}"`; |
| }); |
| } |
|
|
| |
| function processMediaPlaylist(url, content) { |
| const baseUrl = getBaseUrl(url); |
| const lines = content.split('\n'); |
| const output = []; |
|
|
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i].trim(); |
| |
| if (!line && i === lines.length - 1) { |
| output.push(line); |
| continue; |
| } |
| if (!line) continue; |
|
|
| if (line.startsWith('#EXT-X-KEY')) { |
| output.push(processKeyLine(line, baseUrl)); |
| continue; |
| } |
| if (line.startsWith('#EXT-X-MAP')) { |
| output.push(processMapLine(line, baseUrl)); |
| continue; |
| } |
| if (line.startsWith('#EXTINF')) { |
| output.push(line); |
| continue; |
| } |
| if (!line.startsWith('#')) { |
| const absoluteUrl = resolveUrl(baseUrl, line); |
| logDebug(`重写媒体片段: 原始='${line}', 绝对='${absoluteUrl}'`); |
| output.push(rewriteUrlToProxy(absoluteUrl)); |
| continue; |
| } |
| |
| output.push(line); |
| } |
| return output.join('\n'); |
| } |
|
|
| |
| async function processM3u8Content(targetUrl, content, recursionDepth = 0, env) { |
| if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { |
| logDebug(`检测到主播放列表: ${targetUrl}`); |
| return await processMasterPlaylist(targetUrl, content, recursionDepth, env); |
| } |
| logDebug(`检测到媒体播放列表: ${targetUrl}`); |
| return processMediaPlaylist(targetUrl, content); |
| } |
|
|
| |
| async function processMasterPlaylist(url, content, recursionDepth, env) { |
| if (recursionDepth > MAX_RECURSION) { |
| throw new Error(`处理主列表时递归层数过多 (${MAX_RECURSION}): ${url}`); |
| } |
|
|
| const baseUrl = getBaseUrl(url); |
| const lines = content.split('\n'); |
| let highestBandwidth = -1; |
| let bestVariantUrl = ''; |
|
|
| for (let i = 0; i < lines.length; i++) { |
| if (lines[i].startsWith('#EXT-X-STREAM-INF')) { |
| const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/); |
| const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0; |
|
|
| let variantUriLine = ''; |
| for (let j = i + 1; j < lines.length; j++) { |
| const line = lines[j].trim(); |
| if (line && !line.startsWith('#')) { |
| variantUriLine = line; |
| i = j; |
| break; |
| } |
| } |
|
|
| if (variantUriLine && currentBandwidth >= highestBandwidth) { |
| highestBandwidth = currentBandwidth; |
| bestVariantUrl = resolveUrl(baseUrl, variantUriLine); |
| } |
| } |
| } |
|
|
| if (!bestVariantUrl) { |
| logDebug(`主列表中未找到 BANDWIDTH 或 STREAM-INF,尝试查找第一个子列表引用: ${url}`); |
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i].trim(); |
| if (line && !line.startsWith('#') && (line.endsWith('.m3u8') || line.includes('.m3u8?'))) { |
| bestVariantUrl = resolveUrl(baseUrl, line); |
| logDebug(`备选方案:找到第一个子列表引用: ${bestVariantUrl}`); |
| break; |
| } |
| } |
| } |
|
|
| if (!bestVariantUrl) { |
| logDebug(`在主列表 ${url} 中未找到任何有效的子播放列表 URL。可能格式有问题或仅包含音频/字幕。将尝试按媒体列表处理原始内容。`); |
| return processMediaPlaylist(url, content); |
| } |
|
|
| |
|
|
| const cacheKey = `m3u8_processed:${bestVariantUrl}`; |
|
|
| let kvNamespace = null; |
| try { |
| kvNamespace = env.LIBRETV_PROXY_KV; |
| if (!kvNamespace) throw new Error("KV 命名空间未绑定"); |
| } catch (e) { |
| logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`); |
| kvNamespace = null; |
| } |
|
|
| if (kvNamespace) { |
| try { |
| const cachedContent = await kvNamespace.get(cacheKey); |
| if (cachedContent) { |
| logDebug(`[缓存命中] 主列表的子列表: ${bestVariantUrl}`); |
| return cachedContent; |
| } else { |
| logDebug(`[缓存未命中] 主列表的子列表: ${bestVariantUrl}`); |
| } |
| } catch (kvError) { |
| logDebug(`从 KV 读取缓存失败 (${cacheKey}): ${kvError.message}`); |
| |
| } |
| } |
|
|
| logDebug(`选择的子列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`); |
| const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl); |
|
|
| if (!isM3u8Content(variantContent, variantContentType)) { |
| logDebug(`获取到的子列表 ${bestVariantUrl} 不是 M3U8 内容 (类型: ${variantContentType})。可能直接是媒体文件,返回原始内容。`); |
| |
| |
| |
| |
| |
| |
| return processMediaPlaylist(bestVariantUrl, variantContent); |
|
|
| } |
|
|
| const processedVariant = await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1, env); |
|
|
| if (kvNamespace) { |
| try { |
| |
| |
| waitUntil(kvNamespace.put(cacheKey, processedVariant, { expirationTtl: CACHE_TTL })); |
| logDebug(`已将处理后的子列表写入缓存: ${bestVariantUrl}`); |
| } catch (kvError) { |
| logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`); |
| |
| } |
| } |
|
|
| return processedVariant; |
| } |
|
|
| |
|
|
| try { |
| const targetUrl = getTargetUrlFromPath(url.pathname); |
|
|
| if (!targetUrl) { |
| logDebug(`无效的代理请求路径: ${url.pathname}`); |
| return createResponse("无效的代理请求。路径应为 /proxy/<经过编码的URL>", 400); |
| } |
|
|
| logDebug(`收到代理请求: ${targetUrl}`); |
|
|
| |
| const cacheKey = `proxy_raw:${targetUrl}`; |
| let kvNamespace = null; |
| try { |
| kvNamespace = env.LIBRETV_PROXY_KV; |
| if (!kvNamespace) throw new Error("KV 命名空间未绑定"); |
| } catch (e) { |
| logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`); |
| kvNamespace = null; |
| } |
|
|
| if (kvNamespace) { |
| try { |
| const cachedDataJson = await kvNamespace.get(cacheKey); |
| if (cachedDataJson) { |
| logDebug(`[缓存命中] 原始内容: ${targetUrl}`); |
| const cachedData = JSON.parse(cachedDataJson); |
| const content = cachedData.body; |
| let headers = {}; |
| try { headers = JSON.parse(cachedData.headers); } catch(e){} |
| const contentType = headers['content-type'] || headers['Content-Type'] || ''; |
|
|
| if (isM3u8Content(content, contentType)) { |
| logDebug(`缓存内容是 M3U8,重新处理: ${targetUrl}`); |
| const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env); |
| return createM3u8Response(processedM3u8); |
| } else { |
| logDebug(`从缓存返回非 M3U8 内容: ${targetUrl}`); |
| return createResponse(content, 200, new Headers(headers)); |
| } |
| } else { |
| logDebug(`[缓存未命中] 原始内容: ${targetUrl}`); |
| } |
| } catch (kvError) { |
| logDebug(`从 KV 读取或解析缓存失败 (${cacheKey}): ${kvError.message}`); |
| |
| } |
| } |
|
|
| |
| const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl); |
|
|
| |
| if (kvNamespace) { |
| try { |
| const headersToCache = {}; |
| responseHeaders.forEach((value, key) => { headersToCache[key.toLowerCase()] = value; }); |
| const cacheValue = { body: content, headers: JSON.stringify(headersToCache) }; |
| |
| waitUntil(kvNamespace.put(cacheKey, JSON.stringify(cacheValue), { expirationTtl: CACHE_TTL })); |
| logDebug(`已将原始内容写入缓存: ${targetUrl}`); |
| } catch (kvError) { |
| logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`); |
| |
| } |
| } |
|
|
| |
| if (isM3u8Content(content, contentType)) { |
| logDebug(`内容是 M3U8,开始处理: ${targetUrl}`); |
| const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env); |
| return createM3u8Response(processedM3u8); |
| } else { |
| logDebug(`内容不是 M3U8 (类型: ${contentType}),直接返回: ${targetUrl}`); |
| const finalHeaders = new Headers(responseHeaders); |
| finalHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`); |
| |
| finalHeaders.set("Access-Control-Allow-Origin", "*"); |
| finalHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); |
| finalHeaders.set("Access-Control-Allow-Headers", "*"); |
| return createResponse(content, 200, finalHeaders); |
| } |
|
|
| } catch (error) { |
| logDebug(`处理代理请求时发生严重错误: ${error.message} \n ${error.stack}`); |
| return createResponse(`代理处理错误: ${error.message}`, 500); |
| } |
| } |
|
|
| |
| export async function onOptions(context) { |
| |
| return new Response(null, { |
| status: 204, |
| headers: { |
| "Access-Control-Allow-Origin": "*", |
| "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", |
| "Access-Control-Allow-Headers": "*", |
| "Access-Control-Max-Age": "86400", |
| }, |
| }); |
| } |
|
|